アウトプットブログ

主にプログラミングで学んだことをアウトプットします。

ぷよぷよの画像からフィールドを検出したい

前回の記事の続きです。


今回は、ぷよぷよのゲーム画面からフィールド範囲を自動で検出する機能を作成することを目標とします。
プログラミング言語は引き続きPythonOpenCVライブラリを使用します。

輪郭検出について

まず輪郭検出の方法について。


①グレースケール化
②二値化
③輪郭取得


が一般的な手順のようです。

グレースケール化
dst = cv2.cvtColor(src, code)
"""
src : 変換元の画像。
code : 色変換のコード。グレースケール化したい場合は cv2.COLOR_BGR2GRAY を指定。
"""
二値化
retval, dst = cv2.threshold(src, thresh, maxval, type)
"""
src : 変換元の画像(グレースケール)。
thresh : 閾値。
maxval : 最大値。閾値より大きい値をもつ画素に割り当てられる値。
type : 閾値処理の方法の指定。今回は cv2.THRESH_BINARY(閾値より大きい画素はmaxval、閾値以下の画素は0) を指定。
"""
輪郭を取得
contours, hierarchy = cv2.findContours(image, mode, method)
"""
image : 輪郭を取得したい画像。
mode : 輪郭を抽出するモード。今回は cv2.RETR_TREE を指定。
method : 輪郭を近似する方法。今回は cv2.CHAIN_APPROX_SIMPLE を指定。
"""

ポケモン赤緑主人公のドットを用いて試してみます。

# import
import cv2
import matplotlib.pyplot as plt
%matplotlib inline

arr = []

# 画像読み込み
img = cv2.imread("image/red_dot.png")
arr.append([img, "original"])

# グレースケール化
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
arr.append([img_gray, "gray"])

# 二値化
ret, img_thresh = cv2.threshold(img_gray, 100, 255, cv2.THRESH_BINARY)
arr.append([img_thresh, "threshold"])

# 輪郭を取得、描画
contours, hierarchy = cv2.findContours(img_thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
img_cnt = cv2.drawContours(img.copy(), contours, -1, (255, 255, 0), 2, cv2.LINE_AA)
arr.append([img_cnt, "contours"])

plt.figure(dpi=100, figsize=(12,6))
for i, data in enumerate(arr):
    plt.subplot(1, len(arr), i+1)
    plt.imshow(cv2.cvtColor(data[0], cv2.COLOR_BGR2RGB))
    plt.title(label = data[1])
    plt.axis('off')

plt.show()



左から
①元画像
②グレースケール化
③二値化
④元画像に輪郭(水色の線)を追加

ぷよぷよのゲーム画面で検証①

実際に上記の手順で、前回の記事で使用したぷよぷよのゲーム画面で検証してみます。

結果


ドット絵のように単純な画像ではないので、1548個もの輪郭が取得できてしまいました。
この輪郭の中から、フィールドの矩形部分だけを抽出できるようにする必要があります。

矩形の輪郭のみを抽出する

以下の記事が今回の目的に合っていそうなので参考にさせていだきました。

輪郭から四角形を検出 - Qiita


ぱっと見何しているのか分からなかったので、findSquares関数の中身を整理します。

arclen = cv2.arcLength(cnt, True)
approx = cv2.approxPolyDP(cnt, arclen*0.02, True)

arcLengthで輪郭の長さを取得。
approxPolyDPで、取得した長さをパラメータに輪郭を単純な形状に近似。

area = abs(cv2.contourArea(approx))
if approx.shape[0] == 4 and area > cond_area and cv2.isContourConvex(approx) :

近似した輪郭について、
・頂点が4つ
・一定以上の面積を持つ
・輪郭が凸包
に絞り込み。

for j in range(2, 5):
    cosine = abs(angle(approx[j%4], approx[j-2], approx[j-1]))
    maxCosine = max(maxCosine, cosine)

if maxCosine < 0.3 :

余弦の最大値が0.3未満
⇒すべての角度が90°±約17°以内 に絞り込み。

今回の目的用に作成

パラメータや抽出条件など、今回の目的に合ったものに変更して矩形の輪郭のみ抽出する関数を作成しました。

def extract_rect_contours(contours, img_size):
    """
    矩形の輪郭のみを抽出する。
    """
    rect_contours = []
    for i, cnt in enumerate(contours):
        arclen = cv2.arcLength(cnt, True)
        approx = cv2.approxPolyDP(cnt, arclen*0.1, True)
        area = abs(cv2.contourArea(approx))
        if approx.shape[0] == 4 and  img_size*0.1 < area < img_size*0.9 and cv2.isContourConvex(approx):
            maxCosine = 0
            for j in range(2, 5):
                cosine = abs(_angle(approx[j%4], approx[j-2], approx[j-1]))
                maxCosine = max(maxCosine, cosine)
            if maxCosine < 0.2 :
                rect_contours.append(approx)
    return rect_contours

ぷよぷよのゲーム画面で検証②

作成した関数を使用して輪郭を抽出します。



上手くフィールドの矩形を抽出できているようなので、画像のバリエーションを増やして検証。

①フィールドが飽和している状態


ぷよぷよが詰め詰めに入っているフィールドでも、正しく検知できました。
問題なさそうです。

②4人対戦の画面


1P、2Pが検知できていません。
また、3P、4Pの枠が歪んでいます。

③エフェクトが被っている


検知できていません。
二値化の画像見ると明らかですね・・・エフェクトがもろに影響しています。

④画面を直接撮影、少し歪んでいる


斜めになっていても、正しく検知できています。

正しく検知できない原因は?

近似前のフィールドを囲っている輪郭を抽出し、形を確認します。

②4人対戦の画面


輪郭が内部のぷよにまで入り込んで引かれていることが分かります。
2P対戦と比較して画面が小さく、端のぷよと壁の間に隙間がなく、壁が上手く検出できなかったものと思われます。

③エフェクトが被っている


これは二値化の画像を見た時点で分かっていましたが、エフェクトの影響ですね。
あと予告ぷよにも輪郭が影響受けています。近似で吸収できる範囲だとは思いますが。


2Pは正しく検知されているものの、最下段の黄色ぷよに輪郭が入り込んでますね。
配置によっては近似でも吸収しきれないことになりそうです。

改善案の検討

モルフォロジー変換

二値化した画像に、モルフォロジー変換の「オープニング処理」を行い改善を試みます。
オープニング処理とは、白い部分を収縮した後に膨張させることで、白いノイズを除去する効果があるとのことです。

モルフォロジー変換 — OpenCV-Python Tutorials 1 documentation


オープニング処理でぷよぷよの白い部分を除去できるか検証します。
「②4人対戦の画面」にオープニング処理を加えた場合の輪郭はこちら。


オープニング処理有無での輪郭比較してみます。


改善が見られますね。
特に2Pなんかは顕著です。



最終的な結果は1P・2P・3Pは検出(1P・2Pは歪みあり)、4Pは検出不可でした。


ただし、最初に検証した画像でオープニング処理を行うと・・・



赤丸部分、フィールド下のスコアの数字の黒が広がってしまって逆に検出できなくなってしまいました。
有効な方法のひとつではありそうですが、安易にオープニング処理だけしておけば解決できるような問題でもなさそうです。

適応的閾値処理

【OpenCV/Python】adaptiveThresholdの処理アルゴリズム | イメージングソリューション

画像に影がある場合やムラがある場合に効果を発揮してくれる「適応的閾値処理」というものがあります。
エフェクトが被っているところはムラといっても良さそうなので、試してみましょう。
オープニング処理・クロージング処理とも目的が似ているとのことなので、上記オープニング処理で得られた効果も期待できるかもしれません。

dst = cv2.adaptiveThreshold(src, maxValue, adaptiveMethod, thresholdType, blockSize, C)
"""
src : 入力画像。
maxValue : 最大値。閾値より大きい値をもつ画素に割り当てられる値。
adaptiveMethod : 適応的閾値処理で使用するアルゴリズム。
thresholdType : 閾値処理の方法の指定。今回は cv2.THRESH_BINARY(閾値より大きい画素はmaxval、閾値以下の画素は0) を指定。
blockSize : 閾値計算に使用する近傍領域のサイズ。3,5,7など。(3以上の奇数?)
C : 平均または加重平均から差し引かれる定数。通常は正だが、ゼロや負でもよい。
"""


上記の解説ページでなんとなく何をしているかは分かったのですが、やっぱり実際動かしてみて引数調整するのがよさそうなので、色々引数変えて試してみます。

adaptiveMethod

cv2.ADAPTIVE_THRESH_MEAN_C と cv2.ADAPTIVE_THRESH_GAUSSIAN_C を比較。
blockSize=51、C=20で固定。



通常の二値化では真っ白になっていたエフェクトが消えて正しく二値化できてきます。素晴らしい効果。
adaptiveMethodによる明確な違いは判断しかねますが、ADAPTIVE_THRESH_GAUSSIAN_Cの方が若干エフェクトの影響が無くなってそうに見えるので、こちらを採用して検証続けましょう。

blockSize

3、51、151、301で調べてみます。
C=20で固定。



blockSizeが大きいほどフィールドの空白部分が黒になるので良いように思えますが、エフェクト部分の白い部分も大きくなっていますね・・・
ただエフェクトは正直外れ値のようなものだと思うので、エフェクトに合わせすぎて通常の画像が検出できなくなっても本末転倒です。
他の画像でも検証し、最終的に画像サイズの半分程度のblockSizeで実装することとしました。

C

0、20、50、100で調べてみます。



これはC=20にしておきます。

改良後の実装

最終的に作成した関数は以下です。

def extract_field_contours3(img):
    """
    グレースケール → 二値化(適応的しきい値処理) → 矩形抽出
    """
    # blockSizeは画像の半分で設定
    shape = img.shape
    block_size = int(shape[0]/2) if shape[0] > shape[1] else int(shape[1]/2)
    
    if block_size % 2 == 0 : block_size += 1
    
    C = 20

    img_arr = []
    img_arr.append({"src": img, "label": "original"})
    
    # グレースケール化
    img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    img_arr.append({"src": img_gray, "label": "gray"})
    
    # 二値化(適応的閾値処理)
    img_thresh = cv2.adaptiveThreshold(img_gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, block_size, C)
    img_arr.append({"src": img_thresh, "label": "adaptive threshold"})
    
    # 矩形の輪郭抽出
    rect_cnt = find_rect_contours(img_thresh)
    img_cnt = draw_contours(img, rect_cnt)
    img_arr.append({"src": img_cnt, "label": "contours (num="+str(len(rect_cnt))+")"})
       
    return rect_cnt, img_arr
実行結果

終わりに

結果として4人対戦画面の2P・4P、エフェクトがかかっている画面は検出することはできませんでしたが、二値化の結果は確実に改善されているので今回はここで終わりとしておきます。
今後より良い方法があれば改善していきます。