薬剤師のプログラミング学習日記

プログラミングやコンピュータに関する記事を書いていきます

錠剤画像の刻印(識別コード)を強調させて表示する

薬剤師をしていると調剤された薬の監査や持ち込まれた薬の鑑別を毎日のようにしますが、現在はこれらの業務の支援システムがいろんなメーカーから出ているようです。
共通してみられる機能に、撮影した錠剤の画像から薬がどの薬であるかを推定できる、あるいは候補となる薬を表示してくれる、などがあります。
薬の判定を画像からできるようにするのはだいぶ難しそうだな、と思っていろいろ見ていたのですが、錠剤の刻印を強調して一包ずつの中身を画面表示する機能を持つというシステムを見つけて、興味をひかれました。私自身はこのようなシステムを使ったことはありませんが、この錠剤画像の刻印の強調をPython+OpenCVで似たようなことができないかと思い、やってみました。
f:id:enokisaute:20210626142342j:plain


環境

  • Windows10
  • Anaconda3 / Python 3.7.6
  • OpenCV 4.5.1

撮影はラズベリーパイとカメラモジュールを使って下のような機材を作って行いました。カメラを下向きに取り付けて固定しています。
f:id:enokisaute:20210626160228j:plain
撮影時は特にオプションは指定せずに撮っているため静止画のサイズは3280×2464です。

  • Raspberry Pi 3 Model B+
  • Raspberry Pi Camera Module V2



錠剤画像の撮影方法

今回試す方法では、光の当たる方向が異なる複数の画像を1枚に合成します。撮った写真は光の当たり方以外は同じである必要があるため、カメラを固定(対象までの距離は約12cm)しています。
この光の当て方を変えた写真を1枚にするという方法は、以下のリンク先にある富士フィルムさんの動画『光で刻印を見やすくする技術』を参考にしました。多方向から光を当てることで陰影を強調して、刻印を見やすくするという方法です。

当てる光については、とりあえず自分のスマホのライトを用いることにしました。スマホを横に寝かせてライトを4方向から当てて、4枚の写真を撮ります。ライトの角度は手でスマホを支えながら撮ったので適当です。
しかし実際には、良好な結果を得るためにこれらの撮影環境の条件(光源の角度やピント調整、対象との距離など)というのはできる限り厳密にしておいた方が良いでしょう。
なお、錠剤は一包化されたものではなくトレイに入れて撮影しました。

次のような4枚の写真が撮れました。
f:id:enokisaute:20210626162550j:plain

適応的しきい値処理で輪郭を強調する

錠剤の刻印を強調するには、エッジ(画像中で輝度が大きく変化しているところ)抽出による方法も考えられますが、今回は2値化処理によって強調しました。*1

まず2値化の前にノイズを除去しますが、強調することになる錠剤の刻印部分はできるだけはっきりと残しておきたいところです。
ここでは、輪郭などのエッジをできるだけぼやかさずにノイズ除去ができるバイラテラルフィルタを使いました。空間的な距離だけでなく輝度の差を考慮して求めた重みも利用するフィルタです。
OpenCVではcv2.bilateralFilter関数を使います。

_img = cv2.imread(file)
_img = cv2.bilateralFilter(_img, 5, 12, 12)   # src, d, sigmaColor, sigmaSpace
_img = cv2.bilateralFilter(_img, 5, 12, 12)
_img = cv2.bilateralFilter(_img, 5, 12, 12)

cv2.bilateralFilter関数のパラメータは以下の通りです。

src 入力画像(グレースケールまたはカラー)
d フィルタリングで使用されるカーネルのサイズ. 正の値でなければsigmaSpaceから自動で計算される。大きくすると処理速度が低速になる。
sigmaColor 色空間における標準偏差。大きくすると色的により遠くのピクセルが混ぜ合わせられる。つまり、より平滑化される。
sigmaSpace 座標空間における標準偏差。大きくすると位置的により遠くのピクセルが混ぜ合わせられる。d>0以外の場合に使用される。

dに正の値を入れておけばsigmaSpaceは無視されるようですが、簡単にするためsigmaColorと同じ値にしているコード例が多いようです。他、ドキュメントによればdの値はリアルタイムアプリケーションではd=5を、重いノイズフィルタを必要とするオフラインのアプリケーションにはd=9が推奨されています。
1回でパラメータを調整するよりも何回か繰り返した方が効果的とのことで3回繰り返しました。下画像はバイラテラルフィルタとガウシアンフィルタの処理後画像の比較です。拡大してみると、ぼかしが入っていてもバイラテラルの方はガウシアンに比べると刻印部分がはっきりとしています。
f:id:enokisaute:20210626130427j:plain

次は適応的しきい値処理を行います。
適応的しきい値処理では画像の全体ではなく小さな領域ごとにしきい値の計算を行います。光源環境が変わってしまうような画像に対して、単純なしきい値処理よりも良い結果が得られるため、今回のような片側だけ光が当たっている(もしくは影ができている)ような画像に対してはうってつけの2値化処理です。
OpenCVではcv2.adaptiveThreshold 関数を使用します。

# 適応的しきい値処理
g_img = cv2.cvtColor(_img, cv2.COLOR_BGR2GRAY)  # グレースケールに変換
th_img = cv2.adaptiveThreshold(g_img,        # src
            255,                             # maxValue
            cv2.ADAPTIVE_THRESH_GAUSSIAN_C,  # adaptiveMethod
            cv2.THRESH_BINARY,               # thresholdType
            17,                              # blockSize
            5)                               # C

cv2.adaptiveThreshold 関数のパラメータは以下のとおりです。

src
グレースケールの入力画像
maxValue
条件が満たされるピクセルに割り当てられる輝度値
adaptiveMethod
しきい値計算のアルゴリズム: ADAPTIVE_THRESH_MEAN_Cなら近傍領域の平均からCを引いた値. またはADAPTIVE_THRESH_GAUSSIAN_Cなら近傍領域の加重平均(重みが正規分布)からCを引いた値
thresholdType
しきい値の種類. THRESH_BINARY ならsrcがしきい値よりも大きければmaxValue. またはTHRESH_BINARY_INVならsrcがしきい値よりも小さければmaxValue
blockSize
しきい値を計算するために使用されるピクセル近傍領域のサイズ:3、5、7などの奇数を指定
C
計算されたしきい値から減算される定数

しきい値計算の方法について、adaptiveMethodがADAPTIVE_THRESH_GAUSSIAN_Cの場合、blockSize×blockSizeのガウシアンフィルタ(中心ピクセルが最も大きい重みで周辺にいくにしたがって小さな重みになる)で領域をぼかしてから、Cを引いてしきい値を求めています。
これによって、光が当たって明るい部分でも、影になって暗くなっている部分でも周囲が同じような値の所ではしきい値を超えるようになるというわけですね。
当然、Cを大きくするとしきい値は小さくなるので、処理後の画像はどんどん白くなっていきます*2
この関数については以下のサイトが参考になりました。
OpenCvSharpをつかう その15(適応的閾値処理) - schima.hatenablog.com
【OpenCV/Python】adaptiveThresholdの処理アルゴリズム | イメージングソリューション
ここまでで次のような4枚の2値化画像が得られました(一部切り取り)。
f:id:enokisaute:20210626132655p:plain

処理した画像を合成する

2値化した4枚の画像を1つの画像に合成していきます。
cv2.addWeighted()関数では2つの画像に重み付けをして足し算します。

\texttt{dst = α・img1+β・img2+γ  ※ β=1-α}

ここではどちらも同じ重みにするのでαは0.5、γは0に設定して2枚の画像を合成しました。

def blend(img1, img2):
    return cv2.addWeighted(src1=img1, alpha=0.5, src2=img2, beta=0.5, gamma=0)
 
# 適応的しきい値処理してから合成する
b_img1 = blend(threshold(png_file1), threshold(png_file2))
b_img2 = blend(threshold(png_file3), threshold(png_file4))
b_img3 = blend(b_img1, b_img2)
b_img = cv2.fastNlMeansDenoising(b_img3, h=20)  # h:フィルタの強さを決定するパラメータ

画像は4枚あるので、4枚→2枚→1枚とするためcv2.addWeighted()を計3回行います。
まだ少しノイズが残っていたのでv2.fastNlMeansDenoising()で除去しています。
これらの処理によって得られた画像がこちらです。
f:id:enokisaute:20210626134221p:plain

最後に元画像のうちの1枚と上の画像をもう一度cv2.addWeightedで合成します。しかし、処理後の上の画像は1チャンネル画像でそのままではカラー画像と合成することはできないので、cv2.cvtColor(b_img, cv2.COLOR_GRAY2BGR)で3チャンネルに戻してやります。

org_img = cv2.imread(png_file1)  # 元画像4枚のうちの1枚
processed_img = cv2.cvtColor(b_img, cv2.COLOR_GRAY2BGR)  # 3チャンネルに戻す

highlight_img = blend(org_img, processed_img)
cv2.imwrite(DIR + 'highlight_img.png', highlight_img)


最終的にこちらのような画像になりました。
f:id:enokisaute:20210626141440j:plain

錠剤によっては少しかすれている部分もありますが、読み取れる程度には強調できたと思います*3。違う画像で試しても、撮影条件が同じならそこそこ綺麗にできました。
f:id:enokisaute:20210626150635j:plain

参考

Python+OpenCVを利用した二値化処理|ドローンBiz (ドローンビズ)
画像の算術演算 — OpenCV-Python Tutorials

import os
import cv2
 
# 錠剤画像の刻印を強調する
 
def blend(img1, img2):
    return cv2.addWeighted(src1=img1,alpha=0.5,src2=img2,beta=0.5,gamma=0)
 
def threshold(file):
    _img = cv2.imread(file)
    _img = cv2.bilateralFilter(_img, 5, 12, 12)   # src, d, sigmaColor, sigmaSpace
    _img = cv2.bilateralFilter(_img, 5, 12, 12)
    _img = cv2.bilateralFilter(_img, 5, 12, 12)
    g_img = cv2.cvtColor(_img, cv2.COLOR_BGR2GRAY)
    th_img = cv2.adaptiveThreshold(g_img,           # src
                    255,                            # maxValue
                    cv2.ADAPTIVE_THRESH_GAUSSIAN_C, # adaptiveMethod
                    cv2.THRESH_BINARY,              # thresholdType
                    17,                             # blockSize
                    5)                              # C
    return th_img
 
 
if __name__ == '__main__':
    # 錠剤画像フォルダのパス
    DIR = os.path.abspath(os.path.dirname(__file__)) + '/tab_img/'
    # 4方向から光を当てた4枚の錠剤画像
    png_file1 = DIR + 'tablets_1.png'    # top-light
    png_file2 = DIR + 'tablets_2.png'    # bottom
    png_file3 = DIR + 'tablets_3.png'    # left
    png_file4 = DIR + 'tablets_4.png'    # right
 
    # 適応的しきい値処理してから合成する
    b_img1 = blend(threshold(png_file1), threshold(png_file2))
    b_img2 = blend(threshold(png_file3), threshold(png_file4))
    b_img3 = blend(b_img1, b_img2)
    b_img = cv2.fastNlMeansDenoising(b_img3, h=20)  # h:フィルタの強さを決定するパラメータ
 
    # 元画像4枚のうちの1枚と2値化処理後の画像を合成する
    org_img = cv2.imread(png_file1)
    processed_img = cv2.cvtColor(b_img, cv2.COLOR_GRAY2BGR)
    highlight_img = blend(org_img, processed_img)
    # 画像を保存して表示
    cv2.imwrite(DIR + 'highlight_img.png', highlight_img)
    cv2.imshow('output', highlight_img)
    cv2.waitKey(0)
    cv2.destroyAllWindows()

*1:というかエッジ抽出では納得のいくレベルのものができませんでした。

*2:THRESH_BINARYの場合

*3:もちろんメーカーのものには及びませんが