サイゼリヤの間違い探しをOpenCVで解いてみた

初投稿になります
C#信者のQAエンジニアです
以後お見知りおきを…

さてさて学生の味方ともいえるサイゼリヤ
自分も学生の頃は部活の打ち上げやテスト勉強にてドリンクバーとドリアで何時間も粘ったものです

そんな時一度は見たことのある間違い探し

今回はこれを自動で解くアプリをC#で作成してみようと思います
# 実現したいこと
C#とOpenCVSharpを使用し
サイゼリアの間違い探しを右の画像と左の画像として撮影
その差分を画像としてわかりやすく出力する

プログラムの流れとしては
・画像の変換(撮影することを考慮して左右の画像のサイズを合わせる)
・画像の差分抽出(R,G,Bごとに差分を出す)
・差分と元画像を合成して表示

# 今回使う画像
テスト用にサイゼリヤ公式サイトの画像を変形させたものを用意
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/9b88ce5a-c995-d9ae-effe-4a816ef688af.png" width=40%><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/c845d507-7c31-7e10-112b-a9757e393ad7.png" width=40%>

# 実行結果
アプリ画面
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/b06d118d-6681-74ca-941e-5a7484524716.png" width=100%>
最終生成画像(result.jpg)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/815f2d9f-8894-5a1e-ab82-e9dc4526ccc5.jpeg">
取り除ききれなかったごま塩ノイズが少しありますが多めに見てください…
ちなみに変形していない画像を使うと
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/48363ebe-3b23-d9e7-62a4-ff04977ea09c.jpeg">
きれいに取れますね
（変換によるビットの抜け？が原因でごま塩になっているものと思われます）

# 実行環境
C#
.Net Framework 4.7.2
OpenCvSharp3-AnyCPU Ver4.0.0

Windows 10 Home 64Bit
Intel Core i7 9700
RAM 16GB
GPU NVIDIA GeForce GTX 1660

# アルゴリズムの詳細
以下に大まかな流れを紹介していきます
GitHubにソース類をおいてあるので参考にしてみてください
https://github.com/uechan16/SaizeriyaMachigaisagashi

### 画像の変換
画像は机にあるサイゼリアのメニューを撮影することを考えると上からとっても多少のゆがみが発生すると思います
そんなゆがみを射影変換である程度同じ角度、大きさに整えていきます

変換の手法は特徴点マッチングです
画像の特徴を割り出し、その特徴同士を比較する方法です

```C#:Form.cs
            AKAZE akaze = AKAZE.Create();
            KeyPoint[] keyPointsLeft;
            KeyPoint[] keyPointsRight;

            Mat descriptorLeft = new Mat();  
            Mat descriptorRight = new Mat();

            DescriptorMatcher matcher; //マッチング方法
            DMatch[] matches; //特徴量ベクトル同士のマッチング結果を格納する配列

            //画像をグレースケールとして読み込む 
            Mat Lsrc = new Mat(sLeftPictureFile, ImreadModes.Color);

            //画像をグレースケールとして読み込む
            Mat Rsrc = new Mat(sRightPictureFile, ImreadModes.Color);

            //特徴量の検出と特徴量ベクトルの計算
            akaze.DetectAndCompute(Lsrc, null, out keyPointsLeft, descriptorLeft);
            akaze.DetectAndCompute(Rsrc, null, out keyPointsRight, descriptorRight);

            //画像1の特徴点をoutput1に出力
            Cv2.DrawKeypoints(Lsrc, keyPointsLeft, tokuLeft);
            Image imageLeftToku = BitmapConverter.ToBitmap(tokuLeft);
            pictureBox3.SizeMode = PictureBoxSizeMode.Zoom;
            pictureBox3.Image = imageLeftToku;
            
            //画像2の特徴点をoutput1に出力
            Cv2.DrawKeypoints(Rsrc, keyPointsRight, tokuRight);
            Image imageRightToku = BitmapConverter.ToBitmap(tokuRight);
            pictureBox4.SizeMode = PictureBoxSizeMode.Zoom;
            pictureBox4.Image = imageRightToku;

```
この辺は特徴量マッチングで調べるとよく出てくるコード丸パクリです
こうして出てくる画像がこちら
LeftToku.jpg
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/4a156bfc-2dd4-774a-bab7-8e640299d17e.jpeg" width=60%>
RightToku.jpg
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/0a2943ed-b44e-319b-b8c7-d70ddfd4aaea.jpeg" width=60%>
丸がついているところが画像の特徴を表しています

そしてこの特徴同士をマッチング

``` C#:Form.cs
           //総当たりでマッチング
            matcher = DescriptorMatcher.Create("BruteForce");
            matches = matcher.Match(descriptorLeft, descriptorRight);
            Cv2.DrawMatches(Lsrc, keyPointsLeft, Rsrc, keyPointsRight, matches, output);
```
output.jpg
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/dc4bd4a4-0cce-8905-9ae6-30a3120b632b.jpeg" width=60%>
線で結ばれているところがマッチした特徴です

これらの情報をもとに変形

```C#:form1.cs
            int size = matches.Count();
            var getPtsSrc = new Vec2f[size];
            var getPtsTarget = new Vec2f[size];

            int count = 0;
            foreach (var item in matches)
            {
                var ptSrc = keyPointsLeft[item.QueryIdx].Pt;
                var ptTarget = keyPointsRight[item.TrainIdx].Pt;
                getPtsSrc[count][0] = ptSrc.X;
                getPtsSrc[count][1] = ptSrc.Y;
                getPtsTarget[count][0] = ptTarget.X;
                getPtsTarget[count][1] = ptTarget.Y;
                count++;
            }

            // SrcをTargetにあわせこむ変換行列homを取得する。ロバスト推定法はRANZAC。
            var hom = Cv2.FindHomography(
                InputArray.Create(getPtsSrc),
                InputArray.Create(getPtsTarget),
                HomographyMethods.Ransac);

            // 行列homを用いてSrcに射影変換を適用する。
            Mat WarpedSrcMat = new Mat();
            Cv2.WarpPerspective(
                Lsrc, WarpedSrcMat, hom,
                new OpenCvSharp.Size(Rsrc.Width, Rsrc.Height));
```
WarpedSrcMat.jpg
![Warap.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/b63a3da0-effb-31b4-bcb6-0c3f8b8873a4.jpeg)
綺麗に変形できました
この時点で射影変換した画像はだいぶ画質が劣化しているのがわかります
これが前述のごま塩ノイズの原因です


### 画像の差分抽出
今回はMatのデータをRGBごとに抽出してそれぞれのチャンネルごとに差分を出し、
どこか一つのチャンネルでも差分があった部分は差分ありとしてマークするようにしました

```C#:form1.cs
            // 左右両方の画像を各チャンネルごとに分割
            Mat LmatFloat = new Mat();
            WarpedSrcMat.ConvertTo(LmatFloat, MatType.CV_16SC3);
            Mat[] LmatPlanes = LmatFloat.Split();

            Mat RmatFloat = new Mat();
            Rsrc.ConvertTo(RmatFloat, MatType.CV_16SC3);
            Mat[] RmatPlanes = RmatFloat.Split();

            Mat diff0 = new Mat();
            Mat diff1 = new Mat();
            Mat diff2 = new Mat();
            
            // 分割したチャンネルごとに差分を出す
            Cv2.Absdiff(LmatPlanes[0], RmatPlanes[0], diff0);
            Cv2.Absdiff(LmatPlanes[1], RmatPlanes[1], diff1);
            Cv2.Absdiff(LmatPlanes[2], RmatPlanes[2], diff2);

            // ブラーでノイズ除去
            Cv2.MedianBlur(diff0, diff0, 5);
            Cv2.MedianBlur(diff1, diff1, 5);
            Cv2.MedianBlur(diff2, diff2, 5);
```
![diffs.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/687ddd1c-1287-1040-8a90-a8ac7a0b2178.png)
射影変換した画像の劣化によりだいぶ関係ない部分も白くなっていますが
これは別の工程で緩和していきます

各チャンネルを統合し、どこかのチャンネルで差分がある場所は
すべて差分ありとしてCv2.BitwiseOr()でマスク画像を生成します

```C#:Form1.cs
            Mat wiseMat = new Mat();
            Cv2.BitwiseOr(diff0, diff1, wiseMat);
            Cv2.BitwiseOr(wiseMat, diff2, wiseMat);
```
wiseMat.jpg
![wiseMat.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/f1460807-71ce-4564-4737-94bfff0e3847.jpeg)
だいぶ汚いですね

ここから汚いノイズを緩和していきます

```C#:Form1.cs
            //オープニング処理でノイズ緩和
            Mat openingMat = new Mat();
            Cv2.MorphologyEx(wiseMat, openingMat, MorphTypes.Open,new Mat());

            // スレッショルドで差分をきれいにくっきりと
            Mat dilationMat = new Mat();
            Cv2.Dilate(openingMat, dilationMat, new Mat());
            Cv2.Threshold(dilationMat, dilationMat, 100, 255, ThresholdTypes.Binary);
```

オープニング処理と二値化処理にて画像の汚い部分を消していきます
dilationMat.jpg
![dilationMat.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/2cc52472-f079-71d8-5c88-09e6ea6c3fbe.jpeg)

### 差分と元画像を合成して表示
ここからは画像の合成に入ります

``` C#:Form1.cs
            // dilationMatはグレースケールなので合成先のMatと同じ色空間に変換する
            Mat dilationScaleMat = new Mat();
            Mat dilationColorMat = new Mat();
            Cv2.ConvertScaleAbs(dilationMat, dilationScaleMat);
            Cv2.CvtColor(dilationScaleMat, dilationColorMat, ColorConversionCodes.GRAY2RGB);

            // 元画像 3:差分画像 7 で合成
            Cv2.AddWeighted(WarpedSrcMat, 0.3, dilationColorMat, 0.7, 0, LaddMat);
            Cv2.AddWeighted(Rsrc, 0.3, dilationColorMat, 0.7, 0, RaddMat);
```
Cv2.AddWeighted()を使えば合成する画像の割合を変えることができるので便利です
今回は差分画像を多めに加算することで差分をわかりやすくしました

###完成！
![Result.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/4cf1ec62-9a53-ad56-4b3d-26a825e156ad.jpeg)

#いざ、サイゼリヤへ
実際に行って画像を撮影してきました
左の画像
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/dfbd094b-33b3-8bef-d7c1-906d97a8a91f.jpeg" width=40%>
右の画像
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/ed9226d2-0ea1-34fa-57f7-b510dc2cc947.jpeg" width=46%>


### ジャン！！！！
![キャプチャ_honban.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/9c0bb50e-279f-96e9-1545-0e18fdcc5151.png)

ん～～～～～～～～～？？

![Result.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/43964c22-cb2a-1932-c001-70943eb94cc9.jpeg)
ダメっすね

### 今回の敗因
以下のことが失敗のようです
(先人たちと同じ過ちをしてしまいました)
・3次元のゆがみ(紙のそり)を変換できていない
![Result_2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/705249/dcec01ab-64b2-9a16-0a85-46082c75a52b.jpeg)
赤枠で囲った部分が顕著に歪んでいますね

この辺のゆがみが2次元ワープやら弾性マッチングが有効みたいですが計算量が果てしないみたいですね

ちなみに今回のアプリ。
最後の撮影データを使った実験では体感で10秒ほど処理に時間がかかっていました
一番時間がかかっていたのは射影変換ですね
特徴量を総当たりするので
画素数が多くなる
↓
特徴も多くなる
↓
総当たりする計算量も多くなる
なので当たり前ですが…


# 最後に
今回はサイゼリヤの間違い探しをOpenCVを使って解いてみました
仕事でOpenCVを使う機会があったのでその知識を応用＆コピペな部分が多いですが
皆さんの参考になると嬉しいです

# 参考にしたサイト
とても参考にしました
本当にありがとうございます

[サイゼリヤの間違い探しをロバストな画像処理で解く](https://qiita.com/ika_kk/items/32c4986825c86ad92f36)
[サイゼリヤの間違い探しを解く（ヒントになる）プログラムを作ってみた](https://qiita.com/y_a_m_a/items/0bb193b57b162925d1bb)