前回記事【ディープラーニングで錠剤・カプセル識別システムを自作する(1)】からの続きになります。前回は自作撮影ボックスで4方向からのLED光を照射した画像を撮るところまで書きました。今回はOpenCVを使った画像処理の部分について書いていきたいと思います。
錠剤・カプセルの画像について
前回を含め、これ以降もたくさんの錠剤・カプセルの画像が出てきますが、これらはすべて期限切れや地面に落としたりで廃棄処分になったものを用いています。
中には製造後数年経過しており表面が変色・劣化していたりするものもありましたが、本記事を含め関連する記事の内容は実際の現場で使用するものではないため、自分の興味・学習の用途では差し支えないと判断してそのまま使用しています。
現在は流通していない薬剤や、同じ薬品名でも今のものとは刻印・印字が異なるものが含まれている点をご留意ください。
錠剤・カプセルの抽出

この工程では背景画像から差分を取ることで錠剤・カプセル以外の部分を消し去ります。
4枚を合成した画像には、シャーレ縁の光の反射や、底面のわずかな明るさのムラが存在します。ここで単純に明るさだけを元にして錠剤の抽出を行おうとすると、縁部分も検出してしまったり暗い色の錠剤が影と同化してしまいます。
そこで、事前に撮っておいた錠剤のない背景画像との差分(変化)を取ることで最初からそこにある固定の反射や影などを相殺することを狙います。
基本的には、この方法(背景画像との差分を取る)→閾値処理というシンプルなやり方で大抵の錠剤・カプセルは抽出できました。しかし、透明度の高いカプセルの場合は背景がそのまま透けるため差分が小さく、ほとんどの場合で上手く抽出することができませんでした。
透明カプセルの抽出が難しい

このシンプルな方法を透明カプセルを含む画像(上と同じ)に対して行った場合です。
少し小さい画像ですが、左から順に
- 平滑化した元画像と背景画像の差分
- 閾値処理後
- モルフォロジー処理 & シャーレ縁除外後
- マスク処理後
です。画像中の2つの透明カプセルは背景との差があまりないため、左から2番目の画像の時点でほとんど黒く潰れてしまっています。
そこで、元画像をHSVに変換して色相を取り出して見てみると下側の透明カプセルを拾えることがわかったため*1、これを白黒反転し、明度チャンネルの背景差分画像とOR演算*2する、という方法を取ることにしました。
下画像は左から、
- H(色相)を取り出した画像
- V(明度)を取り出した元画像と背景画像の差分
です。

錠剤とカプセルのだいたいの形が拾えれば、次はモルフォロジー変換で虫食いになっているところやノイズのある所をきれいにします。
その後は、求めておいたシャーレの中心と半径*3からシャーレの縁ー外側のノイズをカットして、最後に輪郭の面積が一定の大きさ以下の部分は捨てる処理を行います。

カプセルは自身の影を拾ってしまったり、虫食い状態がきれいにならなかったり、シャーレの縁付近では光が反射してたりで、かなり悪戦苦闘することになりました。閾値やモルフォロジー処理はこちらが上手くいけば、あちらが上手くいかず、といった具合でしたがある程度のところで見切りをつけました。
こちらは失敗例。シャーレ縁ギリギリにあるものは失敗しやすく、錠剤・カプセルがあまり縁に当たらないようして撮影することにしました。
ただ、それでも自身の影を拾うことは完全には避けられなかったので、ある程度のところでOKとしました。
下は上手くできている例ですが、中には錠剤でもわずかに影を拾っているものもあります。

def extract_pills(img, bg, center=(1748, 1242), radius=780, min_area=5000, threshold_h=50, threshold_v=25): """背景差分とHSV色相を組み合わせて錠剤・カプセル領域を抽出する""" # HSVに変換して色相, 明度を取り出す img_hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) bg_hsv = cv2.cvtColor(bg, cv2.COLOR_BGR2HSV) img_h, _, img_v = cv2.split(img_hsv) _, _, bg_v = cv2.split(bg_hsv) # Hを白黒反転させる _, th_img_h = cv2.threshold(img_h, threshold_h, 255, cv2.THRESH_BINARY_INV) # 背景画像は明度で差分をとる diff_v = cv2.absdiff(img_v, bg_v) _, diff_v = cv2.threshold(diff_v, threshold_v, 255, cv2.THRESH_BINARY) # OR演算で白を合わせる diff = cv2.bitwise_or(th_img_h, diff_v) # モルフォロジー処理 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask = cv2.morphologyEx(diff, cv2.MORPH_OPEN, kernel, iterations=2) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=1) # シャーレ以外のノイズをカット circle_mask = np.zeros_like(mask) cv2.circle(circle_mask, center, radius, 255, -1) mask = cv2.bitwise_and(mask, circle_mask) # 輪郭を見つけて塗りつぶす contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) final_mask = np.zeros_like(mask) for cnt in contours: area = cv2.contourArea(cnt) if area > min_area: cv2.drawContours(final_mask, [cnt], -1, 255, -1) return cv2.bitwise_and(img, img, mask=final_mask)
個別画像の切り出し
次に、先で得た抽出画像から個々の薬を切り出します。
contours = _get_pill_contours(gray_img, min_area=min_area)
results = []
for cnt in contours:
# 個別マスクで他の錠剤を除外
single_mask = np.zeros_like(gray_img)
cv2.drawContours(single_mask, [cnt], -1, 255, -1)
isolated_pill = cv2.bitwise_and(img, img, mask=single_mask)
# 切り抜き
crop = _crop_single_pill(isolated_pill, cnt, final_size)
if crop is None:
continue
# 計測
measure = _measure_contour(cnt, scales) if scales is not None else {}
results.append({"crop": crop, **measure})
DMLモデルの入力サイズが224×224なので、当初はそのサイズで切り出した画像を返すようにしていましたが、データ拡張時にRandomCrop*4を使うため途中から256×256に切り抜くようにしました。

処理としては錠剤・カプセルの輪郭を取得して切り抜き、256×256にリサイズするだけですが、下のような処理も行っています。
- 隣の錠剤の写り込みを防ぐため、対象以外の錠剤をマスクで除外してから切り抜く
- 丸型の場合は輪郭に錠剤の影が含まれる場合を考慮し、短辺の半径に20%のマージンを加えた範囲で切り出す
- 楕円形の場合は長辺の半分に20%マージンを取り、長辺が水平になるよう角度補正を行う
ここではDMLモデル用の画像を用意するのが目的ですが、後段の物理性状フィルタでのサイズ計測でも同じ輪郭を使用しています。
そのため、画像を返す際に形状(丸型か楕円形か)・実測サイズ・cv2.minAreaRectの戻り値*5の情報もセットにして付加するようにしています。
形状の区分については、本来は「カプセル」や「涙型」等もあった方が正確ですが、今回はシンプルに丸か楕円の2択にしました。なお、丸型とは長辺 / 短辺の比率が1.2を超えないもの、としました。
50種類までは224×224の固定サイズで切り抜いていました。しかし、もし224pxを超えるサイズの錠剤があった場合に枠からはみ出ることになってしまったり、小さい錠剤の場合は情報のない余白(0)が多い、スカスカの画像をモデルに与えることになってしまいます。
そのため錠剤・カプセルのサイズに合わせてマージンを加えた範囲で切り出す方法に
変更することで、モデルにより詳細な情報を与えられるようにしました。

下は旧手法(固定サイズ)での切り出し例です。一律に224pxで切り抜いているので、枠に対して錠剤・カプセルの大きさはバラバラです。

この方法だと、個々の錠剤の「相対的な大きさ」という情報もモデルは学習することになります。言い換えると、割線や刻印・印字情報などではなく、大きさ(余白の多さ)を頼りに判定してしまう可能性が出てきます。*6
そのためDMLには純粋に刻印や形だけを学習させ、大きさの要素は持ち込まずにその情報は別途フィルタとして使おう、という方向性を決めました。(データセットも一から作り直しになりましたが…。)
楕円形薬剤の回転について
今回の切り出しでは、楕円形の場合は切り出し前に横向きになるよう回転を正規化しています。*7しかし、フレームいっぱいに切り抜く、という方針なのに楕円形は横向きのままでいいのか?という疑問が湧いてきます。
つまり、楕円形の場合は下のように対角線配置にするのが合理的(方針と矛盾しない)ということになります。

こうすると水平配置よりも単純に×√2倍(=約1.4倍)長径が大きくなり、より高解像度で薬を切り取れます。
(理由は不明ですが、デモ画像を見る限り富士フィルムのシステムでもカプセル画像は対角線配置になっています。患者さんに渡す薬の説明書で対角線配置になっているものは私は見たことがありません。)
もしこの配置を採用した場合、学習時のデータ拡張において楕円形薬にはRandomRotation(ランダム回転)は使わずに、回転については180°回転のみを適用することになります。本番環境でも常に対角線配置されたものになるので、ランダム回転させると意味のない学習をすることになるからです。
楕円形薬剤の対角線配置と水平配置の180度回転。
切り出し時に角度を正規化するとモデルは常にどちらかだけを見ることになります。
(ブレを考慮するとここからさらに±5°くらいの回転はあってもいいかも)
しかし水平配置でもモデルが見る角度が固定されることには変わりありません。対角線配置と異なるのは、水平配置の場合はランダム回転させても枠外にはみ出ることがない、という点です。無駄な角度も学習することになりますが、学習時の処理を円形と分けずにひとまとめにできるという実装上のメリットがあります。
まとめると:
(ⅰ)水平配置で切り出し、学習時は円形と一緒にランダム回転させる(実装が楽)
(ⅱ)対角線配置で切り出し、学習時は楕円形だけランダム回転を外す(切り抜き最大限)
水平で切り出してデータセットを作り直したうえ、学習させる段階でこのことに気付いたため、もう一度作り直すだけの価値があるか悩みました。*8
結局、取り敢えずそのまま進めて実際に(ⅰ)の方法で精度を見たところ、実用上まったく問題ないレベルで高く、念のため楕円形と円形でどれくらい精度が変わるかも調べたところ、それほど大差なかったので対角線配置でデータ拡張を分ける方法は今回は見送ることにしました。
形状情報を持つDBも作っていたので、水平配置のデータセットで円形と楕円形でデータ拡張を分ける学習もしてみましたが、これもそんなに変わりませんでした。このあたりはデータセットがもっと巨大で、含まれる楕円形の種類ももっと多かったら違っていたかもしれません。
ということで、今回のDMLモデルの学習時には、楕円形も円形もひとまとめにして(力技)RandomRotationしています。
次回はDMLの学習と評価について書きたいと思います。
*1:上側カプセルは閾値処理で対応
*2:どちらか一方、または両方が白であれば結果が白になる演算
*3:cv2.HoughCirclesを使った円を検出する別コードを作成
*4:画像データの一部をランダムな位置で切り抜く処理
*5:物体(輪郭)を囲む最小面積の回転した長方形
*6:調べたところ相対的な大きさ情報を入れるとショートカット学習(Shortcut Learning)が起きる可能性が高くなる、とのことでした。顔認識モデルでも顔の大きさに関わらずフレームいっぱいのサイズになるようにしていますね。
*7:最初は薬情のように水平にしておいた方が見栄えが良いだろう程度の軽い気持ちでした。
*8:そもそも、見栄えを気にするだけなら切り出し時に回転を正規化する必要はなく、最終表示の時にするのが正解でした。