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

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

ディープラーニングで錠剤・カプセル識別システムを自作する(1)ー概要~刻印強調ー

今や多くの調剤機器メーカーから薬剤監査・識別支援システムが登場しており、実際に日々の業務でその恩恵に預かっている薬剤師の方も多いと思います。
私自身はこれまでそのようなシステムを業務で使ったことはないのですが、そういうものがあると知った時からずっと「どうやっているんだろう?」「自分でもやってみたい」という思いや好奇心を持っていました。そんなところから数か月かけて少しずつ取り組み、ラズパイ*1とDeep Metric Learning(深層距離学習)を組み合わせた錠剤・カプセルの識別システムを自作してみました。
もちろん既存の製品には遠く及びませんが、ディープラーニングすごい(語彙力)と実感したことや自作のハードを組み合わせた試行錯誤などについて書いていきたいと思います。
記事が長くなりそうなので、今後何回かに分けて各工程について書いていこうと思っています。


 

システムの概要


画像に写っている錠剤・カプセルは何か?を判別するシステムです。ただ適当にスマホで撮った写真をGoogleレンズのように見せてこれ何?としてもシートのない裸錠・カプセルではそう簡単にはうまくいきません。刻印や印字、わずかな色の違いだけで判別しなければならないというのはかなり難易度が高いということがわかります。
 
そこで今回は、撮影条件を固定するためにラズパイ+カメラモジュールを使った自作撮影ボックスを用意し、常に一定の条件で撮影できる環境を整えました。これにより刻印を浮かび上がらせ、それら個体の特徴をモデルに学習させて判別するというアプローチを取ることにしました。
いくつか特徴を挙げると、次のようなものがあります。

  • 4方向照明撮影:ラズパイでLEDを制御して撮影し、判別の肝となる刻印を浮かび上がらせます。
  • DML(Deep Metric Learning):画像から読み取った薬の特徴から薬同士の近さ(距離)を学習する手法を用いることで判別を行います。もっと簡単に言うと、登録している薬の画像と今の画像がどれくらい似ているか、を判定させることができます。
  • ハイブリッド判定:DMLの推論だけでなく、物理的な実寸サイズや色・形といった複数の特徴量を加味することで精度向上の助けにしました。

 
 

開発環境

  • Windows11
  • Python 3.12
  • PyTorch / OpenCV
  • Raspberry Pi 3B+ & Camera Module V2

なお全体を通して、複雑なロジック構成や難しいところでは随時AIに助けてもらいながら作成を進めました。
 

全体のパイプライン(処理工程)

1.シャーレ上の錠剤・カプセルを4方向からの照明で撮影

2.OpenCVによる刻印強調・抽出と個別画像の切り出し

3.DMLモデルによる推論

4.物理性状(サイズ・色・形)フィルタで評価・確認

5.結果をブラウザに出力
これらについて失敗や苦労した点、試してみて面白いと思ったこと等について書いていけたらと思います。

撮影環境の作成

ある程度頭の中で方針が決まった後は、ここから始めました。そもそもなぜ撮影環境が大事かと言うと、ランダムな撮影条件(照明や解像度、背景)では同じ錠剤・カプセルでも違う色として認識されたり、記載されている文字や刻印がはっきり見えなかったりするからです。また、もしそのような状況で撮影された画像をモデルに識別させようとすれば、学習データも大量に必要となります。(学習時と本番時では状況を一致させる必要があるため)

以前錠剤の刻印強調にチャレンジした際*2には、スマホのLEDライトを横から当てて撮影しましたが、これは毎回条件が異なる撮影環境でした。今回は毎回同じ(照明、角度)条件とするためガチガチに固めた撮影環境を作ります。

作成に使ったもの

  • ラズパイ3B+:棚の奥で眠っていたラズパイを数年ぶりに引っ張り出してきました。数年前のモデルということもあり、周辺機器の接続も不安定だったためOSをクリーンインストール*3しました。このへんのことは今回は触れませんが、備忘録的にも機会があったら書いてみようと思います。

 

  • カメラモジュールV2:これも古いモデルのため、ピント調整はレンズ周りのリングを手で回して行います。V3だとオートフォーカス機能があります。

 

  • LEDテープ(WS2812B):ラズパイのGPIO(General Purpose Input/Output:汎用入出力)につないで複数のLEDを個別に光らせたり、色や明るさを変更するといった制御がプログラムで自由にできるLED製品です。ラズパイと別電源にはしたくなかったので、ラズパイの5Vピンからの給電で足りるか、一方向から最大何個光らせるかも考慮して選びました。長さが1mありますが、箱の内壁に沿う分があれば充分なので切って使います。

 

  • ロジックレベル変換モジュール:AIに聞いたら無くても動くことが多い、とのことでしたが、光源の安定性は重要だと考えて挟むことにしました。ラズパイの信号の電圧をLEDに合わせます。ブレッドボードやジャンパーワイヤがセットになったキットに入っていたものです。

 

  • ボックス壁・支柱:壁材はとにかく加工しやすそうなものを探しました。商品ラベルには発砲ポリスチレンパネルと書いてありました。一方向からLED光を当てるので、光の反射が強くなさそうな素材で表面が黒色のものを選びました。また、壁だけだと強度が不足しそうだったので、ボックス4隅にL型のプラスチック製のやつを切って支柱代わりに接着剤で組み立てました。全部合わせても1500円くらいで、両方とも近くのホームセンターで調達しました。

 

撮影ボックス

底面にシャーレ(最初は綿棒の容器のフタでやっていましたが後に本物を購入)を置いてそこに錠剤を入れるとして、何回か撮影した上でカメラとの距離を15cmと決めました。そこからボックス寸法を縦15cm×横16cm×高さ18cmで作成しました。念のため、カメラと対象物との距離は1~2cm程度なら調整できるよう工作しました。

試作1号機。ラズパイが落ちそうに見えますが裏からネジで固定しています。
撮影する際は毎度天板を外してピンセットで錠剤を出し入れするという、かなり面倒くさいことをしていました。
この撮影ボックスでは50種類の錠剤画像の識別に挑戦しました。

下の2枚の写真は天板を裏返した所のカメラと中のシャーレ(容器のフタ)の様子です。シャーレは接着剤で固定していました。LEDテープには両面テープが付いていましたが、それで貼ってしまうと位置調整が難しくなりそうだったので、別のテープで止めています。テープの位置は底面から約1.5cmくらい。真横よりも少し上から光が当たる感じです。

 
試作1.2号機(改造版)。1号機は毎回の錠剤交換に手間がかかりすぎたのと、出し入れの際に天板を動かすとカメラの位置が画像の中心から毎回微妙に(数ミリ)ずれるのが悩みポイントでした。
これを解消するため、天板を固定して代わりに横窓を作り、そこからシャーレごと錠剤を出し入れすることにしました。
シャーレもちゃんとした物(プラスチック製)を購入。シャーレ固定具をボックス4隅のL型プラスチックの余りで作り、底面中心から位置がずれないようにしました。


ラズパイ3B+はメモリが1GBしかなく、合成等の画像処理や学習などの重い処理をさせるにはハード的に力不足です。そのため基本的にはラズパイは撮影マシンに徹してもらい、その後の保存や解析はメインPC側で行うという役割分担(パイプライン)にしました。

フォトメトリックステレオによる撮影

ボックスの内壁に貼り付けた一面のLEDを光らせる→撮影→消灯、これを4方向(真上から見て東西南北)から繰り返します。このやり方は富士フィルムのページにあったシステムを参考にしました。
このような手法(光源の方向を変えながら撮影した複数画像から、物体の3D形状を解析する画像処理技術)は外観検査などで使われ、フォトメトリックステレオというそうです。

import time
import board
import neopixel
from subprocess import run
 
# 4方向照明画像を撮影する. 各方向LED点灯→撮影を4回繰り返す 
 
PIXEL_PIN = board.D18
NUM_PIXELS = 35   # LED数(テープ切断後)
BRIGHTNESS = 0.8  # 1.0だと錠剤そのものの影が強すぎるので少し抑える

# LEDテープのインデックス (壁一面できるだけ多く光らせる. 配置の関係でN方向だけ1個多い)
GROUPS = [(2, 8), (10, 16), (18, 25), (27, 33)]
   
pixels = neopixel.NeoPixel(PIXEL_PIN, NUM_PIXELS, brightness=BRIGHTNESS, auto_write=False)
direction = {0: 'S', 1: 'E', 2: 'N', 3: 'W'}
 
def capture_sequence(pill_name):
    for i, (start, end) in enumerate(GROUPS):
        pixels.fill((0, 0, 0))          # LED点灯
        for n in range(start, end):
            pixels[n] = (255, 240, 230) # 青を少なめに
        pixels.show()
        time.sleep(0.5)                 # 光を安定させる
 
        # 撮影
        filename = f"/home/enoki/pill_id/mnt_pc/{pill_name}_{direction[i]}.png"
        print(f"撮影中: {filename}")
        run([
            "rpicam-still", "-o", filename,
            "--encoding", "png",
            "--width", "3280", "--height", "2464",  # 解像度はV2最大値
            "--immediate",
            "--awbgains", "1.8, 1.0", # 色を固定(紫色を抑える)
            "--shutter", "11000",    # シャッタースピード(μs) 11000~12000が最適か
            "--sharpness", "2.0",    # 刻印(エッジ)強調のため
            "--denoise", "cdn_off",  # 余計な加工をオフにする
            "--contrast", "1.5",     
            "--saturation", "1.4",   
            "--gain", "1.0",         # 感度は最低に固定
            "--verbose", "0"
        ])

    pixels.fill((0, 0, 0))
    pixels.show()
 
if __name__ == "__main__":
    n = input("データナンバーを入力: ")
    capture_sequence(f'data{n}')

光らせるLEDはインデックスで指定します。光源の方向はN, S, E, Wで表すこととし、これを撮影画像の保存ファイル名にも付加します。また撮影画像はPCの共有フォルダに保存できるように設定しておきます。
撮影設定のパラメータはいろいろ試しましたが、この値に落ち着きました。デフォルトのままだと撮影画像が紫がかっていたのでLEDのRGBのところで青を少し抑えたり、"--awbgains"で調整しています。
LEDを何個光らせるかや他のパラメータも含めて、これらは少しでも変更すると後段のOpenCV処理に影響(修正の必要)が出てくるので進んでは戻ってを繰り返しました。


S→E→N→Wの順に点灯・撮影した画像です。
 
 

4方向画像の合成と刻印強調

この処理の全体の流れとしては、錠剤・カプセルの色を保持しつつLED光の方向差から得られた凹凸情報を輝度チャンネルに足し込む、という流れになっています。
アンシャープマスクとBlack-Hatは、より刻印が鮮明に出るということで適用しましたが、どちらも強くかけ過ぎると後段の処理に影響が出るため、パラメータは控えめに設定しています。*4
ここもひたすら途中経過や結果画像を確認しながらの作業になりました。CNNなどのモデルも人間と同様にエッジを重要な特徴として扱うため、人間の目で見てくっきりとした画像を目指して調整しました。

def merge_imgs(images, imgs_gray):
    # 4方向照明画像を合成し、刻印を強調した1枚の画像を返す
    # 4枚まとめて処理しており, 600MB以上メモリ消費するのでPC側で行う
    # 平均の算出
    avg_color = np.mean(
        [img.astype(np.float32) for img in images.values()], axis=0
    ).astype(np.uint8)
    W, E, N, S = [img.astype(np.float32) for img in imgs_gray]
    dx = E - W
    dy = S - N
    
    # 勾配強度(二乗和の平方根)
    mag = cv2.magnitude(dx, dy)
    mag_norm = cv2.normalize(mag, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
    
    # アンシャープマスクで刻印を鋭く
    blur = cv2.GaussianBlur(mag_norm, (0, 0), 2)
    mag_sharp = cv2.addWeighted(mag_norm, 2.0, blur, -1.0, 0)
    
    # 合成処理
    lab = cv2.cvtColor(avg_color, cv2.COLOR_BGR2LAB)
    l, a, b = cv2.split(lab)
  
    clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8))
    l_enhanced = clahe.apply(l)
  
    # BlackHatで溝を強調
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
    blackhat = cv2.morphologyEx(l_enhanced, cv2.MORPH_BLACKHAT, kernel)
    
    # 輝度チャンネルに刻印情報をブレンド
    l_final = cv2.addWeighted(l_enhanced, 0.8, mag_sharp, 0.6, 0)
    l_final = cv2.add(l_final, blackhat)
    result = cv2.merge([l_final, a, b])
    return cv2.cvtColor(result, cv2.COLOR_LAB2BGR)

次回の記事ではこの画像からの錠剤・カプセルの抽出、切り出しあたりについて書いていきたいと思います。

*1:小型シングルボードコンピュータ

*2:過去記事参照

*3:Raspbian Strech→Trixie 32bit版

*4:というか、ここでのBlack-Hatはカーネルも小さく、ほとんどかかっていないです。刻印だけで見れば、Top-Hatの方がより鮮明に見える気がしました。