2022年1月1日土曜日

OpenCVでサイゼリヤの「超難解 大人の間違い探し」を台無しにする



 あけましておめでとうございます。


先日、久々にサイゼリヤで外食をした際、レジにて間違い探しを配っているのに気づきました。自分がこれを手にした瞬間に頭のなかで間違いを見つけるプログラムができたのですが、数日後の深夜に実際にコードに落として、動くものができました。

今回は、どのようにしてこの出力を得たかについて、具体的なコード例とあわせて紹介します。


基本的な考え方

サイゼリヤの間違い探しは、左右反転した状態で絵の違いを見つけるものです。今回はAKAZE特徴量を用いてふたつの画像を重ね合わせた上で、差異部分に色をつけてマークしていくことにします。


入力画像を用意する

今回サイゼリヤで入手した間違い探しはA4サイズのコピー用紙に印刷されたものです(配達のときに同梱したりしているようですね)。後でAKAZE特徴量のアルゴリズムで取り扱うため、きちんと平面がでた状態でスキャンします。



今回は手元にあったドキュメントスキャナ(Canon ImageFormla DR-C125)を使いましたが、きちんと平らにできれば、携帯電話のカメラを使ったりしてもいいですし、コンビニのコピー機で適当な形式で保存してきても構いません。

また、ソースの特性を考慮し、可能な限り、白・黒に二値化します。必須ではないのですが、これをしておくことで後の処理がしやすくなるでしょう。二値化はPhotoshop, GIMPなどの画像編集ツールやドキュメントスキャナのスキャン時設定にて行えるでしょう。

入力画像を2ファイルに分割する



img1.jpg, img2.jpg として対象の画像を保存します。今回は macOS 標準のスクリーンショット機能を用いて、とても雑にファイルを生成しました。後でAKAZE特徴量を用いて重ね合わせるつもりですので、ここで傾きや大きさ、ピクセルのシフト量など、正確さを求める必要はありません。今回は JPEGを使用しましたが、 OpenCV が対応する形式であれば PNG などでも構いません。


作業環境

今回は Juypter Notebook 上で、 Python 3.8 の環境に OpenCV 4.x をインストールし作業していきます。 OpenCV は下記操作でインストールするとよいでしょう。 opencv-contrib-python パッケージをインストールすることで、のちに使う AKAZE 特徴量などのアルゴリズムもすぐ使える状態になっています。

!pip install opencv-contrib-python
画像の読み込み
cv2.imread() で画像を読み取って ndarray にします。元画像は原則モノクロですし、面倒を減らすため、読み込み時に1チャンネルのグレースケール画像としておきました。このほうが後での画像のマッチングや比較も簡単です。
import cv2
import numpy as np
from math import sqrt
img1 = cv2.imread("saizeriya_akaze/img1.jpg", cv2.IMREAD_GRAYSCALE)
img2 = cv2.imread("saizeriya_akaze/img2.jpg", cv2.IMREAD_GRAYSCALE)

img1 と img2 は左右反転していますので、 img2 を左右反転させておきます。

img2 = cv2.flip(img2, 1)
2枚の画像を重ね合わせる

AKAZE特徴量を使って画像をマッチングします。

akaze = cv2.AKAZE_create()
kpts1, desc1 = akaze.detectAndCompute(img1, None)
kpts2, desc2 = akaze.detectAndCompute(img2, None)
matcher = cv2.DescriptorMatcher_create(cv2.DescriptorMatcher_BRUTEFORCE_HAMMING)
nn_matches = matcher.knnMatch(desc1, desc2, 2)
matched1 = []
matched2 = []
nn_match_ratio = 0.8 # Nearest neighbor matching ratio
for m, n in nn_matches:
    if m.distance < nn_match_ratio * n.distance:
        matched1.append(kpts1[m.queryIdx])
        matched2.append(kpts2[m.trainIdx])
inliers1 = []
inliers2 = []
good_matches = []
inlier_threshold = 1000 # Distance threshold to identify inliers with homography check
for i, m in enumerate(matched1):
    col = np.ones((3,1), dtype=np.float64)
    col[0:2,0] = m.pt
    dist = sqrt(pow(col[0,0] - matched2[i].pt[0], 2) +\
                pow(col[1,0] - matched2[i].pt[1], 2))
    if dist < inlier_threshold:
        good_matches.append(cv2.DMatch(len(inliers1), len(inliers2), 0))
        inliers1.append(matched1[i])
        inliers2.append(matched2[i])

これで img1 と img2(左右反転) 画像の特徴を対応が得られます。これを画像として出力してみます。

res = np.empty((max(img1.shape[0], img2.shape[0]), img1.shape[1]+img2.shape[1], 3), dtype=np.uint8)
cv2.drawMatches(img1, inliers1, img2, inliers2, good_matches, res)
cv2.imwrite("saizeriya_akaze/result.png", res)


コピー&ペーストでソースコードをいじった時に少しおかしくなったように見えるけど、実用上問題なさそうなのでヨシ。

もちろん、間違い探しという特性上、画像が意図的に異なるので、マッチしない特徴もあるわけですが、十分な数の特徴量がマッチすれば、やりたい事に対して問題はありません。

この特徴量をもとに2枚の映像を重ね合わせます。 img1 の位置を基準とし、 img2 が img1 に重なるようにワープフィルタをかけた img3 を得ます。この処理は IkaLog というプログラムから一部を持ってきました。そのため画像名にキャリブレーションイメージ、キャプチャイメージという名前が付いていますが、そのまま利用しています。
Acalibration_image_gray = img1
capture_image_gray = img2
matcher = cv2.BFMatcher(cv2.NORM_HAMMING)


capture_image_keypoints, capture_image_descriptors = \
    akaze.detectAndCompute(capture_image_gray, None)
calibration_image_keypoints, calibration_image_descriptors = \
    akaze.detectAndCompute(calibration_image_gray, None)

print('caputure_image - %d features' % (len(capture_image_keypoints)))
print('matching...')

def filter_matches(kp1, kp2, matches, ratio=0.75):
    mkp1, mkp2 = [], []
    for m in matches:
        if len(m) == 2 and m[0].distance < m[1].distance * ratio:
            m = m[0]
            mkp1.append(kp1[m.queryIdx])
            mkp2.append(kp2[m.trainIdx])
    p1 = np.float32([kp.pt for kp in mkp1])
    p2 = np.float32([kp.pt for kp in mkp2])
    kp_pairs = zip(mkp1, mkp2)
    return p1, p2, kp_pairs
raw_matches = matcher.knnMatch(
    calibration_image_descriptors,
    trainDescriptors=capture_image_descriptors,
    k=2)

p1, p2, kp_pairs = filter_matches(
    calibration_image_keypoints,
    capture_image_keypoints,
    raw_matches,)

if len(p1) >= 4:
    H, status = cv2.findHomography(p1, p2, cv2.RANSAC, 5.0)
    print('%d / %d  inliers/matched' % (np.sum(status), len(status)))
else:
    H, status = None, None
    print('%d matches found, not enough for homography estimation' % len(p1))
    raise Exception()

assert H is not None

if len(status) < 1000:
    raise WarpCalibrationNotFound()

calibration_image_height, calibration_image_width = img1.shape

corners = np.float32(
    [[0, 0],
     [calibration_image_width, 0],
     [calibration_image_width, calibration_image_height],
     [0, calibration_image_height]])

pts1 = np.float32(cv2.perspectiveTransform(
    corners.reshape(1, -1, 2), H).reshape(-1, 2) + (0, 0))
pts2 = np.float32([
    [0, 0],
    [calibration_image_width, 0],
    [calibration_image_width, calibration_image_height],
    [0, calibration_image_height]])

print('pts1: %s' % [pts1])
print('pts2: %s' % [pts2])

M = cv2.getPerspectiveTransform(pts1, pts2)
 
img3 = cv2.warpPerspective(img2, M, (calibration_image_width, calibration_image_height))
これにより img1 とできるだけマッチするようワープフィルタを適用した img3 が得られました。


img1 と img2 の縦横ピクセル数は一致していないのですが、 img1 にあわせてワープフィルタをかけた img3 は img1 と同じピクセル数になっているので、このことを確認しておきます。

こんな面倒なことをしなくても、他のツールを使ってあらかじめ重ね合わせておくことも可能です。ただ、この重ね合わせ処理含めて実装したので、サイゼリヤの間違い探しの新しいバージョンが出ても、入力画像を差し替える以上の労力をかける必要はなくなりました。


画像の差分をとる

img1 と img3 の差の絶対値をとります。これにより1プレーン8ビット(256色調)色調の画像に対して、差がなければ0、差があれば255の値が得られます。なお img1 および img3 は符号なし8ビット整数の1プレーン モノクロ画像ですが、差を取るにあたり -255 ~ 255 までの範囲をとりうるため、 符号付き32ビット整数として扱っています。絶対値をとった時点では 0~255 に収まっているため、 img_abs は 0~255 の符号付き32ビット整数です。

img_abs = abs(img1.astype(np.int32) - img3.astype(np.int32))

img_abs の内容をモノクロ画像として出力した例を参考までに掲載しておきます。 img1, img2 で差があった部分が白く浮き上がっていることがわかるでしょう。



差分強調画像を作成し出力する

最終結果を出力します。ここではimg1を基として答えの画像を作成します。この画像は HSV フォーマットとして扱うことにします。HSVとは Hue(色相), Saturation(彩度)、明るさ(Brightness) の3要素で色を表現します。モノクロ画像をHSV色空間に変換していますので、S=0、V=0~255, Hについては未定義(結果としてゼロが入っているが)という状態になります。

img_bgr = cv2.cvtColor(img1, cv2.COLOR_GRAY2BGR)
img_hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)

このうち彩度のプレーンを、先ほど得た img1 と img3 の絶対値 img_abs で置き換えます。差のある部分はほぼ 0 、差がある部分だけ彩度があがるため、結果として img1 の画像に対して、 img1 と img3 の差がある部分には何らかの色が付きます。どのような色がつくかは色相チャンネルによりますが、今回 cv2.cvtColor() はこのチャンネルを0で初期化していますので、これに対応して赤色になります。

img_hsv[:, :, 1] = img_abs

生成した画像を cv2.imwrite() で画像ファイルに落とします。 cv2.imwrite() は BGR フォーマットのカラー画像をとりますのでHSV色空間からBGR色空間へ変換も行います。

cv2.imwrite("saizeriya_akaze/result.png", cv2.cvtColor(img_hsv, cv2.COLOR_HSV2BGR))

出力結果


今年もよろしく御願いします。