題材探し

私は電子工作を趣味としていることもあり、部品棚にはカラー抵抗が山のように保管されています。基板にはんだ付けしてしまえばそれまでなのですが、多くの場合ははんだ付けする前にブレッドボードで回路をテストします。

しかし、一度ブレッドボードで使った抵抗は足がグニャグニャ。そのまま実装に使おうとは思えず、捨てることはせずとも「ブレッドボード用」と書かれた引き出しに放り込むのが関の山です。

そんなことを長年繰り返していると、「ブレッドボード用」引き出しは様々な抵抗値のカラー抵抗が放り込まれてカオスな状態になります。

さながら、電子部品屋の「戻す場所が分からなくなったらここに入れてねボックス」でしょう。

そこで閃きました抵抗をカメラに映して抵抗値が表示されるマシンを作ったら面白いんじゃないか。

私はカラー抵抗の読み方は覚えていますが、普段はマイコンを使ったデジタル回路が中心の工作ばかりしているためか、1kΩ、2kΩ、4.7kΩ、10kΩなどの使用頻度が高くなります。しかし、それ以外の抵抗以外は見慣れていないためか読み取りに少し時間を要してしまいます。(青とか灰色の入った抵抗ってあまり使わないですよね?)

そこで、カメラに抵抗を写して、一瞬で答えを出してもらおうという魂胆です。

性能目標

目標:抵抗が画面に映ってから1秒以内に結果を表示する

私が使用頻度の低い抵抗を目視で読み取ると、「青・灰・緑だから、6・8・3で……68kΩか。」てな具合でおおよそ5秒くらいかかりました。この速度の十倍(0.5s)+演算のラグ0.5sと見積り、1以内とします。計算リソースは限られているため、プログラムも可能な限り高速化する必要があります。

検出の条件

事前に試したところ、被検体の回転まで許容すると処理性能的に無理があることが分かりました。そのため、今回抵抗の向きは上下に限定したいと思います。

  1. 抵抗は縦に配置し、横向きや斜めは検出対象としない
  2. 上下の向きのバラツキ(上下どちらに金帯があるか)は許容する(検出対象とする)
  3. 抵抗のメーカーは揃える

環境構築

装置の作製

ハードウェアはお手軽に、ラズパイ3B+とPi Cameraを使います。
また、画像処理のみで抵抗値読み取りを実現したいので、なるべく環境条件を一定にする必要があります。経験上、画像処理の最大の敵は色彩や明るさのバラツキです。被写体を同じ条件で撮影するためにはリング光源と呼ばれる光源装置を使います。これを簡易に模擬し、影ができない抵抗撮影用のスタジオを作りました。

作製した撮影装置がコチラです。光源には秋月電子で販売していたLEDモジュール×4です。LEDモジュールを四角形になるよう固定し、中心にカメラを固定します。こうすることでカメラで取得した画像には影は映らず、外部の影響を受けにくくなります。

LEDモジュールは5V 100mAなので、4本まとめても十分ラズパイから電源供給できる容量です。

続いてカメラの接続確認をします。Pi cameraはセットアップしてしまえば非常に扱いやすいですが、コネクタの接続だけは気を使います。raspistillを実行し、数秒間画面にカメラの映像が表示されれば接続成功です。

$ sudo raspistill -o test.jpg

エラーが出る場合は下記のコマンドを実行し、認識されているかを確認します。「supported=1 detected=1」と表示されれば認識されていますが、「supported=1 detected=0」などの場合は接続を確認しましょう。よくあるミスはフラットケーブルの挿入不足と裏表の間違いです。

$ vcgencmd get_camera
supported=1 detected=1 で成功
supported=1 detected=0 の場合はケーブルの接続を確認する

試しに抵抗を撮影してみました。Pi cameraはレンズを回転させることで焦点距離を変えることができます。今回は3cmほどに焦点が合うように調整しましたが、線の境界線もくっきり撮影できていることが分かります。解像度は問題なさそうです。

開発環境の整備

開発言語はPython2、画像処理ライブラリはOpenCV使います。Raspberry PiでOpenCVを使える言語はいくつかありますが、Python2であれば情報も多く、かつRaspbianに元々入っているのでこのまま活用します。

まずはPython用のOpenCVをインストールします。通信環境によりますが、20~30分かかります。

$ sudo apt-get install python-opencv

エラーが出ていなければ環境整備は完了です。

抵抗値検出までの流れ

設計した処理の流れを下記に示します。抵抗の位置を特定するテンプレートマッチング処理と画素値取得のための補正処理に分岐する構造です。

以降、それぞれの処理について簡単に解説します。

①画像取得

Pi cameraから画像を取得して表示するコードを下記に示します。Pythonでpi cameraを使う場合は専用のpicameraパッケージをimportします。ラズパイ3bであれば非常に高いフレームレートで映像の取得が可能です。タイムラグもほとんどありません。

# -*- coding: utf-8 -*-

import picamera
import cv2

camera = cv2.VideoCapture(0)         # カメラCh(標準の0)を指定
camera.set(cv2.CAP_PROP_FRAME_WIDTH,640)
camera.set(cv2.CAP_PROP_FRAME_HEIGHT,480)

#無限ループ
while True:
    ret, frame = camera.read()      # フレームを取得
    frame = cv2.rotate(frame, cv2.ROTATE_180) #映像を180度回転

    cv2.imshow('camera', frame)     # フレームを画面に表示

    # キー操作があればwhileループを抜ける
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

# 撮影用オブジェクトとウィンドウの解放
camera.release()
cv2.destroyAllWindows()

取得した映像はこんな感じです。カメラそのものの機能で少し暗く補正されてしまっています。(後々、自動補正をOFFにできることを知りました・・・)

人間の目で見れば、無意識に下地の色と比較することで色をはっきりと認識することができますが、実際に画素値を取得して比較してみると、黒と茶色の違いがほとんどないことが分かりました。

②輝度調整(ガンマ変換)

ガンマ変換とは、画像の明るさ調整に用いられる変換手法です。下記のグラフでγ=2を指定すると、トーンカーブが少し上方向に膨らみます。これを画像にてきようすると、暗い部分を持ち上げつつ、明るい領域を飽和させずに画像全体を明るくすることができます。要は、イイ感じに明るくしたり暗くしたりすることのできる関数です。

ただし、積和演算は時間がかかります。今回はリアルタイム処理の中でこの演算を行うため、あらかじめ変換テーブルを用意しておき、実際に変換処理を行う際はこの変換テーブルにインデックスを与えるだけで変換後の値を得ることができます。
※ちなみに、この変換テーブルをLUT(Look Up Table)と呼びます。

import numpy
import numpy as np

#~中略~

#LUTの生成
gamma067LUT  = numpy.array([pow(x/255.0 , 2.2) * 255 for x in range(256)], dtype='uint8')

#~中略~

while True:
   #ループの中では演算を行わず、配列を参照するだけ
   frame = cv2.LUT(frame, gamma067LUT)  # sRGB => linear (approximate value 2.2) 

※以降のガンマ変換のソースコード中ではimportの表記は省略します。

γ=0.67を与えて、明るく補正した結果がこちらです。補正前と比較すると黒と茶色の差がはっきりしたのではないでしょうか。また暗い部分を持ち上げているため、影が変換前よりも目立たなくなりました。

③彩度調整

画像の鮮やかさを増加させ、各色(カラー抵抗の帯の色)どうしを判別しやすくします。通常、カラー画像は赤・緑・青の三原色で表されるRGB色空間で表現されますが、これをいったんHSV色空間に変換し、彩度を増加させた後にRGB色空間に戻します。

HSV色空間とは、色を色相(Hue)・彩度(Saturation)・輝度/明るさ(Value)のパラメータで表現する空間です。

出典:https://www.researchgate.net/figure/a-the-RGB-color-space-black-arrows-show-the-three-main-color-dimensions-whose-values_fig2_323952018

上図(左)では直交座標系上の一点で色を表現されますが、上図(右)では、色相の軸が極座標表現になっています。これは、色を角度で表現できることを示しています。ここで行いたい処理は、色の向き(角度)を変えずにそれぞれの色を際立たせる処理です。
※数学的にはSの値が大きくても小さくても色の違いは変わらないはずですが、実際の画像処理では彩度が高い方が色相を正確に拾えることが経験的に多いです。

#彩度調整用パラメータ
s_magnification = 1.5  # 彩度(Saturation)の倍率

while True:

   #~中略~

    #彩度補正
    img_hsv = cv2.cvtColor(frame,cv2.COLOR_BGR2HSV)  # 色空間をBGRからHSVに変換
    img_hsv[:,:,(1)] = img_hsv[:,:,(1)]*s_magnification  # 彩度の計算
    frame = cv2.cvtColor(img_hsv,cv2.COLOR_HSV2BGR)  # 色空間をHSVからBGRに変換

変換後は鮮やかさが増し、特に赤がはっきりしたことが分かります。これで各色の違いが強調され、画素値を拾いやすくなりました。

④輝度調整(ガンマ変換)

ここではγ=2.2を与えているため、画像を暗く変換します。なぜ暗く変換する必要があるかというと、後段の処理であるテンプレートマッチングでは抵抗値によらず、抵抗の位置を検出することが目的です。そのため、黒や茶色は極端に暗くなってしまい、明るい色だけで構成された抵抗(例えば橙・橙・黄の330kΩなど)は検出されなくなってしまいます。

ここではカラーコードごとの違いをガンマ関数を使って潰し、どの抵抗でも同じような見た目にしてしまいます。

#LUTの生成
gamma22LUT  = numpy.array([pow(x/255.0 , 2.2) * 255 for x in range(256)], dtype='uint8')

#~中略~

while True:
   #ループの中では演算を行わず、配列を参照するだけ
   frame = cv2.LUT(frame, gamma22LUT)  # sRGB => linear (approximate value 2.2) 

⑤グレースケール化

グレースケール化は、RGBの三色で表現されるカラー画像から、明るさのみの1チャンネル画像に変換する処理です。一見単純な処理のように見えますが、グレースケール化だけでも様々なアルゴリズムが開発されており、目的によって使い分ける必要があります。

ここでは後段のガンマ変換で補正するため、標準関数で事足りました。

while True:
   #標準関数でグレースケール化
   frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

もうほとんど何色なのかわからなくなりました。でもこれでOKです。

⑥輝度調整(ガンマ変換)

②と同様の処理のため、ソースコードは省略します。

グレースケール変換後はある程度の明るさの画像に戻すため、γ<1の値を与えます。ここでは0.45としました。

⑦テンプレートマッチング

テンプレートマッチングは、観測対象の画像にテンプレート画像をあてがい、観測対象画像上で一致した(もしくは一致する部分の多い)場所を探す手法です。原理は非常に簡単で、テンプレート画像を観測対象画像の上を走査させ探索します。

OpenCVではすでに関数化されているため、簡単に実現することができます。

#テンプレートマッチング用の画像読み込み
template = cv2.imread('template.png',0)

#テンプレートマッチングの判定しきい値
threshold = 0.8

#~中略~

while True:

#~中略~

    #テンプレートマッチング(グレースケール化したcamera画像から探索)
    res = cv2.matchTemplate(frame_gray,template,cv2.TM_CCOEFF_NORMED)
    loc = np.where( res >= threshold) #結果の配列
    
    for pt in zip(*loc[::-1]): #y,x座標の配列に変換
        raw_result.append([pt[0] + 100, pt[1] + 100])

⑧画素値取得

抵抗の座標が分かったので、その座標を起点に画素値を取得します。y1~y4の値は固定値とし、メーカーが同じであればほぼ誤差はありませんでした。疑似リング光源にはしましたが、はやり得られた画像からは部分的に反射で色が飛んでいる場所があります。そこで、一点から画素値を取得するのではなく、y1~y4それぞれの高さの画素値の平均を取り、これを帯の色として計算します。

この手法で得られた画素値を、y1~y4それぞれの横に線と数値で表示しました。ほぼ帯と同等の色を検出できていることが分かります。
※単なる平均処理のため、ソースコードの記載は省略します。

ここまでの処理を動画に取りました。遅延は0.5s程度で、リアルタイムと言えなくもないレベルです。

⑨抵抗値の推測

いよいよ最終フェーズです。各帯の画素値から抵抗値を推測します。

各色でそれぞれ範囲を決め、しきい値を設定します。しきい値は実際の抵抗を撮影してデータを取り、最小・最大値±マージンを加味して設定しました。

しきい値一覧

これは採取したデータからそれぞれの色相分布をプロットした図です。黄色・緑・青・紫は色相のみで判定可能ということが分かりましたが、その他の色は被っているためS(彩度)・V(輝度)を条件に入れての判定が必要になります。

各色の色相分布

本来であれば緑は120°付近に現れるはずですが、抵抗の大部分を占める下地の色がオレンジの色相を持っているため、この色に引っ張られていることが原因と考えられます。

データ採取のためのサンプル画像

最後に、得られた第一色帯から第三色帯に対応する数値から抵抗値を計算して完了です。

実際に判検出できた様子

まとめ

Python+OpenCVの画像処理でカラー抵抗の抵抗値の検出に成功したとともに、性能目標値だった1秒以内の認識についてもクリアすることができました。今回作成したアルゴリズムは特定メーカー品にパラメータを調整したものであり、その点汎用性はないですが、逆に考えれば、例えば大量生産ラインでの検品処理を自動化するような仕組みは、レガシーな画像処理でも事足りる場合も少なくはないのではとも感じました。

前処理から結果の出力まで一通り実装して感じたことは、最終的な判定アルゴリズム作りとそのためのデータ採集が死ぬほど大変だということです。明るさの補正やガンマ変換などの前処理でもトライ&エラーでパラメータを調整しますが、判定処理のトライ&エラーはその比ではありませんでした。アルゴリズムを自動生成できるAIが持て囃されている理由が分かった気がします。ただし深層学習系のAIの場合は、代わりに膨大な量の学習データを食わせる必要があり、人力でアルゴリズムを作る場合とどちらが楽かは状況や対象によるのだと思います。