勉強しないとな~blog

ちゃんと勉強せねば…な電気設計エンジニアです。

OpenCVやってみる - 27. シール外形検出

前回の内容

春のパン祭り点数集計で、最初に台紙外形の検出をすることを考えていましたが、どうも難しそうなので諦める方向で。

に考えた手順は、

  1. 台紙外形を取得する
    射影変換をするのと、スケーリング(台紙を基準として画像サイズを調整する)に必要
  2. 射影変換実施
  3. シール外形、中心点取得
  4. シール位置でテンプレートマッチング

というものですが、台紙外形取得と射影変換は諦めます。
それに代わる変換処理が必要ですが、

  • シール外形を楕円で近似、楕円の軸方向に圧縮、伸張する
  • シール外形サイズから画像スケーリング

という手でどうかと考えました。

ということで、改めて手順を書くと、

  1. シール外形取得
  2. シール外形を楕円で近似
  3. シール画像の圧縮、伸張、スケーリングを実施
  4. シール画像でテンプレートマッチング

の4ステップになります。

これらの手順を考えていきます。

シール外形取得

台紙外形と比べると、

  • 台紙、シールの色は均一
  • 台紙、シールの色はHue(色相)、Saturation(彩度)で明らかに区別できる

という点から、シール外形は単純に閾値処理をしてから輪郭検出をすることで比較的簡単に得られそうに思います。

具体的な手順としては、

  1. HSVフォーマットに変換
  2. Hue画像、Saturation画像からそれぞれ2値化
  3. 上記2画像のandを取る
  4. 輪郭検出を実施

となります。

ちょっと気になるのは、OpenCVチュートリアルにあるWatershedアルゴリズムについて。

https://docs.opencv.org/4.5.2/d3/db4/tutorial_py_watershed.html

これは、輪郭どうしが接触してしまっている場合にうまく輪郭検出できないので、その場合につながった領域の境界を見つける、というものです。
春のパン祭りシールでも、シールが重なってしまうことはあるかと。

ひとまずは単純な輪郭検出でやってみて、だめであればWatershedアルゴリズムを使っていきたいと思います。
以下やってみます。

import cv2
img1 = cv2.imread('harupan_190428_1.jpg')
img2 = cv2.imread('harupan_190428_2.jpg')
img3 = cv2.imread('harupan_200317_1.jpg')
img4 = cv2.imread('harupan_210227_2.jpg')
img5 = cv2.imread('harupan_210402_1.jpg')
img6 = cv2.imread('harupan_210402_2.jpg')
img7 = cv2.imread('harupan_210414_1.jpg')
img1 = cv2.resize(img1, None, fx=0.1, fy=0.1, interpolation=cv2.INTER_AREA)
img2 = cv2.resize(img2, None, fx=0.1, fy=0.1, interpolation=cv2.INTER_AREA)
img3 = cv2.resize(img3, None, fx=0.1, fy=0.1, interpolation=cv2.INTER_AREA)
img4 = cv2.resize(img4, None, fx=0.1, fy=0.1, interpolation=cv2.INTER_AREA)
img5 = cv2.resize(img5, None, fx=0.1, fy=0.1, interpolation=cv2.INTER_AREA)
img6 = cv2.resize(img6, None, fx=0.1, fy=0.1, interpolation=cv2.INTER_AREA)
img7 = cv2.resize(img7, None, fx=0.1, fy=0.1, interpolation=cv2.INTER_AREA)
%matplotlib inline
from matplotlib import pyplot as plt
import numpy as np

まず5番目の画像でやってみます。
シールが全面ピンクで、実際に重なりも見られるので、Watershedアルゴリズムの必要性が判断できるかと。

plt.imshow(cv2.cvtColor(img5, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211207075319p:plain

ヒストグラム確認

HSVフォーマットでのヒストグラムを見てみます。

img5_hsv = cv2.cvtColor(img5, cv2.COLOR_BGR2HSV)
plt.hist(img5_hsv[:,:,0].ravel(), 180, [0,180]), plt.title('Hue');
plt.figure()
plt.hist(img5_hsv[:,:,1].ravel(), 256, [0,256]), plt.title('Sat');
plt.figure()
plt.hist(img5_hsv[:,:,2].ravel(), 256, [0,256]), plt.title('Val');

f:id:nokixa:20211207075322p:plain

f:id:nokixa:20211207075324p:plain

f:id:nokixa:20211207075327p:plain

  • Hueについては、170あたりの分布がシールの色に当たるかと。20あたりのピークはフローリングかな。
  • Saturationについては、150以上のところに3つほどピークがあるように思えます。これは何だろう?
  • Value(明度)についても、明度が高いのはシール台紙かと思われますが、150以上で2つピークがあるのは何か?

色々2値化して見てみたいと思います。

2値化

まずはHue画像で。
0~25, 26~50, 150~179の範囲でそれぞれ見てみたいかと。

ちなみに、以下ではmatplotlibでの表示サイズの調整のため、plt.figure()でサイズ(inch単位)、dpiを指定しています。 以下を参考にしました。

https://qiita.com/cnloni/items/20b5908fbae755192498

ret, img5_th_hue0 = cv2.threshold(img5_hsv[:,:,0], 25, 255, cv2.THRESH_BINARY_INV)
ret, img5_th_hue1 = cv2.threshold(img5_hsv[:,:,0], 50, 255, cv2.THRESH_BINARY_INV)
img5_th_hue1 = cv2.bitwise_and(img5_th_hue1, cv2.bitwise_not(img5_th_hue0))
ret, img5_th_hue2 = cv2.threshold(img5_hsv[:,:,0], 150, 255, cv2.THRESH_BINARY)
plt.figure(figsize=(6.4,100), dpi=100)
plt.subplot(131), plt.imshow(img5_th_hue0, cmap='gray'), plt.title('0-25'), plt.xticks([]), plt.yticks([])
plt.subplot(132), plt.imshow(img5_th_hue1, cmap='gray'), plt.title('26-50'), plt.xticks([]), plt.yticks([])
plt.subplot(133), plt.imshow(img5_th_hue2, cmap='gray'), plt.title('150-179'), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211207075331p:plain

20付近のピークはフローリングの色、その次の集まりはシール台紙の色、最後はシールおよび台紙の文字色でした。

白では色相は不定になるので、台紙の色相はあんまり当てにならないかも。照明の具合にも左右されてかなり変動するかもしれません。

次にSaturation画像ですが、ヒストグラムをもう少し細かく見てみます。

plt.hist(img5_hsv[:,:,1].ravel(), 256, [0,256]), plt.title('Sat');
plt.xlim(160,220)

f:id:nokixa:20211207075334p:plain

適当に140~190, 191~205, 206~255の範囲で見てみたいと思います。

ret, img5_th_sat0 = cv2.threshold(img5_hsv[:,:,1], 140, 255, cv2.THRESH_BINARY)
ret, img5_th_sat1 = cv2.threshold(img5_hsv[:,:,1], 190, 255, cv2.THRESH_BINARY)
ret, img5_th_sat2 = cv2.threshold(img5_hsv[:,:,1], 206, 255, cv2.THRESH_BINARY)
img5_th_sat0 = cv2.bitwise_and(img5_th_sat0, cv2.bitwise_not(img5_th_sat1))
img5_th_sat1 = cv2.bitwise_and(img5_th_sat1, cv2.bitwise_not(img5_th_sat2))
plt.figure(figsize=(6.4,100), dpi=100)
plt.subplot(131), plt.imshow(img5_th_sat0, cmap='gray'), plt.title('140-190'), plt.xticks([]), plt.yticks([])
plt.subplot(132), plt.imshow(img5_th_sat1, cmap='gray'), plt.title('191-205'), plt.xticks([]), plt.yticks([])
plt.subplot(133), plt.imshow(img5_th_sat2, cmap='gray'), plt.title('206-255'), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211207075338p:plain

140~190は台紙の色付き部分、191~205はシールの少し一部分、206~255はシールの大部分となっています。
いずれもフローリング領域も含んでいます。

最後にValue画像を見てみます。
大きい3つの山に分けて、50~120, 150~200, 201~240にしてみます。

ret, img5_th_val0 = cv2.threshold(img5_hsv[:,:,2], 50, 255, cv2.THRESH_BINARY)
ret, img5_th_val1 = cv2.threshold(img5_hsv[:,:,2], 120, 255, cv2.THRESH_BINARY)
ret, img5_th_val2 = cv2.threshold(img5_hsv[:,:,2], 150, 255, cv2.THRESH_BINARY)
ret, img5_th_val3 = cv2.threshold(img5_hsv[:,:,2], 200, 255, cv2.THRESH_BINARY)
ret, img5_th_val4 = cv2.threshold(img5_hsv[:,:,2], 240, 255, cv2.THRESH_BINARY)
img5_th_val0 = cv2.bitwise_and(img5_th_val0, cv2.bitwise_not(img5_th_val1))
img5_th_val2 = cv2.bitwise_and(img5_th_val2, cv2.bitwise_not(img5_th_val3))
img5_th_val3 = cv2.bitwise_and(img5_th_val3, cv2.bitwise_not(img5_th_val4))
plt.figure(figsize=(6.4,100), dpi=100)
plt.subplot(131), plt.imshow(img5_th_val0, cmap='gray'), plt.title('50-120'), plt.xticks([]), plt.yticks([])
plt.subplot(132), plt.imshow(img5_th_val2, cmap='gray'), plt.title('150-200'), plt.xticks([]), plt.yticks([])
plt.subplot(133), plt.imshow(img5_th_val3, cmap='gray'), plt.title('201-240'), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211207075340p:plain

分かりやすい感じで、50~120はフローリング、150~200はシールと台紙の色付き部分、201~240は台紙の白い部分となっています。

閾値決定

Hue(色相)、Saturation(彩度)でのシール領域検出を考えていましたが、ここまでやってみた結果を見ると、

  • Hueでのシール領域検出はやりやすそう、シールが特徴的で均一な色になっているので。
  • Saturationは難しそう、違う色で鮮やかさが同じぐらい、もしくは暗めな領域があると、誤検出してしまう
  • Valueでもシールと台紙の区別はしやすそう、ただHueでの結果とそれほど変わらないか?必要があれば補助的に使うかな。

というわけで、Hueだけでいいかと思われます。
色味がカメラや撮影条件で変わることも考えて、Hueが135~179の範囲を取りたいと思います。

ret, img5_th_hue = cv2.threshold(img5_hsv[:,:,0], 135, 255, cv2.THRESH_BINARY)
plt.figure(figsize=(6.4,100), dpi=100)
plt.imshow(img5_th_hue, cmap='gray'), plt.title('Thresholded(Hue)'), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211207075342p:plain

輪郭検出

よさそうな2値化ができたので、輪郭検出をしてみます。
シールの外形だけ取りたいので、cv2.RETR_EXTERNALcv2.findContours()関数を実行します。

img5_contours, img5_hierarchy = cv2.findContours(img5_th_hue, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
img5_with_contours = cv2.drawContours(img5.copy(), img5_contours, -1, (0,255,0), 2)
plt.figure(figsize=(6.4,100), dpi=100)
plt.imshow(cv2.cvtColor(img5_with_contours, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211207075346p:plain

やっぱりシールどうしの重なりのせいで、シールの輪郭同士がつながってしまっています。
一旦収縮処理をしたらどうだろう。

kernel = np.ones((3,3), np.uint8)
img5_th_hue_morph = cv2.erode(img5_th_hue, kernel, iterations=1)
img5_contours, img5_hierarchy = cv2.findContours(img5_th_hue_morph, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
img5_with_contours = cv2.drawContours(img5.copy(), img5_contours, -1, (0,255,0), 2)
plt.figure(figsize=(6.4,100), dpi=100)
plt.subplot(121), plt.imshow(img5_th_hue_morph, cmap='gray'), plt.xticks([]), plt.yticks([])
plt.subplot(122), plt.imshow(cv2.cvtColor(img5_with_contours, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211207075350p:plain

今度はシール内側の文字まで輪郭になってしまいました。
これも良くない…

方針変更

ちょっと別の考え方を。
1つ前の輪郭画像で、少なくともこの輪郭の中に点数の文字がある、ということまでは絞れています。
このさらに内側の輪郭を、各点数の文字テンプレートと比較していく、というのでどうだろう?

ここまで

今回はここまでにしておきます。
次回は方針を考え直して、改めて進めていきたいと思います。

思ったほど簡単にはいかないな…