[OpenCV] 写真の中からマルフク看板を探す実験

# はじめに

OpenCVを使った画像処理の実験。
物体検出の練習として、一部界隈で熱狂的なファンを持つ（？）**マルフク看板**を写真の画像から探してみます。
といっても物体検出というほど立派なことをしていないので、いくらでも改善の余地がありそうですが、まずは取っ掛かりとして。

## マルフク看板？

こんなの。
![koshinokata.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/eecaa335-c11f-5d77-0342-9526d29d7c49.png)
民家や小屋に貼られているのを見たことのある方もいらっしゃると思います。
最近見なくなったとお思いの方も多いかもしれません。

ここではあまり書いていませんでしたが、**ファンサイトやTwitterのBotも作ってたりする筋金入りのマルフク看板ファンなのであります。（宣伝）**

- [トップページ - マルフク看板コレクション (RC)](https://029.every-little.com/)
- [電話の金融マルフクbot (@029bot) / Twitter](https://twitter.com/029bot/)

# 検証環境

- Windows 10 Home (1903)
- Python 3.6.8
    - opencv-python==4.2.0.34 (pipからインストール)
    - imutils==0.5.3 (同上)

# 問題設定

今回は入力された画像に対して、マルフク看板の写っている領域を検出して四角で囲んでみます。看板自体は長方形ですが、撮影の角度によって多少歪んだ形になっているでしょう。
最初の画像はこんな感じになると良いですね。

![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/69ccbcb9-73ac-3786-4047-a4541fb7a839.png)

## 自分で作らなくてもいい？

既存の技術やモデルで間に合うならそれでよいので、最初にMask R-CNNの学習済みモデルを試してみたのですが、どうも微妙な結果でした。
[matterport/Mask_RCNN: Mask R-CNN for object detection and instance segmentation on Keras and TensorFlow](https://github.com/matterport/Mask_RCNN)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/f61ab661-49c4-7aa4-b563-a542017a9355.png)
[**stop sign**](https://ja.wikipedia.org/wiki/%E4%B8%80%E6%99%82%E5%81%9C%E6%AD%A2_%28%E9%81%93%E8%B7%AF%E6%A8%99%E8%AD%98%29)、赤と白ですし気持ちは分かりますが…。

他の画像だと何も検出されない場合もありました。
<del>「マルフク看板」なんてクラス、あるわけないもんなあ。</del>

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">【手動ポスト】最近Botの中の人は深層学習で遊んでいるのですが、マルフク看板のある風景を物体検出させたらどうなるのかやってみました。マルフク看板には残念ながら何の結果もつかず。マルフク看板自動検出への道は遠いか。使ったプログラムはこれです。 <a href="https://t.co/aFUoKs05UJ">https://t.co/aFUoKs05UJ</a> <a href="https://t.co/6BiyNyDs44">pic.twitter.com/6BiyNyDs44</a></p>&mdash; 電話の金融マルフクbot (@029bot) <a href="https://twitter.com/029bot/status/1243124131287019520?ref_src=twsrc%5Etfw">March 26, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

というわけで、やはり何か考える必要があるっぽいです…。

# OpenCVで物体検出（もどき）

四角や円を検出するチュートリアルがあるので、同様の方法でマルフク看板の領域を検出してみたいと思います。
なお「もどき」と書いているのは、いろいろな物体クラスを用意しているわけでもなければ、輪郭抽出以上のことをしているわけでもないからです。。。

以下のチュートリアルの内容をベースに作ってみます。
[OpenCV shape detection - PyImageSearch](https://www.pyimagesearch.com/2016/02/08/opencv-shape-detection/)

チュートリアルの内容の他、必要に応じてこちらのページを参考にしました。
[機械学習のためのOpenCV入門 - Qiita](https://qiita.com/icoxfog417/items/53e61496ad980c41a08e)

まずはモジュールのインポートと、引数解析・画像読み込み部分です。

```python
import argparse
import imutils
import cv2

# 引数解析
ap = argparse.ArgumentParser()
ap.add_argument("-i", "--image", required=True,
	help="path to the input image")
args = vars(ap.parse_args())
# 画像読み込み
image = cv2.imread(args["image"])
```

## 2値化

まずは画像を2値化して、背景部分が黒、物体部分（マルフク看板の領域）が白になるようにします。
以下のコードを実行すると、2値化された画像がウィンドウに表示され、何かキーを入力するとウィンドウが閉じます。

```python
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.bilateralFilter(gray, 25, 15, 15)
thresh = cv2.threshold(blurred, 160, 255, cv2.THRESH_BINARY)[1]
cv2.imshow("Thresh", thresh)
cv2.waitKey(0)
```

カラー画像をグレースケール化し、画素値がある閾値以上かどうかで白・黒に塗り分けます。

![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/2d30d9e8-d33d-74e8-12ec-c8aa423f6298.png)

この方法で問題なのは、**「赤で描かれている部分が黒（背景）になってしまう」**という点です。このままだと赤い部分は看板の領域の外になってしまいます。``cv2.threshold()`` のパラメータも関係ないわけではありませんが、赤色がそれなりに濃く描かれているので、赤い部分が白くなるようにパラメータを調整すると、大部分が白になってしまいます。

そこで、前処理として **赤色を消す** 方法を試してみたいと思います。
光の赤色に見える部分は、絵の具（インク）の三原色でいえば「マゼンタ」と「イエロー」を混ぜたものになります。[^1]
つまり、**マゼンタとイエローを消す**ことによって、赤い部分を白くすることができそうです。
光の三原色 (RGB) で言えば、**マゼンタを消すには緑 (G) を最大にすればよく、イエローを消すには青 (B) を最大にすればよいので、マゼンタとイエローを消すと結局RGBの赤 (R) の成分だけが効いてきます**。よって、Rの値だけを残すことによって目的を達成できます。

[^1]: [意外と知らない RGBとCMYの話 | Photographer Takashi Yokoyama](http://yokoyamatakashi.com/archives/1306)

ということで、グレースケール化する際に ``cv2.cvtColor()`` を使わず、RGBの「赤」の成分を取り出す方法を試します。

```python
gray = image[:, :, 2]
blurred = cv2.bilateralFilter(gray, 25, 15, 15)
thresh = cv2.threshold(blurred, 160, 255, cv2.THRESH_BINARY)[1]
cv2.imshow("Thresh", thresh)
cv2.waitKey(0)
```

結果はこんな感じ。良いですね。
![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/ac91b298-cd42-eb19-143c-0fb663d82335.png)
ちなみに、赤の成分を取り出した時点の画像 (``gray``) はこうなっています。文字が読める程度に色は残っていますが、背景と比べると薄くなっていることが分かります。
**こんな感じで色あせて赤色が抜けた看板は普通に見かける気がする…。**
![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/7fa1c22c-3691-1ebe-e211-faefe6ad995d.png)

## 輪郭抽出

今度は、先ほど作成した白黒の2値画像から、物体の輪郭を抽出します。
``cv2.findContours()`` を使えば輪郭抽出は簡単にできるので、その結果を画面に描画して確認してみましょう。

```python
output = image.copy()
# 輪郭抽出
cnts = cv2.findContours(thresh.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
cnts = imutils.grab_contours(cnts)
# 画像に輪郭を描画
for c in cnts:
    cv2.drawContours(output, [c], -1, (0, 255, 0), 2)
cv2.imshow("Image", output)
cv2.waitKey(0)
```

![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/edf0f004-833e-ba4f-ce82-28366edde671.png)

なんか随分ぐちゃぐちゃですね…。
先ほどの2値画像に境界線を素直に引いた結果ではあるのですが、これでは使い勝手が悪いですし、ノイズの影響もあって変なところに線が出ていたりするので、もう少し改良しましょう。

## 輪郭の多角形近似

輪郭抽出の結果は点の集合になっていますが、最終的には輪郭は四角形（綺麗な長方形とは限りませんが）を想定しているので、多角形で近似した方がノイズの影響を小さくできてよいでしょう。

```python
output = image.copy()
# 輪郭抽出
cnts = cv2.findContours(thresh.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
cnts = imutils.grab_contours(cnts)
# 画像に輪郭を描画
for c in cnts:
    # 輪郭を多角形で近似
    epsilon = 0.1*cv2.arcLength(c,True)
    c = cv2.approxPolyDP(c,epsilon,True)
    cv2.drawContours(output, [c], -1, (0, 255, 0), 2)
cv2.imshow("Image", output)
cv2.waitKey(0)
```

![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/48fa8f29-3b18-e810-ff82-dff7ab13a529.png)

看板の縁の部分の線がギザギザしていましたが、そのギザギザ感が取れて直線的になりましたね。
一方、背景の関係ないところに線が出る状況はそのままです。

## 小さい領域を削除

アドホックな方法ではありますが、ノイズから誤検出される領域は小さいことが多いので、面積が一定値未満の領域は表示しないようにします。

```python
output = image.copy()
# 輪郭抽出
cnts = cv2.findContours(thresh.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
cnts = imutils.grab_contours(cnts)
# 画像に輪郭を描画
for c in cnts:
    # 輪郭を多角形で近似
    epsilon = 0.1 * cv2.arcLength(c, True)
    c = cv2.approxPolyDP(c, epsilon, True)
    # 小さい領域を表示しない
    area = cv2.contourArea(c)
    if area < 400:
        continue
    cv2.drawContours(output, [c], -1, (0, 255, 0), 2)
cv2.imshow("Image", output)
cv2.waitKey(0)
```

いい感じですね。
![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/69ccbcb9-73ac-3786-4047-a4541fb7a839.png)

## 形状の情報でフィルタリング

先ほどの例ではうまくいくのですが、他の画像を試すと変な輪郭が残ることがあります。
![marufuku.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/b1d5002c-6a50-6324-fbfc-92de02bd95bf.png)
以下のように、**画像の縁と屋根で囲まれた三角形の部分を検出してしまっています。**
![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/f063f0a3-3164-1f8f-f8f9-2093eaca4fab.png)
そこで、**形状の情報を利用してフィルタリング**してみます。
マルフク看板の実物は「幅900mm × 高さ600mm」の長方形です。このとき、面積 $S$ と周囲の長さ $L$ の関係として

```math
\frac{\sqrt S}{L} = \frac{\sqrt{900 \times 600}}{2 \times (900 + 600)} = \frac{\sqrt 6}{10} \approx 0.245
```

が成り立ちます。この値は幅と高さを同じ倍率で大きくしても変わりませんので、形状を表す特徴として使えそうです。[^2]

[^2]: 実は「円形度」を $4\pi$ で割って正の平方根をとったものと等しくなります。→[円形度とは何？ Weblio辞書](https://www.weblio.jp/content/%E5%86%86%E5%BD%A2%E5%BA%A6)

```python
output = image.copy()
# 輪郭抽出
cnts = cv2.findContours(thresh.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
cnts = imutils.grab_contours(cnts)
# 画像に輪郭を描画
for c in cnts:
    # 輪郭を多角形で近似
    epsilon = 0.1 * cv2.arcLength(c, True)
    c = cv2.approxPolyDP(c, epsilon, True)
    # 小さい領域を表示しない
    area = cv2.contourArea(c)
    if area < 400:
        continue
    # 周囲の長さを計算
    perimeter = cv2.arcLength(c, True)
    # 輪郭が3:2の長方形に近い場合のみ表示
    if perimeter == 0:
        # ゼロ割り防止
        continue
    if 0.23 < area ** 0.5 / perimeter < 0.26: # approx sqrt(6)/10
        cv2.drawContours(output, [c], -1, (0, 255, 0), 2)
cv2.imshow("Image", output)
cv2.waitKey(0)
```

これで右上の三角形の輪郭は消え、良い感じに看板の部分だけ残ります。
![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/68e891fc-25ed-7a7b-d42d-04e5c52deb3b.png)
まだ形状しか見ていませんが、実際には枠の中の領域を取り出して物体検出（マルフク看板かどうか？）を実行することになるのでしょう。

## 2値化の閾値パラメータを与えないようにする

最初の方に出てきた以下のコードです。Rの値が160より大きいか小さいかで白と黒を塗り分けています。

```python
thresh = cv2.threshold(blurred, 160, 255, cv2.THRESH_BINARY)[1]
```

少し考えれば分かるように、画像の明るさなどの条件によって最適な閾値は変化します。
OpenCVには、閾値を自動で決める方法として「大津の方法（大津の2値化）」[^3]という有名な方法が実装されているので、試してみます。

[^3]: [大津展之: 判別および最小2乗規準に基づく自動しきい値選定法, 電子通信学会論文誌 D, Vol. 63, No. 4, pp.349-356, 1980](https://ci.nii.ac.jp/naid/40002557720/)

```python
# 大津の方法
thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1]
```

結果はこちら。
![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/4f5f256e-95cb-47e2-7900-8c89b326197f.png)
![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/ec0bf304-defc-72a8-4762-1462c28f7e45.png)
自動でパラメータを決めることなしに輪郭を抽出できました。
電気メーターの部分に枠が出てしまっていますが、後で分類すればよいことにして、とりあえず目を瞑ります。。。

## 課題

結構うまく動いているように見えますが、実はこんな画像をうまく処理できません。
![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/ddc1ad04-9e23-93bb-ff92-6238e0fbe36f.png)
小屋に貼られている白い板が実はマルフク看板です。皆様ご存知の通り（？）、放置されたマルフク看板はこんな感じで読めなくなってしまいます。しかし真っ白ならばむしろ検出には都合がよいはず。
ではなぜ白い領域に枠が出てこないかというと、2値化の結果
![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/759e5024-0529-b53c-2339-80d1b408fcba.png)
こうなってしまったからです。要するに背景のトタン板の色が明るいことが原因ですね。

この画像の場合、パラメータを適切に決めることができればうまくいきます。

```python
#thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1]
thresh = cv2.threshold(blurred, 190, 255, cv2.THRESH_BINARY)[1]
```

こんな感じに色分けできて
![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/592adcde-3921-eef6-dd1a-e27b7f0507ee.png)
こんな感じに枠がつきます。
![output2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204084/d21da457-f19a-cec8-9f61-82b756d77fc1.png)

推測ですが、「大津の方法」では輝度のヒストグラムに「背景」と「物体」という2つのピークがあることを仮定しているため、明るいトタン板の色が「物体」側に取り込まれてしまったと考えられます。
他の画像を使った場合でも、うまくいったりいかなかったり。改善は今後の課題ですね。

# コードまとめ

```python:marufuku.py
import argparse
import imutils
import cv2

# 引数解析
ap = argparse.ArgumentParser()
ap.add_argument("-i", "--image", required=True,
	help="path to the input image")
args = vars(ap.parse_args())
# 画像読み込み
image = cv2.imread(args["image"])

gray = image[:, :, 2]
blurred = cv2.bilateralFilter(gray, 25, 15, 15)
# 大津の方法
thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)[1]
cv2.imshow("Thresh", thresh)
cv2.waitKey(0)

output = image.copy()
# 輪郭抽出
cnts = cv2.findContours(thresh.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
cnts = imutils.grab_contours(cnts)
# 画像に輪郭を描画
for c in cnts:
    # 輪郭を多角形で近似
    epsilon = 0.1 * cv2.arcLength(c, True)
    c = cv2.approxPolyDP(c, epsilon, True)
    # 小さい領域を表示しない
    area = cv2.contourArea(c)
    if area < 400:
        continue
    # 周囲の長さを計算
    perimeter = cv2.arcLength(c, True)
    # 輪郭が3:2の長方形に近い場合のみ表示
    if perimeter == 0:
        # ゼロ割り防止
        continue
    if 0.23 < area ** 0.5 / perimeter < 0.26: # approx sqrt(6)/10
        cv2.drawContours(output, [c], -1, (0, 255, 0), 2)
cv2.imshow("Image", output)
cv2.waitKey(0)
```

以下のようにコマンドを実行すると動きます。

```
python marufuku.py -i image.png
```

# まとめ

物体検出の初歩の初歩ではありますが、マルフク看板を題材に輪郭抽出だけ試してみました。
実際に使えるものにするには、背景色への頑健性も課題ですが、抽出した領域に対するクラス分類器の学習（データを集めて）も必要になってくると思います。
パラメータを全自動で決めることにこだわらず、目視確認しながらパラメータ調整しつつ領域を切り出して、Mask R-CNNなどの学習データを作る方針が良いのかな、などと考えています。