PDHにつないだUSBカメラ画像(静止画)の差分検出

1. 概要

モノづくりの現場では、過去のある時間の画像を基準として、現在の画像と比較したいという需要がかなりあるのではないかと思います。
ここでは、カメラにオンライン会議に使用する安価なUSBカメラとPDH(Raspberry Pi)で一定時間間隔で画像を取得し、限度見本と比較するシステムを構築します。

カメラ画像の取得と全体システムの制御には、Node-REDを用います。また、画像の比較処理には、PythonのOpenCVライブラリを使用します。

2.全体のフロー

世の中には、AIを用いた画像比較のアプリやライブラリが出回っています。ここではそれらは使わず、シンプルに画像を比較し、差分を赤く元の画像に重ねて表示する簡便なシステムをつくります。重ね合わせ画像は、作業者に対して実物を点検する箇所を指し示す際の指標として使用します。そのため、差分を赤色に変えて重ね合わせます。

 

 

3. 画像処理の手順の考え方

画像処理の手順(フロー)を以下に示します。差分画像③をグレースケール化④することで各ビットを一つの数値に変換します。その後、閾値で黒と白に2値化⑤します。そして重ねた時に分かりやすいように赤色に差分の色を変えて⑥、限度見本に重ね合わせます⑦。
合わせて、2値化時に白のドット数を計算し⑧、出力します⑨。この白ドット数は、限度見本から変化したドット数であり、現在画像が限度見本から変化したかどうかを判定するための数字になります。

4.実装1: usbcameraノードによる画像の取得

USBカメラを使った静止画像の取得には、usbcameraノードを使います。
usbcameraノードを使った静止画像の取得は、こちら(PDHにつないだUSBカメラ画像(静止画)の表示)を参考にしてください。

4-1.基本構成

基本構成は、injectノードとusbcameraノードとviewerノードの3つです。gateノードは、ON/OFFのスイッチに使っています。
viewerノードは、“node-red-contrib-image-tools”に含まれています。“node-red-contib-image-output”のimageノードでも代用できます。viewerノードは、msg.payloadにイメージ画像のパス付のファイル名を載せて送るとそれを表示してくれるところが便利です。

injectノードで定期的にusbccameraノードにアクセスして、画像を取得します。usbcameraノードで“File Mode”=File Modeに設定します。そして、取得した画像は、File Nameに指定したファイル名で、“File Default Path”=noを選ぶと表示されるFile Pathに保存します。

5.実装2: Pythonによる画像比較処理

5-1. OpenCVライブラリのインストール

画像比較には、OepnCVのライブラリを使用します。そのため、ライブラリをインストールします。
また、numpyのライブラリも使用しますので、numpyがインストールされていない場合は、numpyもインストールします。

python3 –m pip install opencv-python
python3 –m pip install numpy

    updateの時は、$ python3 –m pip install –U numpy

    update後に依存関係が壊れていないかチェック $ python3 –m pip check
       “No broken requirements found.” と表示されればOKです。。

5-2. pythonプログラム

Pythonのプログラムに関して説明します。コードは、Appendix1にも掲載しています。

1~3行目:ライブラリを読み込んでいます。sysは6行目の引数の読込みに使用します。
6行目:閾値を引数として受け取ります。
8~9行目:限度見本と現在画像のjpgファイルを読み込みます。base_pic.jpg:限度見本、usbcam.jpg:現在画像
11~12行目:限度見本と現在画像の差分を取ります。結果をdiff.jpgとして保存します。
13~14行目:グレースケール化を行います。結果をgray.jpgとして保存します。
16~17行目:2値化(黒と白)を引数として受け取った閾値を使って行います。閾値は255~0の間の数値です。結果をthresh.jpgに保存します。
19~22行目:白色を赤色に変換します。結果をresult.jpgに保存します。
24~25行目:限度見本とresult.jpgを重ね合わせ、結果をdiff_add.jpgとして保存します。
28~30行目:16~17行目で2値化した結果のすべてのビットのうち、白色のビットがいくつあるかをカウントして、JSONとして出力します。

5-3. グレースケール差分画像の2値化のための閾値検討

グレースケール化した差分画像を2値化(白と黒)にして、差分を明確にします。グレースケール化された画像の各ビットは、0~255の8ビットの数値で表現されています。0:黒、255:白でその間がねずみ色(グレー)になります。

次に、グレースケール化した差分画像を示します。差が無いところは真っ黒になっていますが、差のある所は濃いねずみ色から白っぽいねずみ色になっています。0~255のいずれかの数値で閾値を決めて2値化して差分のある所と無いところを切り分けます。

各ビットの数値の分布図は次のようになります。25までに98%のドットが含まれます。

閾値を決めるために、横軸を26~255として残り2%の分布をみてみると次の図のようになり、50~150のねずみ色値の間にもう一山あります。これが、差分を表現しているねずみ色群とみなせます。

そこで、閾値を50として、50より大きい値をもつビットは白(255)に、50より小さい値を持つビットは黒(0)に2値化します。2値化した結果が次の図になります。

この閾値は差分があるかどうかを判定するために重要です。

次のような限度見本と現在画像の比較をしてみます。椅子が1脚無くなっています。グレースケール差分画像はいろんなねずみ色を含んだ椅子型になっています。ねずみ色値の分布を調べると、23あたりにピークがあります。これを閾値50と閾値14で2値化した図を次に示します。閾値50の方は、椅子と分かりにくい差分画像になっています。閾値14の方は椅子の形と分かりますが、ノイズが目立ち始めます。暗い色の面積の広い差分がある場合は、閾値の値に要注意です。黒(0)から調べてピーク値を探し、その手前の谷の値を閾値にするとよいかもしれません。

 

6. Node-REDへのシステムの組み込み

作成したNode-REDのフローは大きく3つの部分に分かれます。

① 限度見本取得部:最初に一度限度見本を取得します。画像は、”basic-pic.jpg”として保存します。表示用のinjectを別途設けています。
② 現在画像取得部:定期的に画像を取得します。画像は、usbcam.jpgとして保存します。 gateノードはON/OFFのスイッチ機能です。
③ 画像比較実施部:現在画像取得後、execノードでpythonプログラム”diff_pic.py”を起動します。pythonのプログラムファイル名は、フルパスで記載します。引数欄にチェックを入れ、引数として、閾値をmsg.payloadに載せて渡します。標準出力への変化ドット数は、今後の使用を考慮して、jsonとしてデバッグノードに表示します。差分と重ね合わせた結果ファイルは、日時情報を付けて、 “diff_20230822-140505.jpg”のフォーマットで逐次保存されます。

 

7.実行結果の例

8.この手法の応用例(案)

1)既存の監視カメラの映像を使った侵入検知
2)材料棚の部材の減少管理:不足してきたら注文のアラームを上げる
3)駐車場の混み具合の検出
4)箱詰め商品の箱詰め状態の確認
5)ドアの鍵のかけ忘れチェック:夜の11時に、鍵が回されていなかったら通知する
6)乾いた板の上の水滴を検出:雨の降り始め通知

9. 現時点での課題

カメラが限度見本から少し位置ずれを起こしたような場合の対応を検討する必要があります。閾値の変更で吸収できない場合の対応です。

Appendix1. python programソース:diff_pic.py

import sys
import cv2
import numpy as np

# main
thresh = int(sys.argv[1])  # thresh level for converting gray scale

img_1 = cv2.imread('/home/pi/Pictures/opencv/usbcam.jpg')
img_2 = cv2.imread('/home/pi/Pictures/opencv/base-pic.jpg')

img_diff = cv2.absdiff(img_1, img_2)
cv2.imwrite('/home/pi/Pictures/opencv/diff.jpg',img_diff)
img_gray = cv2.cvtColor(img_diff, cv2.COLOR_BGR2GRAY)
cv2.imwrite('/home/pi/Pictures/opencv/gray.jpg',img_gray)

ret, img_thresh = cv2.threshold(img_gray, thresh, 255, 0)
cv2.imwrite('/home/pi/Pictures/opencv/thresh.jpg',img_thresh)

color_fg = (0, 0, 255)
color_bg = (0, 0, 0)
img_result = np.where(img_thresh[..., np.newaxis] == 255, color_fg, color_bg).astype(np.uint8)
cv2.imwrite('/home/pi/Pictures/opencv/result.jpg', img_result)

img_add = cv2.add(img_2, img_result)
cv2.imwrite('/home/pi/Pictures/opencv/diff-add.jpg',img_add)

# 
image_array = np.array(img_thresh)
count = np.count_nonzero(image_array == 255)
print('{"count":%d}' % count)

Appendix2. グレイスケールのねずみ色値の分布を調べるpythonプログラム:diff_pic.py

import cv2
import numpy as np

# 画像の読み込み
img = cv2.imread("/home/pi/Pictures/opencv/gray.jpg", 0)

# ヒストグラムの取得
img_hist_cv = cv2.calcHist([img], [0], None, [256], [0, 256])

# ヒストグラムの表示
print(img_hist_cv)

unix系であれば、以下のコマンドを打つと、csvファイルとして出力されます。

python3 hist_pic.py | sed 's/[\[ ]//g'| sed 's/\]//g' | awk '{print NR-1", " $0 }' > histgram.csv

Appendix3. Node-REDのフロー

[{"id":"8feb4e818197603c","type":"image viewer","z":"43fade5ebd979c61","name":"","width":160,"data":"filename","dataType":"msg","active":true,"x":810,"y":420,"wires":[[]]},{"id":"72b0374c61c827f6","type":"exec","z":"43fade5ebd979c61","command":"mv /home/pi/Pictures/opencv/diff-add.jpg","addpay":"filename","append":"","useSpawn":"false","timer":"","winHide":false,"oldrc":false,"name":"mv","x":630,"y":380,"wires":[["6f44ac67a3601046","8feb4e818197603c"],["6f44ac67a3601046"],["6f44ac67a3601046"]]},{"id":"d9258e17a862e421","type":"template","z":"43fade5ebd979c61","name":"filename","field":"filename","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"/home/pi/Pictures/opencv/diff_{{datetime}}.jpg","output":"str","x":500,"y":380,"wires":[["72b0374c61c827f6"]]},{"id":"6f44ac67a3601046","type":"debug","z":"43fade5ebd979c61","name":"debug 48","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"filename","targetType":"msg","statusVal":"","statusType":"auto","x":820,"y":380,"wires":[]},{"id":"76e2134303a75e93","type":"moment","z":"43fade5ebd979c61","name":"datetime","topic":"","input":"","inputType":"date","inTz":"Asia/Tokyo","adjAmount":0,"adjType":"days","adjDir":"add","format":"YYYYMMDD-HHmmss","locale":"ja-JP","output":"datetime","outputType":"msg","outTz":"Asia/Tokyo","x":360,"y":380,"wires":[["d9258e17a862e421"]]},{"id":"275f9c07d5ddbdb1","type":"exec","z":"43fade5ebd979c61","command":"python3 /home/pi/source/python/diff_pic/diff_pic.py","addpay":"payload","append":"","useSpawn":"false","timer":"","winHide":false,"oldrc":false,"name":"diff_pic.py","x":210,"y":400,"wires":[["76e2134303a75e93","ca4555f24ca7d3ce"],["ebf54a65b78f8222"],["ebf54a65b78f8222"]]},{"id":"ca6e96dca85af306","type":"change","z":"43fade5ebd979c61","name":"thresh level","rules":[{"t":"set","p":"payload","pt":"msg","to":"50","tot":"num"}],"action":"","property":"","from":"","to":"","reg":false,"x":290,"y":260,"wires":[["275f9c07d5ddbdb1"]]},{"id":"ca4555f24ca7d3ce","type":"json","z":"43fade5ebd979c61","name":"","property":"payload","action":"","pretty":false,"x":350,"y":340,"wires":[["7a24d8bb36013d27"]]},{"id":"ebf54a65b78f8222","type":"debug","z":"43fade5ebd979c61","name":"debug 47","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"filename","targetType":"msg","statusVal":"","statusType":"auto","x":360,"y":420,"wires":[]},{"id":"a847c667ec61ab20","type":"usbcamera","z":"43fade5ebd979c61","filemode":"1","filename":"usbcam.jpg","filedefpath":"0","filepath":"/home/pi/Pictures/opencv","fileformat":"jpeg","resolution":"2","name":"usbcam.jpg","x":410,"y":180,"wires":[["cfb3c97fc36cfabc","ca6e96dca85af306"]]},{"id":"7a24d8bb36013d27","type":"debug","z":"43fade5ebd979c61","name":"debug 49","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":480,"y":340,"wires":[]},{"id":"f2fecdaf6aa8b3fc","type":"gate","z":"43fade5ebd979c61","name":"","controlTopic":"control","defaultState":"closed","openCmd":"open","closeCmd":"close","toggleCmd":"toggle","defaultCmd":"default","statusCmd":"status","persist":false,"storeName":"memory","x":270,"y":180,"wires":[["a847c667ec61ab20"]]},{"id":"cfb3c97fc36cfabc","type":"image viewer","z":"43fade5ebd979c61","name":"","width":160,"data":"payload","dataType":"msg","active":true,"x":690,"y":180,"wires":[[]]},{"id":"077406a4b0d19691","type":"inject","z":"43fade5ebd979c61","name":"toggle","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"control","payload":"toggle","payloadType":"str","x":150,"y":200,"wires":[["f2fecdaf6aa8b3fc"]]},{"id":"b61ea7011c05f089","type":"inject","z":"43fade5ebd979c61","name":"10秒毎","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"10","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":140,"y":160,"wires":[["f2fecdaf6aa8b3fc"]]},{"id":"0b1a9d70b43ce060","type":"inject","z":"43fade5ebd979c61","name":"基準画像","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":140,"y":120,"wires":[["a1c87c77cdf46062"]]},{"id":"a1c87c77cdf46062","type":"usbcamera","z":"43fade5ebd979c61","filemode":"1","filename":"base-pic.jpg","filedefpath":"0","filepath":"/home/pi/Pictures/opencv","fileformat":"jpeg","resolution":"2","name":"base-pic.jpg","x":410,"y":120,"wires":[["64539d2465dd2306"]]},{"id":"f8a25cda935a03d6","type":"inject","z":"43fade5ebd979c61","name":"基準画像表示","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"/home/pi/Pictures/opencv/base-pic.jpg","payloadType":"str","x":570,"y":80,"wires":[["64539d2465dd2306"]]},{"id":"64539d2465dd2306","type":"image viewer","z":"43fade5ebd979c61","name":"","width":160,"data":"payload","dataType":"msg","active":true,"x":870,"y":80,"wires":[[]]}]