勉強しないとな~blog

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

OpenCVやってみる - 32. 輪郭の変形(考察のみ)

あけましておめでとうございます。
1月も半分以上過ぎてしまいましたが、今年初めての記事です。

今年も引き続き春のパン祭りシール点数集計をやっていきます。
去年の3月から始めて、色々寄り道しながらゆっくり進めてきましたが、 今年の春のパン祭りに間に合うように完成させたいな。

今回の内容

前回テンプレート画像を用意したので、今回は基本的にテンプレートマッチングを実施するだけです。
ただし、台紙を斜めから撮影することによる変形も考慮したいと思います。
以下進めていきます。

輪郭の変形

シール台紙をカメラで撮影するとき、人間がやるのであれば多少なりともカメラの角度がシール台紙の垂直軸からずれます。そうすると撮影された画像では点数文字がいずれかの方向につぶれて写ります。これを補正したいなと。

今回のように平面をカメラで撮影した場合、平面上の点から画像上の点への変換は、厳密には射影変換で表すことができます。ただし、射影変換のパラメータ推定をするのは計算量が多くなるし、そのために準備が必要なマッチング点ペアの数も多くなります。
カメラの撮像面が被写体平面に対してそれほど平行から外れていなければ、アフィン変換での近似で十分なので、これを試してみたいと思います。

一応この方向で考えますが、本当にアフィン変換での変形の補正が必要か、というのも確認しておきます。
そんなに斜めから撮影しないという前提にすれば、必要なさそうな気も…。
ただ、勉強ということで試しにやってみたいなと、そういうモチベーションでやっています。

アフィン変換参考
https://note.nkmk.me/python-opencv-warp-affine-perspective/

今回の変換適用について

テンプレート画像と検出した点数文字輪郭(の周辺画像)を比較する、というのが今回やろうと思っていることです。
テンプレート画像を基準として、これとは違う角度から撮影されて輪郭周辺画像が得られる、という見方になります。

  • この変換のパラメータを求める
  • 輪郭周辺画像に逆変換を施して、本来のカメラ角度からの画像を得る

ということを目指します。

アフィン変換の解釈?

アフィン変換は、2つの平面座標間を、2x2の行列と並進を作用させて変換します。

 \begin{bmatrix} x' \\ y' \\ \end{bmatrix}
= 
\begin{bmatrix} a & b \\ c & d \\ \end{bmatrix}
\begin{bmatrix} x \\ y \\ \end{bmatrix}
+
\begin{bmatrix} \tau_x \\ \tau_y \\ \end{bmatrix}
=
\boldsymbol{M}
\begin{bmatrix} x \\ y \\ \end{bmatrix}
+
\boldsymbol{\tau}

以降、並進\boldsymbol{\tau}は置いておいて、 行列\boldsymbol{M}のほうを考えます。

アフィン変換では、\boldsymbol{M}の要素に特に制約はありません(2次元座標変換には他にユークリッド変換、相似変換がありますが、変換行列に制約がある)が、この要素の意味合いを少し考えてみました。

一応考えましたが、もしかしたら間違いがあるかもなので、そこはご容赦です。あまり信用しないでください。

f:id:nokixa:20220117230423p:plain

まず、この図では、被写体平面に対して斜めの方向からカメラで撮影を行っている様子を表しています。
被写体平面に平行にx軸とy軸、垂直にz軸を定義しています。
ここで、カメラ軸を被写体平面上に射影した軸をx'軸、これに垂直な被写体平面内の軸をy'軸と定義します。
以下の図はz軸に垂直な視点から見た図です。

f:id:nokixa:20220117230426p:plain

x'軸とy'軸を使って被写体の座標を表すことを考えます。
上の図で、x'軸はx軸に対して\varphiの角度(角度をどう取るかはもう少し考えたほうがよさそう…)となっているので、x, yでの座標からx', y'での座標への変換は、

 \begin{bmatrix} x' \\ y' \end{bmatrix} 
=
\begin{bmatrix} \cos(-\varphi) & -\sin(-\varphi) \\ \sin(-\varphi) & \cos(-\varphi) \end{bmatrix} 
\begin{bmatrix} x \\ y \end{bmatrix}
=
\begin{bmatrix} \cos\varphi & \sin\varphi \\ -\sin\varphi & \cos\varphi \end{bmatrix} 
\begin{bmatrix} x \\ y \end{bmatrix} 
=
\boldsymbol{R_\varphi} \begin{bmatrix} x \\ y \end{bmatrix}

となります。

この被写体平面上の点が、カメラの撮像面上でどのように写るか考えてみます。
まずカメラ軸とx'軸を含む面で見てみます。

f:id:nokixa:20220117230429p:plain

カメラの撮像面に対して被写体平面が傾いていて、被写体平面上の点のx'軸座標と撮像面上の座標tex:uはシンプルな関係にはなりませんが、カメラの光学中心と被写体平面間の距離がカメラの奥行方向に大きく変動しなければ、図の緑の面(撮像面に平行)に射影した点で近似することができ、そうすると2つの座標間の関係は

 u = k_x x' \cos \theta = k'_x x'
 (k'_x = k_x \cos \theta)

という形になります。
また、カメラ軸とy'軸を含む面で見ると、こちらはy'軸とカメラ軸が直交するのでシンプルになります。

f:id:nokixa:20220117230431p:plain

被写体平面上のy'座標と撮像面上の座標vの関係は

 v = k_y y'

となります。

これらの関係は、x'軸、y'軸上にない点についても同様で、まとめると、

 \begin{bmatrix} u \\ v \end{bmatrix}
= 
\begin{bmatrix} k'_x & 0 \\ 0 & k_y \end{bmatrix}
\begin{bmatrix} x' \\ y' \end{bmatrix}
=
\boldsymbol{K} \begin{bmatrix} x' \\ y' \end{bmatrix}

となります。

また、カメラのほうも、カメラ軸を中心として回転する自由度があります。
この回転角を\omegaとすると、回転後のu'軸、v'軸座標について、

 \begin{bmatrix} u' \\ v' \end{bmatrix}
=
\begin{bmatrix} \cos\omega & \sin\omega \\ -\sin\omega & \cos\omega \end{bmatrix}
\begin{bmatrix} u \\ v \end{bmatrix}
=
\boldsymbol{R_\omega} \begin{bmatrix} u \\ v \end{bmatrix}

となります。

f:id:nokixa:20220117230434p:plain

今までに出た変換をまとめると、被写体平面上の点(x,y)と撮像面上の点(u',v')の間に以下の関係が成り立ちます。

 \begin{bmatrix} u' \\ v' \end{bmatrix}
=
\boldsymbol{R_\omega} \boldsymbol{K} \boldsymbol{R_\varphi} \begin{bmatrix} x \\ y \end{bmatrix} 
=
\boldsymbol{M}_{\omega,k'_x,k_y,\varphi} \begin{bmatrix} x \\ y \end{bmatrix}

ここで現れた行列 \boldsymbol{M}_{\omega,k'_x,k_y,\varphi} は2x2行列で、4つの任意なパラメータを持ちますが、 これがアフィン変換の行列\boldsymbol{M}に当たるということになります。
これをちゃんと計算すれば、行列\boldsymbol{M}の要素と4つのパラメータ(\omega,k'_x,k_y,\varphi)の関係が出て、かつこれらのパラメータにより行列\boldsymbol{M}の要素を任意に設定できることが示せるかと思います。

ここで一区切り

記事が長くなったので、一旦ここで切ります。
次回からは実際にコードをいじっていこうと思います。

OpenCVやってみる- 31. テンプレート画像作成

春のパン祭りシール点数集計の続きです。

前回のおさらい

前回までで、ひとまず点数文字の輪郭が取れるようになりました。

  • Hue,Saturation情報を使って2値化
  • 階層認識ありで輪郭を検出、最上位階層の1つ下の輪郭を検出

ここから、テンプレートマッチングを使って文字の認識、識別をしていきたいと思います。
今回はテンプレート画像を用意するところまでとします。

検討内容

シール画像でのテンプレートマッチングをやりますが、いくつか考慮の必要なことが。

  • どうやってテンプレート画像を用意するか
    シールのデザインが年ごとに違ったので、その辺も要検討かな。
  • 輪郭のままでテンプレートマッチングするか?
    輪郭描画の関数を見たところ、輪郭の内部を塗りつぶすこともできるようで、テンプレートマッチングのマスクとして使えそうな感じがしています。
  • シールの回転、変形にどう対処するか
    シールの回転はもともと考えないとな、と思っていたところですが、回転を考慮した外接矩形を返してくれる関数があるようなので、これが使えそう。外接矩形の縦横比で変形へもある程度対応できそうな気がします。

以下検討してみます。

元データの用意

ひとまず今用意できている各年(2019,2020,2021)の画像から、今までにやった点数文字輪郭検出をして、そこからテンプレートにするものを選びたいと思います。

import cv2
import numpy as np
%matplotlib inline
from matplotlib import pyplot as plt

img1 = cv2.imread('harupan_190428_1.jpg')
img3 = cv2.imread('harupan_200317_1.jpg')
img5 = cv2.imread('harupan_210402_1.jpg')
# image: Input image, BGR format
def calculate_harupan(image, debug):
    h, w, chs = image.shape
    if h > 800 or w > 800:
        k = 800.0/h if w > h else 800.0/w
    else:
        k = 1.0
    img = cv2.resize(image, None, fx=k, fy=k, interpolation=cv2.INTER_AREA)
    if debug:
        print('Resized to ', img.shape)
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    # Convert hue value (rotation, mask by saturation)
    hsv[:,:,0] = np.where(hsv[:,:,0] < 50, hsv[:,:,0]+180, hsv[:,:,0])
    hsv[:,:,0] = np.where(hsv[:,:,1] < 100, 0, hsv[:,:,0])
    # Thresholding with cv2.inRange(), using different threshold
    th_hue = cv2.inRange(hsv[:,:,0], 135, 190)
    contours, hierarchy = cv2.findContours(th_hue, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    indices0 = [i for i,hier in enumerate(hierarchy[0,:,:]) if hier[3] == -1]
    indices1 = [i for i,hier in enumerate(hierarchy[0,:,:]) if hier[3] in indices0]
    if debug:
        print('Number of contours: ', len(contours))
        print('Number of indices0: ', len(indices0), 'indices1: ', len(indices1))
    contours1 = [contours[i] for i in indices1]
    contours1_filtered = [ctr for ctr in contours1 if cv2.contourArea(ctr) > 800*800/4000]
    if debug:
        return contours1_filtered, img
    else:
        return contours1_filtered
imgs = [img1, img3, img5]

plt.figure(figsize=(20,15), dpi=100)
for i,im in enumerate(imgs):
    ctrs, img_resize = calculate_harupan(im, True)
    img_ctrs = cv2.drawContours(img_resize, ctrs, -1, (0,255,0), 2)
    plt.subplot(131+i), plt.imshow(cv2.cvtColor(img_ctrs, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])    
plt.show()
Resized to  (1067, 800, 3)
Number of contours:  2514
Number of indices0:  1448 indices1:  875
Resized to  (1067, 800, 3)
Number of contours:  2062
Number of indices0:  1154 indices1:  718
Resized to  (1067, 800, 3)
Number of contours:  1613
Number of indices0:  698 indices1:  795



f:id:nokixa:20211231011143p:plain

1つずつ輪郭データを見ていきますが、トラックバーの機能を使うと良さそう。

トラックバー

OpenCVでもトラックバーを出す機能はありますし、他にipywidgetsというものでもできるようです。

http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_gui/py_trackbar/py_trackbar.html#trackbar
https://epic-life.me/archives/1363

  • ipywidgetsのトラックバー

https://qiita.com/ciela/items/55dc860433d52228ce20
https://qiita.com/ground0state/items/58a576cf09a56a0dd425

jupyter上でやってみましたが、OpenCVのほうはいまいちだったので、ipywidgetsでやった結果を以下に示します。

ひとまずipywidgetsが入っていることの確認。

pip list | grep ipywidgets
STDIN
ipywidgets                         7.5.1
Note: you may need to restart the kernel to use updated packages.

では、ipywidgetsのトラックバーを使ってみます。

ctrs1, img1_resize = calculate_harupan(img1, True)

def drawContour_img1(idx):
    img1_ctr = cv2.drawContours(img1_resize.copy(), [ctrs1[idx]], -1, (0,255,0), 2)
    plt.imshow(cv2.cvtColor(img1_ctr, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
    plt.show()
Resized to  (1067, 800, 3)
Number of contours:  2514
Number of indices0:  1448 indices1:  875
from ipywidgets import interact
interact(drawContour_img1,idx=(0,len(ctrs1)-1));

f:id:nokixa:20211231012855p:plain

いい感じにjupyter上でトラックバーを動かして1つずつ輪郭表示ができています。
キャプチャした動画も。

from IPython.display import Image
Image('blog_OpenCV_31-Jupyter-Notebook-Google-Chrome-2021-12-28-01-42-05_Trim.gif')

f:id:nokixa:20211231013102g:plain

テンプレート選択

上で見た中から、文字の種類ごとに1つずつテンプレートを選びます。
Bounding Box(外接矩形)も確認して、画像から切り出してみます。

Bounding Box参考
https://docs.opencv.org/4.5.2/dd/d49/tutorial_py_contour_features.html

ctrs1_idx_zero = 26
ctrs1_idx_one = 27
ctrs1_idx_two = 24
ctrs1_idx_three = 33
ctrs1_idx_five = 35

ctrs1_idx_numbers = [ctrs1_idx_zero, ctrs1_idx_one, ctrs1_idx_two, ctrs1_idx_three, ctrs1_idx_five]
plt.figure(figsize=(20,15), dpi=100)
for i,idx in enumerate(ctrs1_idx_numbers):
    img = cv2.drawContours(img1_resize.copy(), [ctrs1[idx]], -1, (0,255,0), 2)
    x,y,w,h = cv2.boundingRect(ctrs1[idx])
    plt.subplot(2,5,1+i), plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
    plt.subplot(2,5,6+i), plt.imshow(cv2.cvtColor(img[y:y+h,x:x+w,:], cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211231011149p:plain

いい感じです。

あと2点やっておきたいのが、

  • 回転を考慮した外接矩形を用意、また、輪郭画像の向きをまっすぐに回転させる
  • 輪郭内部を塗りつぶしてテンプレートデータとする

というところです。

回転角を考慮した外接矩形

cv2.minAreaRect()関数で用意することができます。
返り値は、(中心座標(x,y), (幅,高さ), 回転角)となるとのこと。
回転角の基準、向きはどうなるのか…

rect1_zero = cv2.minAreaRect(ctrs1[ctrs1_idx_zero])
print(rect1_zero)
center1_zero = rect1_zero[0]
size1_zero = rect1_zero[1]
angle1_zero = rect1_zero[2]
((645.6923828125, 285.5384826660156), (54.07914352416992, 39.194976806640625), 82.87498474121094)
box1_zero = cv2.boxPoints(rect1_zero)
box1_zero = np.int0(box1_zero)
bound1_zero = cv2.boundingRect(box1_zero)
img1_zero = cv2.drawContours(img1_resize.copy(), [box1_zero], -1, (0,255,0), 2)
img1_zero = img1_zero[bound1_zero[1]:bound1_zero[1]+bound1_zero[3], bound1_zero[0]:bound1_zero[0]+bound1_zero[2],:]

plt.figure(figsize=(3,2), dpi=100)
plt.imshow(cv2.cvtColor(img1_zero, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211231011153p:plain

この画像を回転してみます。 cv2.getRotationMatrix2D()cv2.warpAffine()を使います。
https://docs.opencv.org/4.5.2/da/d6e/tutorial_py_geometric_transformations.html

  • 一旦Bounding Boxの中心を中心として、外接矩形の角度分回転させます。
  • 並進変換を行い、中心が外接矩形(回転考慮あり)の中心座標に来るようにします。
  • これら2つの変換を1つの行列で表現し、外接矩形(回転考慮なし)の範囲の画像に適用します。
    変換後の画像サイズは外接矩形のサイズにします。
Mrot1_zero = cv2.getRotationMatrix2D((bound1_zero[2]/2.0, bound1_zero[3]/2.0), angle1_zero, 1)
Mrot1_zero[0,2] += (size1_zero[0]-bound1_zero[2])/2.0
Mrot1_zero[1,2] += (size1_zero[1]-bound1_zero[3])/2.0
print(Mrot1_zero)
rot1_zero = cv2.warpAffine(img1_zero, Mrot1_zero, np.int0(size1_zero))
plt.figure(figsize=(3,2), dpi=100)
plt.imshow(cv2.cvtColor(rot1_zero, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()
[[ 0.12403472  0.99227788 -5.14744149]
 [-0.99227788  0.12403472 39.25699445]]



f:id:nokixa:20211231011156p:plain

まあまあ思ったような感じで回転できました。
他の文字も外接矩形の算出と回転をやってみます。

def align_and_clip_image(img, ctr):
    rect = cv2.minAreaRect(ctr)
    center = rect[0]
    size = rect[1]
    angle = rect[2]
    box = cv2.boxPoints(rect)
    box = np.int0(box)
    bound = cv2.boundingRect(box)
    img_clip = cv2.drawContours(img.copy(), [ctr], -1, (0,255,0),2)
    img_clip = img_clip[bound[1]:bound[1]+bound[3], bound[0]:bound[0]+bound[2],:]
    M = cv2.getRotationMatrix2D((bound[2]/2.0, bound[3]/2.0), angle, 1)
    M[0,2] += (size[0]-bound[2])/2.0
    M[1,2] += (size[1]-bound[3])/2.0
    img_clip = cv2.warpAffine(img_clip, M, np.int0(size))
    return img_clip
plt.figure(figsize=(20,15), dpi=100)
for i,idx in enumerate(ctrs1_idx_numbers):
    img = align_and_clip_image(img1_resize, ctrs1[idx])
    plt.subplot(1,5,1+i), plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211231011158p:plain

いずれもそれなりにまっすぐになっていますが、矩形の向きの定義がどうなるのかやはり不明です。
0,90,180,270°の4つの回転パターンが出てきそうなので、テンプレートマッチングするときにも4パターンの比較をするのがよさそうかと思います。

輪郭内部の塗りつぶし

cv2.drawContours()で輪郭描画するときに、thickness(線分太さ)引数に負の値を入れると輪郭内部の塗りつぶしになるようです。

上で表示した3画像で確認してみます。

imgs = [img1, img3, img5]

plt.figure(figsize=(20,15), dpi=100)
for i,im in enumerate(imgs):
    ctrs, img_resize = calculate_harupan(im, True)
    # thickness for cv2.drawContours() is -1
    img_ctrs = cv2.drawContours(img_resize, ctrs, -1, (0,255,0), -1)
    plt.subplot(131+i), plt.imshow(cv2.cvtColor(img_ctrs, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])    
plt.show()
Resized to  (1067, 800, 3)
Number of contours:  2514
Number of indices0:  1448 indices1:  875
Resized to  (1067, 800, 3)
Number of contours:  2062
Number of indices0:  1154 indices1:  718
Resized to  (1067, 800, 3)
Number of contours:  1613
Number of indices0:  698 indices1:  795



f:id:nokixa:20211231011204p:plain

確かに塗りつぶしされています。
1つ目の画像で選んだ文字輪郭でもやってみます。

plt.figure(figsize=(20,15), dpi=100)
for i,idx in enumerate(ctrs1_idx_numbers):
    # thickness for cv2.drawContours() is -1
    img = cv2.drawContours(img1_resize.copy(), [ctrs1[idx]], -1, (0,255,0), -1)
    x,y,w,h = cv2.boundingRect(ctrs1[idx])
    plt.subplot(2,5,1+i), plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
    plt.subplot(2,5,6+i), plt.imshow(cv2.cvtColor(img[y:y+h,x:x+w,:], cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211231011210p:plain

いい感じです。

テンプレート画像作成

今までやったところから、テンプレート画像を作ってみたいと思います。

  • 輪郭を選ぶ
  • 輪郭の外接矩形のサイズで黒画像を作る
  • 黒画像上に輪郭を塗りつぶしで描画
  • この画像を回転させる

という手順になります。

輪郭描画のときに、必要なだけのサイズの画像を対象としようと思うので、輪郭データの座標をいじる必要があります。
これを確認します。
まずは輪郭データの構造から。

type(ctrs1)
list
type(ctrs1[ctrs1_idx_zero])
numpy.ndarray
ctrs1[ctrs1_idx_zero].shape
(62, 1, 2)
ctrs1[ctrs1_idx_zero][0:10,:,:]
array([[[639, 260]],

       [[640, 259]],

       [[647, 259]],

       [[648, 260]],

       [[650, 260]],

       [[651, 261]],

       [[652, 261]],

       [[659, 268]],

       [[659, 269]],

       [[661, 271]]], dtype=int32)

点の座標は、(水平座標, 垂直座標)でよさそうです。
輪郭の外接矩形の原点を引けば、外接矩形内の座標が出せます。

ctrs1[ctrs1_idx_zero][0:10,:,:] - bound1_zero[0:2]
array([[[17,  4]],

       [[18,  3]],

       [[25,  3]],

       [[26,  4]],

       [[28,  4]],

       [[29,  5]],

       [[30,  5]],

       [[37, 12]],

       [[37, 13]],

       [[39, 15]]])

では、テンプレート画像生成の処理を書いてみます。
また、1つ目の画像に適用してみます。

def create_template(img, ctr):
    rect = cv2.minAreaRect(ctr)
    center = rect[0]
    size = rect[1]
    angle = rect[2]
    box = cv2.boxPoints(rect)
    box = np.int0(box)
    bound = cv2.boundingRect(box)
    img_template = np.zeros((bound[3],bound[2]), 'uint8')
    ctr = ctr - bound[0:2]
    img_template = cv2.drawContours(img_template, [ctr], -1, 255,-1)
    M = cv2.getRotationMatrix2D((bound[2]/2.0, bound[3]/2.0), angle, 1)
    M[0,2] += (size[0]-bound[2])/2.0
    M[1,2] += (size[1]-bound[3])/2.0
    img_template = cv2.warpAffine(img_template, M, np.int0(size))
    return img_template
plt.figure(figsize=(20,15), dpi=100)
for i,idx in enumerate(ctrs1_idx_numbers):
    img = create_template(img1_resize, ctrs1[idx])
    plt.subplot(1,5,1+i), plt.imshow(img, cmap='gray'), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211231011215p:plain

結果良好。

ということで、残りの2画像についてもテンプレートの選択、テンプレート画像の作成をやってみます。

ctrs3, img3_resize = calculate_harupan(img3, True)

def drawContour_img3(idx):
    img3_ctr = cv2.drawContours(img3_resize.copy(), [ctrs3[idx]], -1, (0,255,0), 2)
    plt.imshow(cv2.cvtColor(img3_ctr, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
    plt.show()
Resized to  (1067, 800, 3)
Number of contours:  2062
Number of indices0:  1154 indices1:  718
interact(drawContour_img3,idx=(0,len(ctrs3)-1));

f:id:nokixa:20211231012858p:plain

ctrs3_idx_zero = 7
ctrs3_idx_one = 4
ctrs3_idx_two = 17
ctrs3_idx_five = 6
ctrs3_idx_numbers = [ctrs3_idx_zero, ctrs3_idx_one, ctrs3_idx_two, ctrs3_idx_five]
plt.figure(figsize=(20,15), dpi=100)
for i,idx in enumerate(ctrs3_idx_numbers):
    img = create_template(img3_resize, ctrs3[idx])
    plt.subplot(1,4,1+i), plt.imshow(img, cmap='gray'), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211231011218p:plain

ctrs5, img5_resize = calculate_harupan(img5, True)

def drawContour_img5(idx):
    img5_ctr = cv2.drawContours(img5_resize.copy(), [ctrs5[idx]], -1, (0,255,0), 2)
    plt.imshow(cv2.cvtColor(img5_ctr, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
    plt.show()
Resized to  (1067, 800, 3)
Number of contours:  1613
Number of indices0:  698 indices1:  795
interact(drawContour_img5,idx=(0,len(ctrs5)-1));

f:id:nokixa:20211231012829p:plain

ctrs5_idx_zero = 3
ctrs5_idx_one = 4
ctrs5_idx_one_2 = 8
ctrs5_idx_two = 2
ctrs5_idx_five = 5
ctrs5_idx_numbers = [ctrs5_idx_zero, ctrs5_idx_one, ctrs5_idx_one_2, ctrs5_idx_two, ctrs5_idx_five]
plt.figure(figsize=(20,15), dpi=100)
for i,idx in enumerate(ctrs5_idx_numbers):
    img = create_template(img5_resize, ctrs5[idx])
    plt.subplot(1,5,1+i), plt.imshow(img, cmap='gray'), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211231011221p:plain

いずれもいい結果になっています。
5番目画像では、'1'の文字について、傾いているものとまっすぐのものでテンプレートを作ってみましたが、ほぼ同じ結果が得られています。

どの年も少しずつ点数文字のフォントが違うみたいです。
今やっている結果から、来年の春のパン祭りで使えるツールを作ってみたいですが、そのときは改めてテンプレート作成をする必要があるかと。

以上

今回はここまでです。
次回は、実際にテンプレートマッチングをやってみたいと思います。

OpenCVやってみる- 30. 点数文字輪郭検出3

引き続きの春のパン祭りシール点数集計。

前回の結果再確認

前回の結果をよく見ると、点数文字の輪郭をきちんと取れていない部分がありました。
3つ目の画像で、中央上部の'1'、'2'の輪郭が取れていません。

f:id:nokixa:20211213072314p:plain

対応を検討します。

様子チェック

まずは問題の画像を見てみます。

import cv2
import numpy as np
%matplotlib inline
from matplotlib import pyplot as plt

img3 = cv2.imread('harupan_200317_1.jpg')
img3 = cv2.resize(img3, None, fx=800.0/img3.shape[1], fy=800.0/img3.shape[1], interpolation=cv2.INTER_AREA)
img3_hsv = cv2.cvtColor(img3, cv2.COLOR_BGR2HSV)
ret, th_hue = cv2.threshold(img3_hsv[:,:,0], 135, 255, cv2.THRESH_BINARY)
plt.figure(figsize=(64,48), dpi=100)
plt.subplot(131), plt.imshow(cv2.cvtColor(img3, cv2.COLOR_BGR2RGB)), plt.title('Original',fontsize=60), plt.xticks([]), plt.yticks([])
plt.subplot(132), plt.imshow(img3_hsv[:,:,0], cmap='gray'), plt.title('Hue',fontsize=60), plt.xticks([]), plt.yticks([])
plt.subplot(133), plt.imshow(th_hue, cmap='gray'), plt.title('Thresholded',fontsize=60), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211223060713p:plain

Hue画像を見てみると、輪郭取得に失敗していたシールではシール領域の一部のはずのエリアで黒くなっているように見えます。おそらく元々のシールの色が赤っぽくて、照明の具合で色相がちょうど一周してしまったのではないかと。
その結果、2値化画像でもシール領域の一部が欠けて、外周輪郭が取れなかったものと考えられます。
Hueのヒストグラムも見てみます。

plt.hist(img3_hsv[:,:,0].ravel(),180, [0,180])
plt.show()

f:id:nokixa:20211223060730p:plain

やはり分布が179(最大値)まで達していて、分布の一部が一周して0付近に来ているようです。

対応検討

HueよりSaturation(彩度)のほうが使えそうな気がしてきたがどうだろう。
台紙は白色でSaturationは低く、シールの色は鮮やかなピンクでSaturationが高くなります。

まずはSaturationのヒストグラムを確認します。

plt.hist(img3_hsv[:,:,1].ravel(),256, [0,256])
plt.show()

f:id:nokixa:20211223060732p:plain

ひとまず閾値は100ぐらいでよさそうかな。
Saturation画像と、2値化した画像を出してみます。

ret, th_sat = cv2.threshold(img3_hsv[:,:,1], 100, 255, cv2.THRESH_BINARY)
plt.figure(figsize=(64,48), dpi=100)
plt.subplot(131), plt.imshow(cv2.cvtColor(img3, cv2.COLOR_BGR2RGB)), plt.title('Original',fontsize=60), plt.xticks([]), plt.yticks([])
plt.subplot(132), plt.imshow(img3_hsv[:,:,1], cmap='gray'), plt.title('Sat',fontsize=60), plt.xticks([]), plt.yticks([])
plt.subplot(133), plt.imshow(th_sat, cmap='gray'), plt.title('Thresholded',fontsize=60), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211223060740p:plain

これでシール輪郭は取れそうですが、少し心配な点が。

  • フローリングも検出されているので、第一輪郭を取ると台紙の外形が取れてしまうのでは?
  • そうすると第二輪郭はシール外形となってしまう

やっぱりHueの情報も使っていきたいなと。
Hue境界の問題については、値の範囲の変換でどうかと。
すなわち、[0,50)ぐらいの範囲を[180,230)に持ってくる変換をすれば、紫~赤の範囲が連続するようになります。

img3_hsv[:,:,0] = np.where(img3_hsv[:,:,0] < 50, img3_hsv[:,:,0]+180, img3_hsv[:,:,0])
plt.hist(img3_hsv[:,:,0].ravel(),256, [0,256])
plt.show()

f:id:nokixa:20211223060759p:plain

ちょっと思い付き。
Saturationの値が低かったらHueの値を0にしてしまうとどうだろう。
HueとSaturationでの2値化処理をまとめてできるし、Hueの値のふるい分けもしやすくなる。

また、色々調べていて、cv2.inRange()という2値化に便利な関数があることを知ったので、以下ではこれを使っています。

https://docs.opencv.org/4.x/da/d97/tutorial_threshold_inRange.html

img3_hsv[:,:,0] = np.where(img3_hsv[:,:,1] < 100, 0, img3_hsv[:,:,0])
plt.hist(img3_hsv[:,:,0].ravel(),256, [0,256])
plt.show()

f:id:nokixa:20211223060801p:plain

これを元に2値化します。閾値は、今までは135~179にしていましたが、今回は135~190に広げてみます。

th_hue = cv2.inRange(img3_hsv[:,:,0], 135, 190)
plt.figure(figsize=(64,48), dpi=100)
plt.subplot(131), plt.imshow(cv2.cvtColor(img3, cv2.COLOR_BGR2RGB)), plt.title('Original',fontsize=60), plt.xticks([]), plt.yticks([])
plt.subplot(132), plt.imshow(img3_hsv[:,:,0], cmap='gray'), plt.title('Hue',fontsize=60), plt.xticks([]), plt.yticks([])
plt.subplot(133), plt.imshow(th_hue, cmap='gray'), plt.title('Thresholded',fontsize=60), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211223060808p:plain

これで点数文字輪郭の検出を試してみると、

contours, hierarchy = cv2.findContours(th_hue, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
indices0 = [i for i,hier in enumerate(hierarchy[0,:,:]) if hier[3] == -1]
indices1 = [i for i,hier in enumerate(hierarchy[0,:,:]) if hier[3] in indices0]
contours1 = [contours[i] for i in indices1]
contours1_filtered = [ctr for ctr in contours1 if cv2.contourArea(ctr) > 800*800/4000]
img_ctrs = cv2.drawContours(img3, contours1_filtered, -1, (0,255,0), 2)
plt.figure(figsize=(10,10), dpi=100)
plt.imshow(cv2.cvtColor(img_ctrs, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211223060824p:plain

なんとかいけました!

再確認

他の画像でも再確認してみます。
前回作った関数を変更して適用する感じです。

# image: Input image, BGR format
def calculate_harupan(image, debug):
    h, w, chs = image.shape
    if h > 800 or w > 800:
        k = 800.0/h if w > h else 800.0/w
    else:
        k = 1.0
    img = cv2.resize(image, None, fx=k, fy=k, interpolation=cv2.INTER_AREA)
    if debug:
        print('Resized to ', img.shape)
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    # Convert hue value (rotation, mask by saturation)
    hsv[:,:,0] = np.where(hsv[:,:,0] < 50, hsv[:,:,0]+180, hsv[:,:,0])
    hsv[:,:,0] = np.where(hsv[:,:,1] < 100, 0, hsv[:,:,0])
    # Thresholding with cv2.inRange(), using different threshold
    th_hue = cv2.inRange(hsv[:,:,0], 135, 190)
    contours, hierarchy = cv2.findContours(th_hue, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    indices0 = [i for i,hier in enumerate(hierarchy[0,:,:]) if hier[3] == -1]
    indices1 = [i for i,hier in enumerate(hierarchy[0,:,:]) if hier[3] in indices0]
    if debug:
        print('Number of contours: ', len(contours))
        print('Number of indices0: ', len(indices0), 'indices1: ', len(indices1))
    contours1 = [contours[i] for i in indices1]
    contours1_filtered = [ctr for ctr in contours1 if cv2.contourArea(ctr) > 800*800/4000]
    if debug:
        return contours1_filtered, img
    else:
        return contours1_filtered
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')

imgs = [img1, img2, img3, img4, img5, img6, img7]

plt.figure(figsize=(20,15), dpi=100)
for i,im in enumerate(imgs):
    ctrs, img_resize = calculate_harupan(im, True)
    img_ctrs = cv2.drawContours(img_resize, ctrs, -1, (0,255,0), 2)
    plt.subplot(241+i), plt.imshow(cv2.cvtColor(img_ctrs, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
    
plt.show()
Resized to  (1067, 800, 3)
Number of contours:  2514
Number of indices0:  1448 indices1:  875
Resized to  (1067, 800, 3)
Number of contours:  2269
Number of indices0:  1265 indices1:  818
Resized to  (1067, 800, 3)
Number of contours:  2062
Number of indices0:  1154 indices1:  718
Resized to  (1067, 800, 3)
Number of contours:  1204
Number of indices0:  450 indices1:  664
Resized to  (1067, 800, 3)
Number of contours:  1613
Number of indices0:  698 indices1:  795
Resized to  (1067, 800, 3)
Number of contours:  1242
Number of indices0:  373 indices1:  777
Resized to  (1067, 800, 3)
Number of contours:  1258
Number of indices0:  555 indices1:  595

f:id:nokixa:20211223060830p:plain

閾値が少し心配でしたが、照明の反射で取得できなかったものを除いて、きちんと点数文字の輪郭が取得できました!

以上

今回はここまでです。
次回は、前回考えていたようにテンプレートマッチングをやっていきます。

OpenCVやってみる- 29. 点数文字輪郭検出2

春のパン祭りシール点数集計の続きです。

今回の内容

前回、春のパン祭り画像から点数文字の輪郭を取得できましたが、まだ1画像でしか試していないので、 他の画像でもやってみます。

関数化

いつも通りの手順ですが、内容が増えてきたので、関数化しておきたいと思います。
関数の内容は、

  • 画像のリサイズ
  • 2値化
  • 輪郭検出

になります。

リサイズについて

画像のリサイズについては、今までは固定の縮小率にしていましたが、今回はリサイズ後の画像サイズの目標を設定して、それに向けたリサイズをするようにします。

前回も少し考えたように、

  • シール台紙は画像全体の縦横半分程度は写るようにする
  • シールが縦横に5個ずつ並ぶ

という前提条件を設定して、あとシール領域の解像度の目標を決めたいですが、
前回の画像を見返してみると、リサイズ後でだいたい縦横80pixelずつでした。
ということで、

  • 解像度は縦横それぞれ80x5x2=800pixel以上

にリサイズするようにしたいと思います。

2値化について

前回は2値化の後にClosing処理で細かい輪郭の除去をしていましたが、輪郭検出後の面積でのフィルタリングでも十分そうだったので、Closing処理は省いてみます。

import cv2
import numpy as np
%matplotlib inline
from matplotlib import pyplot as plt

# image: Input image, BGR format
def calculate_harupan(image, debug):
    h, w, chs = image.shape
    if h > 800 or w > 800:
        k = 800.0/h if w > h else 800.0/w
    else:
        k = 1.0
    img = cv2.resize(image, None, fx=k, fy=k, interpolation=cv2.INTER_AREA)
    if debug:
        print('Resized to ', img.shape)
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    ret, th_hue = cv2.threshold(hsv[:,:,0], 135, 255, cv2.THRESH_BINARY)
    contours, hierarchy = cv2.findContours(th_hue, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    indices0 = [i for i,hier in enumerate(hierarchy[0,:,:]) if hier[3] == -1]
    indices1 = [i for i,hier in enumerate(hierarchy[0,:,:]) if hier[3] in indices0]
    if debug:
        print('Number of contours: ', len(contours))
        print('Number of indices0: ', len(indices0), 'indices1: ', len(indices1))
    contours1 = [contours[i] for i in indices1]
    contours1_filtered = [ctr for ctr in contours1 if cv2.contourArea(ctr) > 800*800/4000]
    if debug:
        return contours1_filtered, img
    else:
        return contours1_filtered

実践

では、各画像に適用してみます。

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')

imgs = [img1, img2, img3, img4, img5, img6, img7]

plt.figure(figsize=(20,15), dpi=100)
for i,im in enumerate(imgs):
    ctrs, img_resize = calculate_harupan(im, True)
    img_ctrs = cv2.drawContours(img_resize, ctrs, -1, (0,255,0), 2)
    plt.subplot(241+i), plt.imshow(cv2.cvtColor(img_ctrs, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
    
plt.show()
Resized to  (1067, 800, 3)
Number of contours:  2534
Number of indices0:  1264 indices1:  1048
Resized to  (1067, 800, 3)
Number of contours:  2427
Number of indices0:  1217 indices1:  1049
Resized to  (1067, 800, 3)
Number of contours:  1836
Number of indices0:  434 indices1:  630
Resized to  (1067, 800, 3)
Number of contours:  1021
Number of indices0:  295 indices1:  683
Resized to  (1067, 800, 3)
Number of contours:  1689
Number of indices0:  480 indices1:  1063
Resized to  (1067, 800, 3)
Number of contours:  1320
Number of indices0:  367 indices1:  844
Resized to  (1067, 800, 3)
Number of contours:  873
Number of indices0:  424 indices1:  392

f:id:nokixa:20211213072314p:plain

いい感じです。

気になるのは、

  • 1番目の画像、シール部分での光の反射で'0'の文字が1つ検出できていない
  • 3番目の画像、点数文字以外の輪郭が残っている
    おそらく台紙が画像全体に写っていて、細かい文字の面積も大きくなってしまったため

というところ。

光の反射は対処が難しいなー...
どうしようか。

ここまで

今回はここまでにします。
一部課題はありますが、おおむね前回の方針でよさそうということで。

次回はテンプレートマッチングをやっていきたいと思います。

OpenCVやってみる - 28. 点数文字輪郭検出

前回の続きです。
春のパン祭り点数集計を進めていきます。

方針再検討

前回の結果を振り返りつつ、改めて方式を検討します。

前回の状況

  • Hue画像での2値化から、シール領域の輪郭取得を実施した
  • シールの重なりにより、輪郭どうしがつながってしまっていた

輪郭がつながってしまった件について、Watershedアルゴリズムも考えていましたが、参考サイトを見ると、まずシール領域の中心付近の領域を取得して、そこから領域を広げていきながら境界を見つける、という処理のよう。

f:id:nokixa:20211207075342p:plain:w400 f:id:nokixa:20211207075346p:plain:w400

前回の画像を見ると、収縮処理や距離変換でシール領域の中心を見つけるのは難しそう。
ということでWatershedアルゴリズムもやってみたかったけど諦めます。

ただ、この2値化画像の最外周輪郭を見ると、全てのシールが内側に含まれていて、この内側の輪郭で点数文字が取得できそうに思えます。
点数文字を取得したら、そこからテンプレートマッチングで点数を識別します。
これを進めてみたいと思います。

台紙やシールの外形によるスケーリング、変形も考えていましたが、これは諦めで。

下準備

まずはいつもの下準備から。前回の5番目画像だけでやります。

※今回、cv2.resize()での縮小率を0.1から0.2に変更しています。
元の縮小率で進めていましたが、うまくいかなかったりしたので。
あまり大きくすると処理が重くなりそうですが、適度な解像度がありそうです。

import cv2
import numpy as np
%matplotlib inline
from matplotlib import pyplot as plt

img5 = cv2.imread('harupan_210402_1.jpg')
img5 = cv2.resize(img5, None, fx=0.2, fy=0.2, interpolation=cv2.INTER_AREA)
print(img5.shape)
(806, 605, 3)
plt.figure(figsize=(6.4,100), dpi=100)
plt.imshow(cv2.cvtColor(img5, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211211022046p:plain

輪郭取得

輪郭を取得しますが、輪郭の階層を利用します。

cv2.findContour()関数では、輪郭取得のモード指定で、輪郭の階層構造も一緒に取得できます。

https://docs.opencv.org/4.5.2/d9/d8b/tutorial_py_contours_hierarchy.html

前回はcv2.RETR_EXTERNALを指定したので最外周輪郭のみ取得になりましたが、cv2.RETR_TREEであれば全輪郭と全階層構造を取得できます。
階層構造は、(1,輪郭の数,4)の形状のndarrayとして得られて、1輪郭あたりの情報としては[Next, Previous, First_Child, Parent]という形で輪郭のインデックスが入ってきます。該当する輪郭がなければ-1が入ります。

  • Next: 同じ階層の次の輪郭
  • Previous: 同じ階層の前の輪郭
  • First_Child: 一つ下の階層の1番目の輪郭
  • Parent: 1つ上の階層の輪郭

全輪郭の取得、表示

まず全輪郭を見てみて、点数文字の輪郭が取れていそうか確認します。

img5_hsv = cv2.cvtColor(img5, cv2.COLOR_BGR2HSV)
ret, img5_th_hue = cv2.threshold(img5_hsv[:,:,0], 135, 255, cv2.THRESH_BINARY)
img5_contours, img5_hierarchy = cv2.findContours(img5_th_hue, cv2.RETR_TREE, 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()

print('Shape: ', img5_hierarchy.shape, '\nContents: \n', img5_hierarchy[0,0:20,:], ' ...')

f:id:nokixa:20211211022051p:plain

Shape:  (1, 1618, 4) 
Contents: 
 [[ 1 -1 -1 -1]
 [ 2  0 -1 -1]
 [ 3  1 -1 -1]
 [ 4  2 -1 -1]
 [ 5  3 -1 -1]
 [ 6  4 -1 -1]
 [ 7  5 -1 -1]
 [ 8  6 -1 -1]
 [ 9  7 -1 -1]
 [10  8 -1 -1]
 [11  9 -1 -1]
 [12 10 -1 -1]
 [13 11 -1 -1]
 [14 12 -1 -1]
 [15 13 -1 -1]
 [16 14 -1 -1]
 [17 15 -1 -1]
 [18 16 -1 -1]
 [19 17 -1 -1]
 [20 18 -1 -1]]  ...

大丈夫そうです。

輪郭を階層ごとに分ける

最外周輪郭は、Parentが-1になっている輪郭、ということで探せます。そしてもう1つ下の階層の輪郭は、最外周輪郭をParentとする輪郭、ということで探せます。

img5_indices_level0 = [i for i,hier in enumerate(img5_hierarchy[0,:,:]) if hier[3] == -1]
img5_contours_level0 = [img5_contours[i] for i in img5_indices_level0]
img5_hierarchy_level0 = [img5_hierarchy[0,i,:] for i in img5_indices_level0]
print('Contours Level0')
print('  Number of contours: ', len(img5_hierarchy_level0))
print('  Indices: ', img5_indices_level0[0:min(100,len(img5_indices_level0))], ' ...')
print('  Contents:')
[print(img5_hierarchy_level0[i]) for i in range(20)];
print('...')
Contours Level0
  Number of contours:  499
  Indices:  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 64, 66, 68, 70, 72, 73, 74, 75, 76, 79, 80, 81, 82, 83, 84, 85, 87, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 118, 119, 120, 123, 124]  ...
  Contents:
[ 1 -1 -1 -1]
[ 2  0 -1 -1]
[ 3  1 -1 -1]
[ 4  2 -1 -1]
[ 5  3 -1 -1]
[ 6  4 -1 -1]
[ 7  5 -1 -1]
[ 8  6 -1 -1]
[ 9  7 -1 -1]
[10  8 -1 -1]
[11  9 -1 -1]
[12 10 -1 -1]
[13 11 -1 -1]
[14 12 -1 -1]
[15 13 -1 -1]
[16 14 -1 -1]
[17 15 -1 -1]
[18 16 -1 -1]
[19 17 -1 -1]
[20 18 -1 -1]
...
img5_indices_level1 = [i for i,hier in enumerate(img5_hierarchy[0,:,:]) if hier[3] in img5_indices_level0]
img5_contours_level1 = [img5_contours[i] for i in img5_indices_level1]
img5_hierarchy_level1 = [img5_hierarchy[0,i,:] for i in img5_indices_level1]
print('Contours Level1')
print('  Number of contours: ', len(img5_hierarchy_level1))
print('  Indices: ', img5_indices_level1[0:min(100,len(img5_indices_level1))], ' ...')
print('  Contents:')
[print(img5_hierarchy_level1[i]) for i in range(20)];
print('...')
Contours Level1
  Number of contours:  1094
  Indices:  [29, 57, 58, 60, 65, 67, 69, 71, 77, 78, 86, 88, 89, 101, 102, 114, 115, 116, 117, 121, 122, 125, 128, 129, 135, 137, 138, 147, 169, 170, 183, 184, 226, 227, 228, 253, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 321, 322, 339, 354, 358, 364, 381, 382, 384, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399]  ...
  Contents:
[-1 -1 -1 28]
[58 -1 -1 56]
[60 57 59 56]
[-1 58 61 56]
[-1 -1 -1 64]
[-1 -1 -1 66]
[-1 -1 -1 68]
[-1 -1 -1 70]
[78 -1 -1 76]
[-1 77 -1 76]
[-1 -1 -1 85]
[89 -1 -1 87]
[-1 88 -1 87]
[102  -1  -1 100]
[ -1 101  -1 100]
[115  -1  -1 113]
[116 114  -1 113]
[117 115  -1 113]
[ -1 116  -1 113]
[122  -1  -1 120]
...
img5_with_contours0 = cv2.drawContours(img5.copy(), img5_contours_level0, -1, (0,255,0), 2)
img5_with_contours1 = cv2.drawContours(img5.copy(), img5_contours_level1, -1, (0,255,0), 2)
plt.figure(figsize=(6.4,100), dpi=100)
plt.subplot(121), plt.imshow(cv2.cvtColor(img5_with_contours0, cv2.COLOR_BGR2RGB)), plt.title('Level0'), plt.xticks([]), plt.yticks([])
plt.subplot(122), plt.imshow(cv2.cvtColor(img5_with_contours1, cv2.COLOR_BGR2RGB)), plt.title('Level1'), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211211022055p:plain

それらしい感じになりました。

モルフォロジー変換での2値化画像の調整

Level1の輪郭を見ると、細かい文字の部分の輪郭も取ってしまっているように見えます。
2値化の後にモルフォロジー変換のClosing処理(収縮→膨張)をやってから輪郭検出してみます。

kernel = np.ones((3,3),np.uint8)
img5_th_hue2 = cv2.morphologyEx(img5_th_hue, cv2.MORPH_CLOSE, kernel)
img5_contours, img5_hierarchy = cv2.findContours(img5_th_hue2, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

img5_indices_level0 = [i for i,hier in enumerate(img5_hierarchy[0,:,:]) if hier[3] == -1]
img5_contours_level0 = [img5_contours[i] for i in img5_indices_level0]
img5_hierarchy_level0 = [img5_hierarchy[0,i,:] for i in img5_indices_level0]
img5_indices_level1 = [i for i,hier in enumerate(img5_hierarchy[0,:,:]) if hier[3] in img5_indices_level0]
img5_contours_level1 = [img5_contours[i] for i in img5_indices_level1]
img5_hierarchy_level1 = [img5_hierarchy[0,i,:] for i in img5_indices_level1]

img5_with_contours0 = cv2.drawContours(img5.copy(), img5_contours_level0, -1, (0,255,0), 2)
img5_with_contours1 = cv2.drawContours(img5.copy(), img5_contours_level1, -1, (0,255,0), 2)
plt.figure(figsize=(6.4,100), dpi=100)
plt.imshow(cv2.cvtColor(img5_with_contours1, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211211022058p:plain

いい感じになってきました。

輪郭面積によるフィルタリング

ついでに、極端に小さい面積の輪郭は除去したほうがいいのかなと。
面積のヒストグラムを見てみます。

img5_areas_level1 = [cv2.contourArea(ctr) for ctr in img5_contours_level1]
plt.hist(img5_areas_level1, 100)
plt.show()

f:id:nokixa:20211211022103p:plain

小さい面積の部分を拡大してみると、

plt.hist(img5_areas_level1, 100, [0,500])
plt.show()

f:id:nokixa:20211211022105p:plain

面積100pixel分を閾値としてやってみます。

100pixelというと、10x10pixelぐらいの領域ですが、今回の画像は800x600ピクセルぐらいだったので、画像全体に対して縦1/80、横1/60ぐらい→画像全体の面積の1/4800のサイズになります。
それに対して、点数文字の大きさをかなりおおざっぱに

  • シール台紙が画像全体の縦横半分程度は写っているとする (写るようにする)
  • シールが縦横に5個ずつ並んでいる
  • 点数文字はシールのおよそ縦横半分程度の大きさはある

と考えると、画像全体に対してだいたい縦横1/20ぐらい→画像全体の面積の1/400程度のサイズとなります。 今回の画像でいうと、1000pixelぐらいになる?
ヒストグラムを見ると、200~500pixelあたりの面積の輪郭が点数文字に当たるかと。誤差は大きいですが、およそスケール感としてはそんなところかと。

画像全体の1/4000ぐらいを閾値とするのでいいかと思います。

img5_contours_level1_2 = [ctr for ctr in img5_contours_level1 if cv2.contourArea(ctr) > 100]
img5_with_contours1 = cv2.drawContours(img5.copy(), img5_contours_level1_2, -1, (0,255,0), 2)
plt.figure(figsize=(6.4,100), dpi=100)
plt.imshow(cv2.cvtColor(img5_with_contours1, cv2.COLOR_BGR2RGB)), plt.title('Level1'), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211211022108p:plain

よりいい感じになってきました。

今回はここまで

少し違うものも混ざってしまっていますが、およそいい感じに点数文字の輪郭が取れました。
次回はここから点数文字のテンプレートマッチングをやっていきたいと思います。

ついでに

一応元の縮小率(0.1)での結果を出しておきます。
輪郭面積でのフィルタリングのとき、面積の閾値は、縮小率が2倍になっていることを考慮して、上での閾値の1/4 -> 25とします。

img5_01 = cv2.imread('harupan_210402_1.jpg')
img5_01 = cv2.resize(img5_01, None, fx=0.1, fy=0.1, interpolation=cv2.INTER_AREA)
img5_01_hsv = cv2.cvtColor(img5_01, cv2.COLOR_BGR2HSV)

ret, img5_01_th_hue = cv2.threshold(img5_01_hsv[:,:,0], 135, 255, cv2.THRESH_BINARY)
img5_01_th_hue = cv2.morphologyEx(img5_01_th_hue, cv2.MORPH_CLOSE, kernel)
img5_01_contours, img5_01_hierarchy = cv2.findContours(img5_01_th_hue, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

img5_01_indices_level0 = [i for i,hier in enumerate(img5_01_hierarchy[0,:,:]) if hier[3] == -1]
img5_01_contours_level0 = [img5_01_contours[i] for i in img5_01_indices_level0]
img5_01_indices_level1 = [i for i,hier in enumerate(img5_01_hierarchy[0,:,:]) if hier[3] in img5_01_indices_level0]
img5_01_contours_level1 = [img5_01_contours[i] for i in img5_01_indices_level1]

img5_01_contours_level1_2 = [ctr for ctr in img5_01_contours_level1 if cv2.contourArea(ctr) > 25]
img5_01_with_contours1_2 = cv2.drawContours(img5_01.copy(), img5_01_contours_level1_2, -1, (0,255,0), 2)

img5_01_with_contours0 = cv2.drawContours(img5_01.copy(), img5_01_contours_level0, -1, (0,255,0), 1)
img5_01_with_contours1 = cv2.drawContours(img5_01.copy(), img5_01_contours_level1, -1, (0,255,0), 1)
img5_01_with_contours1_2 = cv2.drawContours(img5_01.copy(), img5_01_contours_level1_2, -1, (0,255,0), 1)

plt.figure(figsize=(100,100), dpi=100)
plt.subplot(131), plt.imshow(cv2.cvtColor(img5_01_with_contours0, cv2.COLOR_BGR2RGB)), plt.title('Level0'), plt.xticks([]), plt.yticks([])
plt.subplot(132), plt.imshow(cv2.cvtColor(img5_01_with_contours1, cv2.COLOR_BGR2RGB)), plt.title('Level1'), plt.xticks([]), plt.yticks([])
plt.subplot(133), plt.imshow(cv2.cvtColor(img5_01_with_contours1_2, cv2.COLOR_BGR2RGB)), plt.title('Level1_2'), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211211022113p:plain

こちらでは、2や5の文字輪郭がうまく取得できませんでした。

ただ、この後試しにモルフォロジー変換なしでやってみると、

ret, img5_01_th_hue = cv2.threshold(img5_01_hsv[:,:,0], 135, 255, cv2.THRESH_BINARY)
# img5_01_th_hue = cv2.morphologyEx(img5_01_th_hue, cv2.MORPH_CLOSE, kernel)
img5_01_contours, img5_01_hierarchy = cv2.findContours(img5_01_th_hue, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

img5_01_indices_level0 = [i for i,hier in enumerate(img5_01_hierarchy[0,:,:]) if hier[3] == -1]
img5_01_contours_level0 = [img5_01_contours[i] for i in img5_01_indices_level0]
img5_01_indices_level1 = [i for i,hier in enumerate(img5_01_hierarchy[0,:,:]) if hier[3] in img5_01_indices_level0]
img5_01_contours_level1 = [img5_01_contours[i] for i in img5_01_indices_level1]

img5_01_contours_level1_2 = [ctr for ctr in img5_01_contours_level1 if cv2.contourArea(ctr) > 25]
img5_01_with_contours1_2 = cv2.drawContours(img5_01.copy(), img5_01_contours_level1_2, -1, (0,255,0), 2)

img5_01_with_contours0 = cv2.drawContours(img5_01.copy(), img5_01_contours_level0, -1, (0,255,0), 1)
img5_01_with_contours1 = cv2.drawContours(img5_01.copy(), img5_01_contours_level1, -1, (0,255,0), 1)
img5_01_with_contours1_2 = cv2.drawContours(img5_01.copy(), img5_01_contours_level1_2, -1, (0,255,0), 1)

plt.figure(figsize=(100,100), dpi=100)
plt.subplot(131), plt.imshow(cv2.cvtColor(img5_01_with_contours0, cv2.COLOR_BGR2RGB)), plt.title('Level0'), plt.xticks([]), plt.yticks([])
plt.subplot(132), plt.imshow(cv2.cvtColor(img5_01_with_contours1, cv2.COLOR_BGR2RGB)), plt.title('Level1'), plt.xticks([]), plt.yticks([])
plt.subplot(133), plt.imshow(cv2.cvtColor(img5_01_with_contours1_2, cv2.COLOR_BGR2RGB)), plt.title('Level1_2'), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211211022132p:plain

という感じで、縮小率を変える前と同様の輪郭が取れました。

  • 縮小率の高い画像にとっては3x3カーネルが大き過ぎた
  • 輪郭面積でのフィルタリングでも、結果的にモルフォロジー変換を適用したのと同じ結果が得られる

ということかと思います。
ただ、縮小率0.2での輪郭に比べると、解像度不足からぎざぎざしてしまっている感じがするので、やっぱり縮小率0.2で進めていきたいと思います。

検出対象の画像データ中の大きさを意識しておく必要がる、ということかと思います。

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つ前の輪郭画像で、少なくともこの輪郭の中に点数の文字がある、ということまでは絞れています。
このさらに内側の輪郭を、各点数の文字テンプレートと比較していく、というのでどうだろう?

ここまで

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

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

OpenCVやってみる - 26. 台紙外枠検出その2

前回の続きになります。

おさらい

前回までのおさらいです。
以下のような手順で台紙外形取得を試みました。

  • Cannyエッジ検出実施
  • 輪郭検出(最外周のみ)実施
  • 面積最大の輪郭を選ぶ
  • 輪郭を単純な多角形で近似、四角形になっていればOK

問題だったのは、

  • 背景が複雑なパターンになっていて、最外周輪郭が乱れる
  • 四角形に近似できなかった
  • なぜかよく分からない検出不良

の3つです。

まずは使用データと検出コードを示します。

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
def get_and_disp_card_corners(img):
    img_canny = cv2.Canny(img, 100, 200)
    img_contours, img_hierarchy = cv2.findContours(img_canny, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    img_contours.sort(reverse = True, key = lambda x:cv2.contourArea(x))
    eps = 0.05 * cv2.arcLength(img_contours[0], True)
    approx_contour = cv2.approxPolyDP(img_contours[0], eps, True)
    print('Number of corners : ', approx_contour.shape[0])
    print(approx_contour)
    img_with_corners = cv2.drawContours(img.copy(), [img_contours[0]], -1, (255,0,0), 3)    
    img_with_corners = cv2.drawContours(img_with_corners, [approx_contour], -1, (0,255,0), 3)    
    for pt in approx_contour:
        cv2.circle(img_with_corners, pt[0], 5, (0,0,255), -1)
        
    plt.imshow(cv2.cvtColor(img_with_corners, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
    if(approx_contour.shape[0] != 4):
        print('Not approximated as quadrangle')
        
    return approx_contour

問題だった検出結果を示します。

approx_contour1 = get_and_disp_card_corners(img1)
Number of corners :  4
[[[ 28   1]]

 [[ 31 402]]

 [[270 377]]

 [[287  34]]]



f:id:nokixa:20211126015528p:plain

approx_contour2 = get_and_disp_card_corners(img2)
Number of corners :  4
[[[ 23   0]]

 [[ 34 361]]

 [[281 375]]

 [[289  17]]]



f:id:nokixa:20211126020019p:plain

approx_contour3 = get_and_disp_card_corners(img3)
Number of corners :  5
[[[184   0]]

 [[ 38  47]]

 [[ 42 387]]

 [[278 390]]

 [[274  49]]]
Not approximated as quadrangle



f:id:nokixa:20211126020102p:plain

approx_contour7 = get_and_disp_card_corners(img7)
Number of corners :  4
[[[218  86]]

 [[164  91]]

 [[193  90]]

 [[174 140]]]



f:id:nokixa:20211127030031p:plain

エッジ取得の確認

まずはエッジがきちんと取れているか確認してみます。

img1_canny = cv2.Canny(img1, 100, 200)
plt.imshow(img1_canny, cmap='gray'), plt.xticks([]), plt.yticks([])
img1_contours, img1_hierarchy = cv2.findContours(img1_canny, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
img1_contours.sort(reverse = True, key = lambda x:cv2.contourArea(x))
img1_with_contour = cv2.drawContours(img1.copy(), [img1_contours[0]], 0, (0,255,0), 3)
plt.figure()
plt.imshow(cv2.cvtColor(img1_with_contour, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])

f:id:nokixa:20211127034740p:plain

f:id:nokixa:20211127034806p:plain

エッジ画像を見た感じ、カーペットの模様もエッジ認識されているので、最初にフィルタをかけておいたほうがよさそうかな。
いくつかのフィルタを試してみます。
処理時間も気になるので、測定してみます。

https://docs.opencv.org/4.5.2/dc/d71/tutorial_py_optimization.html

https://note.nkmk.me/python-timeit-measure/

%timeit img1_blur = cv2.blur(img1, (5,5))
453 µs ± 5.02 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
%timeit img1_blur2 = cv2.blur(img1, (3,3))
338 µs ± 2.1 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
%timeit img1_gaussian = cv2.GaussianBlur(img1, (5,5), 0)
121 µs ± 34.7 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
%timeit img1_gaussian2 = cv2.GaussianBlur(img1, (3,3), 0)
79.6 µs ± 24.1 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
%timeit img1_bilateral = cv2.bilateralFilter(img1, 9, 75, 75)
4.59 ms ± 94.9 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

ガウシアンフィルタが一番処理時間が短いのか?予想外。
エッジ検出をしてみます。

def plot_edge_and_contour(img, canny_th1, canny_th2):
    img_canny = cv2.Canny(img, canny_th1, canny_th2)
    plt.subplot(121), plt.imshow(img_canny, cmap='gray'), plt.xticks([]), plt.yticks([])
    img_contours, img_hierarchy = cv2.findContours(img_canny, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    img_contours.sort(reverse = True, key = lambda x:cv2.contourArea(x))
    img_with_contour = cv2.drawContours(img.copy(), img_contours[1:], -1, (255,255,0), 2)
    cv2.drawContours(img_with_contour, [img_contours[0]], -1, (0,255,0), 3);
    plt.subplot(122), plt.imshow(cv2.cvtColor(img_with_contour, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
    return img_contours[0]
    
img1_blur = cv2.blur(img1, (5,5))
plot_edge_and_contour(img1_blur, 100, 200);

f:id:nokixa:20211127034827p:plain

img1_blur = cv2.blur(img1, (3,3))
plot_edge_and_contour(img1_blur, 100, 200);

f:id:nokixa:20211127034853p:plain

img1_gaussian = cv2.GaussianBlur(img1, (5,5), 0)
plot_edge_and_contour(img1_gaussian, 100, 200);

f:id:nokixa:20211127034913p:plain

img1_gaussian = cv2.GaussianBlur(img1, (3,3), 0)
plot_edge_and_contour(img1_gaussian, 100, 200);

f:id:nokixa:20211127034936p:plain

img1_bilateral = cv2.bilateralFilter(img1, 9, 75, 75)
plot_edge_and_contour(img1_bilateral, 100, 200);

f:id:nokixa:20211127034952p:plain

ガウシアンフィルタ、バイラテラルフィルタがよさそうですが、処理時間を考えてガウシアンフィルタにしておこうかな。
cv2.blurでうまくいっていないのはぼけ方が強すぎるからかと。 カーネルサイズもどうしようかという感じですが、3x3にしておこうかと思います。
ある程度のノイズはハフ変換での直線検出、というところで対応されるかと。

ヒストグラム平坦化

Cannyエッジ検出の閾値をどう設定すればいいか悩みます。
画像の全体的な明るさに依存するかと思うので、ヒストグラム平坦化でどうだろう?
まずはヒストグラムを見てみます。HSVで見るかな。

img1_hsv = cv2.cvtColor(img1, cv2.COLOR_BGR2HSV)
plt.hist(img1_hsv[:,:,0].ravel(),180,[0,180]);
plt.figure()
plt.hist(img1_hsv[:,:,1].ravel(),256,[0,256]);
plt.figure()
plt.hist(img1_hsv[:,:,2].ravel(),256,[0,256]);

f:id:nokixa:20211127035054p:plain

f:id:nokixa:20211127035052p:plain

f:id:nokixa:20211127035056p:plain

Vの値がきちんと広がっているので、特に処理は不要かな。
一応暗いシーンで撮影された場合を想定してVの値のヒストグラム平坦化をやっておきます。

img1_hsv[:,:,2] = cv2.equalizeHist(img1_hsv[:,:,2])
img1_equalized = cv2.cvtColor(img1_hsv, cv2.COLOR_HSV2BGR)
plt.subplot(121), plt.title('Original'), plt.imshow(cv2.cvtColor(img1, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.subplot(122), plt.title('Equalized'), plt.imshow(cv2.cvtColor(img1_equalized, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211127035128p:plain

CLAHEでは?

CLAHEは、おおざっぱに言うと画像の部分部分でヒストグラム平坦化をやるような感じです。
これを使ってみます。 上の画像では手の影が映っていて、明るさのばらつきがありますが、これが軽減されるかも。

img1_clahe = cv2.cvtColor(img1, cv2.COLOR_BGR2HSV)
clahe = cv2.createCLAHE(clipLimit=4.0, tileGridSize=(8,8))
img1_clahe[:,:,2] = clahe.apply(img1_clahe[:,:,2])
img1_clahe = cv2.cvtColor(img1_clahe, cv2.COLOR_HSV2BGR)
plt.subplot(121), plt.title('Original'), plt.imshow(cv2.cvtColor(img1, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.subplot(122), plt.title('CLAHE'), plt.imshow(cv2.cvtColor(img1_clahe, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])
plt.show()
plt.figure()
plt.hist(img1_clahe[:,:,2].ravel(), 256, [0,256]);

f:id:nokixa:20211127035244p:plain

f:id:nokixa:20211127035304p:plain

分かりやすい変化としては、カーペット部分の明るさの変化があります。
これはどちらかというと悪いほうに転がってしまったか。

改めて輪郭検出を実施します。

img1_equalized_gaussian = cv2.GaussianBlur(img1_equalized, (5,5), 0)
img1_contour = plot_edge_and_contour(img1_equalized_gaussian, 100, 200)

f:id:nokixa:20211127035448p:plain

img1_clahe_gaussian = cv2.GaussianBlur(img1_clahe, (5,5), 0)
img1_contour = plot_edge_and_contour(img1_clahe_gaussian, 100, 200)

f:id:nokixa:20211127035451p:plain

なかなかうまくいかない…

別案検討

一旦シールの領域と台紙の色が付いた部分も白で塗りつぶしてしまう案を考えましたが、どうだろう。
シール台紙上の色のついた部分が邪魔になっているのではと思ったので。
今まで見た春のパン祭り画像だと、シールの色は鮮やかなピンクになっていたので、色相と彩度で分離できそうな。

img1_hsv = cv2.cvtColor(img1, cv2.COLOR_BGR2HSV)
ret, img1_th_hue = cv2.threshold(img1_hsv[:,:,0], 150, 255, cv2.THRESH_BINARY)
ret, img1_th_sat = cv2.threshold(img1_hsv[:,:,1], 128, 255, cv2.THRESH_BINARY)
plt.subplot(121), plt.title('Thresholded(Hue)'), plt.imshow(img1_th_hue, cmap='gray'), plt.xticks([]), plt.yticks([])
plt.subplot(122), plt.title('Thresholded(Sat)'), plt.imshow(img1_th_sat, cmap='gray'), plt.xticks([]), plt.yticks([])
plt.show()

f:id:nokixa:20211127035559p:plain

import numpy as np

img1_th = cv2.bitwise_and(img1_th_hue, img1_th_sat)
img1_seal_masked = np.zeros_like(img1)
img1_seal_masked[:,:,0] = cv2.bitwise_or(img1[:,:,0], img1_th)
img1_seal_masked[:,:,1] = cv2.bitwise_or(img1[:,:,1], img1_th)
img1_seal_masked[:,:,2] = cv2.bitwise_or(img1[:,:,2], img1_th)
plt.imshow(cv2.cvtColor(img1_seal_masked, cv2.COLOR_BGR2RGB)), plt.xticks([]), plt.yticks([])

f:id:nokixa:20211127035602p:plain

img1_gaussian = cv2.GaussianBlur(img1_seal_masked, (3,3), 0)
img1_contour = plot_edge_and_contour(img1_gaussian, 100, 200)

f:id:nokixa:20211127035604p:plain

ヒストグラム平坦化も適用しておきます。

img1_seal_masked = np.zeros_like(img1)
img1_seal_masked[:,:,0] = cv2.bitwise_or(img1_equalized[:,:,0], img1_th)
img1_seal_masked[:,:,1] = cv2.bitwise_or(img1_equalized[:,:,1], img1_th)
img1_seal_masked[:,:,2] = cv2.bitwise_or(img1_equalized[:,:,2], img1_th)
img1_gaussian = cv2.GaussianBlur(img1_seal_masked, (3,3), 0)
img1_contour = plot_edge_and_contour(img1_gaussian, 100, 200)

f:id:nokixa:20211127035607p:plain

img1_seal_masked = np.zeros_like(img1)
img1_seal_masked[:,:,0] = cv2.bitwise_or(img1_clahe[:,:,0], img1_th)
img1_seal_masked[:,:,1] = cv2.bitwise_or(img1_clahe[:,:,1], img1_th)
img1_seal_masked[:,:,2] = cv2.bitwise_or(img1_clahe[:,:,2], img1_th)
img1_gaussian = cv2.GaussianBlur(img1_seal_masked, (3,3), 0)
img1_contour = plot_edge_and_contour(img1_gaussian, 100, 200)

f:id:nokixa:20211127035609p:plain

ダメな結果になってしまった…

一旦撤退

なんだかハマってきた感じがするので、一旦作戦を考え直したほうがよさそうです。

台紙外枠をロバストに検出をするのはかなり難しそう。

  • まず台紙自体ぐにゃぐにゃしているので四角形に近似しづらい
  • 台紙に部分的に色がついているので、これも形状認識を邪魔する
  • 背景の模様に惑わされる
  • 背景が台紙と同じような白色だったらアウト

外枠検出を諦めて、シール領域の検出から始めてしまったほうがよさそうに思います。
シール領域であれば、台紙とは明らかに色が違うはずなので。
極端に斜めから撮影していなければ、楕円→円の補正だけでいいのでは。

Cannyエッジ検出 → 輪郭検出 の流れもあまりよくなかったかと思います。

一応今回得られたこともいくつかあります。

  • 今使用しているPCでは、だいたい解像度300x400の画像で、カーネルサイズ5x5のガウシアンフィルタは120us程度、Blurフィルタは450us程度、バイラテラルフィルタは4.5ms程度時間がかかる
  • ヒストグラム平坦化の使い方、種類


次回は、シール領域検出からのスタートでやり直してみたいと思います。