元画像処理エンジニアがサイゼリヤの間違い探しをOpenCVで解いてみた

# はじめに

前職で産業機械に使用する画像処理の設定などを作っていたこともあり、[サイゼリヤの間違い探し](https://qiita.com/h-yanai/items/1e33fd93e5cb1ac98398)のアルゴリズムに気になった部分があったので、手を出してみました。

# 今回のお題

さすがに同じものを使うのは芸がないので、今回は下記の画像で間違い探しを行います。
![body.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395414/5d1fb059-7ba4-a43d-17df-dbf8951caeb3.png)
Copyright Saizeriya Co,. Ltd All rights reserved.


ここからは間違い探しの答えの情報が乗っています。
もしネタバレが嫌であれば、[サイゼリヤのホームページ](https://www.saizeriya.co.jp/entertainment/)からチャレンジしてみてください。

# 変更点…を実装する前に

## 前提の確認

まず、サイゼリヤの間違い探しについて確認しましょう。
サイゼリヤの間違い探しで注視すべきは次の2点です。

1. 通常は印刷されているメニューを使う
1. 色の違いも答えに含まれる

1は、画像ファイルは解像度を印刷用からディスプレイ用に変換している可能性が高いことを意味しています。
そのため、同じ場所のピクセルが同じ色である保証はなく、何らかのノイズ対策が必要です。
2は、色の違いもわかりやすく表記、検出するような方法が必要ということを意味しています。RGBではなく、HSV(色相・輝度・彩度)を比較したほうが良い結果が生じそうです。

# 変更点

## 変更前の確認

まずは現状を確認しましょう。最初の状態の差分は次のようになりました。
![最初.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395414/498ac4b3-823d-cdc4-e2d7-3a80c1320415.png)

## ノイズ除去1

試しにぼかして、細かいノイズを除去できないか試してみます。

``` python
    # 画像の余白を削除
    img = img_src[:, padding:-padding]
    # 画像を左右で分割する
    height, width, channels = img.shape[:3]
    img1 = img[:, :width//2]
    img2 = img[:, width//2:]

    # ぼかしを加えて細かいノイズを除去する
    blue_size = (5, 5)
    img1 = cv2.blur(img1, blue_size)
    img2 = cv2.blur(img2, blue_size)
    
    cv2.imshow("img2",img2)
```

![ぼかし元画像.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395414/a3f2ad44-15fe-9ef2-4e49-00d138ab4ab9.png)
![ぼかし.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395414/67cff4f5-4faa-354c-2711-0ca28dfd83be.png)

あまり良くなっている雰囲気はありませんね。後で他の方法を試しましょう。

## HSV変換

### 差分の取得及びグレースケールへの変換

印刷物であることを意識し、HSVに変換してから差分をとってみます。
また、H,S,Vのどれかが大きく変わったところが答えのはずなので、間違いかどうかはHSVの差分の最大値で見るようにします。

```python
# もともとの対象が印刷物なので、HSVに変換してみる。
img1_hsv = cv2.cvtColor(img1, cv2.COLOR_RGB2HSV)
img2_hsv = cv2.cvtColor(img2, cv2.COLOR_RGB2HSV)

# 2つの画像の差分を算出
img_diff = cv2.absdiff(img2_hsv, img1_hsv)
diff_h = img_diff[:, :, 0]
diff_s = img_diff[:, :, 1]
diff_v = img_diff[:, :, 2]

# HSVの差分の一番大きく変わったところを取得する
diff_glay = np.maximum(diff_h, diff_s, diff_v)
```

![ぼかし+HSV変換.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395414/722746b7-5a1d-2c18-e724-82e02635a34d.png)

特に文字の付近にノイズが多く見られますが、文字は白黒のため、RGBのちょっとした変動で色相(H)が大きく変わってしまうからですね。

また、本来間違いのはずの羊の服の色がだいぶ暗いのが気になります。後の処理のことも考え、輝度の変化はオーバーに検出してくれるように変更しましょう。

### 低彩度の色相対策

単純に、彩度が低い部分は色相の変化を見ないようにします。
同時に、輝度と彩度の差分を0～255の範囲になるように規格化します。

```python

# 2つの画像の差分を算出
img_diff = cv2.absdiff(img2_hsv, img1_hsv)
diff_h = img_diff[:, :, 0]
diff_s = img_diff[:, :, 1] * 3
diff_v = img_diff[:, :, 2]

# 一定未満の彩度(V)の部分は色合い(H)の差分を考慮しないようにする
H_THRESHOLD = 70
_, diff_h_mask = cv2.threshold(diff_v, H_THRESHOLD, 255, cv2.THRESH_BINARY)
diff_h = np.minimum(diff_h, diff_h_mask)

# 輝度、彩度の差分を規格化する
diff_s = cv2.normalize(diff_s, _, 255, 0, cv2.NORM_MINMAX)
diff_v = cv2.normalize(diff_v, _, 255, 0, cv2.NORM_MINMAX)

# HSVの差分の一番大きく変わったところを取得する
diff_glay = np.maximum(diff_h, diff_s, diff_v)
```

![H虫.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395414/e8d74e33-a51a-85f1-abe3-1ed739d45632.png)

だいぶわかりやすくなってきました。

## 二値化及びノイズの除去

次はどこが変更されているかをもうすこしわかりやすくしてみます。
二値化し、差があるところを白で表示するようにします。

また、そのままではノイズだらけだったので、ぼかしを取り消してオープニングを使ってノイズの除去を行います。

```python

# 変わっている場所の候補を二値化で取得
DIFF_THRESHOLD = 60
_, diff_bin = cv2.threshold(diff_glay, DIFF_THRESHOLD, 255, cv2.THRESH_BINARY)

# ノイズの除去
diff_bin = cv2.morphologyEx(diff_bin, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)))
```

二値化のみ
![2値化のみ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395414/829d5076-bc90-bc59-673e-46b2a035c163.png)

オープニング込み
![オープニング.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395414/7875845f-877e-95b6-95fa-2be1eef87c7f.png)

こうして並べてみるとオープニングによるノイズ除去の威力は一目瞭然ですね。

## 表示形式の改善

リンク元の記事では、色の濃さを目視で探せていませんでした。
もう少し差が無い場所とある場所の変化がわかりやすいように変更してみましょう。

とは言っても上記で既に間違いのある場所を白黒で表記しています。これをいい感じに組み込めば十分でしょう。

```python
# 画像合成
diff_bin_rgb = cv2.cvtColor(diff_bin, cv2.COLOR_GRAY2RGB)
add = np.maximum(np.minimum(img2, diff_bin_rgb), (img2 // 3))
cv2.imshow("add", add)
```

最終的な出力は次のようになりました。
暖炉がわかりにくいですが、二値化のときのデータを見れば十分わかると思います。
もしくはRGB変換する前の二値化データを拡張(ダイレーション)して使えば見やすいと思います。

![最終結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/395414/72f66ea2-84b1-7f57-8290-83de4497e016.png)

# ソースコード

他にも元記事のソースにはいくつか気になった部分があったのですが、今回は本質ではないので省略します。
自分の画像処理の知識を入れたら、最終的にこのようなソースになりました。

```python
import cv2
import numpy as np


# --------------------------------------------------- #
# 画像合成                                             #
# --------------------------------------------------- #

def FitImageSize_small(img1, img2):
    # height
    if img1.shape[0] > img2.shape[0]:
        height = img2.shape[0]
        width = img1.shape[1]
        img1 = cv2.resize(img1, (width, height))
    else:
        height = img1.shape[0]
        width = img2.shape[1]
        img2 = cv2.resize(img2, (width, height))

    # width
    if img1.shape[1] > img2.shape[1]:
        height = img1.shape[0]
        width = img2.shape[1]
        img1 = cv2.resize(img1, (width, height))
    else:
        height = img2.shape[0]
        width = img1.shape[1]
        img2 = cv2.resize(img2, (width, height))
    return img1, img2


img = cv2.imread('ファイル名')

if img is None:
    print('ファイルを読み込めません')
    import sys

    sys.exit()

cv2.imshow("img", img)

# 余白を取り除いたときに2つの画像が最も一致するような適切な余白（padding）の幅を見つける
img_src = img
padding_result = []
for padding in range(0, 50):
    # 画像の余白を削除
    # (余白無しの可能性を考慮)
    if padding:
        img = img_src[:, padding:-padding]

    # 画像を左右で分割する
    height, width, channels = img.shape[:3]
    img1 = img[:, :width // 2]
    img2 = img[:, width // 2:]

    # 画像サイズを合わせる(小さい方に)
    img1, img2 = FitImageSize_small(img1, img2)

    # 2つの画像の差分を算出
    img_diff = cv2.absdiff(img2, img1)
    img_diff_sum = np.sum(img_diff)

    padding_result.append((img_diff_sum, padding))

# 差分が最も少ないものを選ぶ
_, padding = min(padding_result, key=lambda x: x[0])

# 画像の余白を削除
if padding:
    img = img_src[:, padding:-padding]

# 画像を左右で分割する
height, width, channels = img.shape[:3]
img1 = img[:, :width // 2]
img2 = img[:, width // 2:]
cv2.imshow("img2", img2)

# もともとの対象が印刷物なので、HSVに変換してみる。
img1_hsv = cv2.cvtColor(img1, cv2.COLOR_RGB2HSV)
img2_hsv = cv2.cvtColor(img2, cv2.COLOR_RGB2HSV)

# 2つの画像の差分を算出
img_diff = cv2.absdiff(img2_hsv, img1_hsv)
diff_h = img_diff[:, :, 0]
diff_s = img_diff[:, :, 1] * 3
diff_v = img_diff[:, :, 2]

# 一定未満の彩度(V)の部分は色合い(H)の差分を考慮しないようにする
H_THRESHOLD = 70
_, diff_h_mask = cv2.threshold(diff_v, H_THRESHOLD, 255, cv2.THRESH_BINARY)
diff_h = np.minimum(diff_h, diff_h_mask)

# 輝度、彩度の差分を規格化する
diff_s = cv2.normalize(diff_s, _, 255, 0, cv2.NORM_MINMAX)
diff_v = cv2.normalize(diff_v, _, 255, 0, cv2.NORM_MINMAX)

# HSVの差分の一番大きく変わったところを取得する
diff_glay = np.maximum(diff_h, diff_s, diff_v)

# 変わっている場所の候補を二値化とオープニングで取得
DIFF_THRESHOLD = 60
_, diff_bin = cv2.threshold(diff_glay, DIFF_THRESHOLD, 255, cv2.THRESH_BINARY)
diff_bin = cv2.morphologyEx(diff_bin, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)))

# 画像サイズを合わせる(小さい方に)
img2, diff_bin = FitImageSize_small(img2, diff_bin)
cv2.imshow("img_diff", diff_bin)

# 画像合成
diff_bin_rgb = cv2.cvtColor(diff_bin, cv2.COLOR_GRAY2RGB)
add = np.maximum(np.minimum(img2, diff_bin_rgb), (img2 // 3))
cv2.imshow("add", add)

cv2.waitKey(0)
cv2.destroyAllWindows()

```

# まとめ

+ 画像処理を行う場合は処理対象のことを考えよう
+ 最終的な結果の出力方法も考えておこう
+ [デバッグする人のことを考えて、全例外を握りつぶすようなソースを書かないようにしよう](https://qiita.com/mickie895/items/6a99751af6f8b8f5d80a)