試験の電子採点を画像認識でサポートした話

[ゆるゆる新卒1年生 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/yuruyuru-freshman)、23日目へようこそ。
ゆるいにも程がある新卒1年生、 [@NamedPython] (https://qiita.com/NamedPython) です:joy:

今日は僕の隠し特技である **OpenCVによる画像認識** についてです。

画像認識について書くことはあらかじめ決めていましたが、新しく何かネタを見つけるのもきつかったので、学校の授業で~~頑張って書いたのにどこにも供養してなかった~~ **回答用紙の領域抽出** をした記録をここに残します:pencil2:


## 何を作ろうとしたのさ

授業と言いましたが、授業名は **プログラミング応用** で、普段授業で学んでいること・学生の持つ技術を絡めて、ソフトウェア開発による問題解決をチームで取り組んでみましょうねというものです。

じゃあ、問題解決のテーマは何かというと、

**先生が行なっている試験の電子採点を楽にしてくれ**:pray:

というものでした。そもそも電子採点している時点でかなりナウでヤングなんですが要件をまとめると

- 学生の試験回答をスキャンして**iPad**に取り込んでいる(**ナウい**)
- 回答用紙は解答欄を用意しているわけではなく、自由記述
- 上記のこともあり回答の位置が生徒によって**バラバラ**
- なので、取り込んだ画像の回答の領域をApple Pencilで**色分け**して囲んでいる(**ナウい**)
- 同じ問題(同じ色分け)を一気に採点したい
- 君たち、**なんとかしてくれ**:pray:

って感じでした。

### チーム

実はこの授業の一年前に、

- バックエンドAPI、Webフロントの @NamedPython 
- iOSの @Taillook 
- Androidの @youta1119 
- PythonとRasPi制御、自然言語処理の @Kyoskk

というそこそこ最強のチームを組んで大批判を浴びました。その影響で、チームを組むメンツに制限がかかりました:frowning2: 

それでも、先述の @Taillook と、もう一人仲の良いクラスメイトを引っ張って3人のチームを組みました。

### ソリューション

先述のチーム組みで、**iOS書けるやつ**を引っ張ってきました。

せっかくなので、何から何までiOSに乗っける以下のような構成のシステムを提案しました(最終発表のプレゼンから抽出)。 iOS周りの実装をありがとう、@Taillook。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115622/350e31a2-c7d0-0dde-9add-e12a22f2b683.png)

この図はKeynoteでゴリゴリ頑張りました。フォントは**M+**と、**筑紫B丸ゴシック**。

## 認識概要

- 色分けする運用が既にあるので、彩度のある部分のみを抽出すれば良さそう
    - `BGR`空間から`HSV`空間に変換して、`S`(彩度のチャネル)のみ抽出
- 二値化
    - Sチャネル抽出の時点でそこそこ二値化されているため、単純な`判別分析法`でOK
- 領域をそれぞれ切り出す
    - `findContours`による辺検出
    - 様々な辺が検出されるため、`cv2.RETR_TREE`による辺の構造化
        - 内側にさらに辺があるもの `OR` 面積が一定以下 をスキップ
- その領域がどの色で塗られているかを判別する
    - 抽出された辺の斜め左上(`x - 1`, `y - 1`)のピクセルの色を20点ほど抽出
    - 平均をとって、その辺の色とする

## 対象画像

当時は動作検証用の実物の回答用紙をもらいましたが、ここで持ってくるには権利周りがグレーな予感がしたので、**Pages職人** の力を使ってサンプルを生成しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115622/804abcd5-cd20-61b8-8835-00fc407bd514.png)


## 実装

さて、もうそろそろ退屈なのでドーンとソースコードを。
`FROM jjanzic/docker-python3-opencv` な `Dockerfile`を用意してどこでも開発できるように用意しました。最終的に画像認識部分は僕がフルで開発したのでオーバーキルでしたが。

Dockerの中身は以下。

- Python 3.7.0
- OpenCV 3.4.1

```python:source.py
import os
import cv2
import numpy as np

DIR = os.path.dirname(os.path.abspath(__file__))

image = cv2.imread(f'{DIR}/image/sample_answer_sheet.png')
if image is None:
    print("File not found.")
    exit()


def write_out(img, name):
    cv2.imwrite(f'{DIR}/image/result/{name}.png', img)


def extract_inside(contours, hierarchy, debug=False):
    extracted = []
    contours_drawed = image.copy()
    if debug:
        print(f'len: {len(contours)}')
        print(hierarchy)

    for index in range(len(contours)):
        if hierarchy[0, index, 2] != -1 or cv2.contourArea(contours[index]) < 8000:
            continue
        extracted.append(contours[index])

        if debug:
            cv2.drawContours(contours_drawed, contours, index, (255, 0, 255), 10)

        print(f'{index} : {cv2.contourArea(contours[index])}')

    if debug:
        write_out(contours_drawed, 'out_contours')

    return extracted


width, height = image.shape[:2]

extracted = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)[:, :, 1]

write_out(extracted, 'after_cvt')

ret, thrshld_ex = cv2.threshold(extracted, 0, 255, cv2.THRESH_OTSU)

write_out(thrshld_ex, 'thrshld')

kernel = np.ones((3, 1), np.uint8)
thrshld_ex = cv2.morphologyEx(thrshld_ex, cv2.MORPH_OPEN, kernel)

write_out(thrshld_ex, 'thrshld_ex')

_, contours, hierarchy = cv2.findContours(thrshld_ex, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)

inside_contours = extract_inside(contours, hierarchy, True)

print(f'extracted {len(inside_contours)} countours')

clr_img_cp = image.copy()
for circle in inside_contours:
    colors = []
    av_color = 0
    M = cv2.moments(circle)
    center_of_circle = (int(M['m10']/M['m00']), int(M['m01']/M['m00']))
    cnt = 0
    for approx in circle:
        if cnt == 20:
            break
        cv2.circle(clr_img_cp, (approx[0, 0] - 1, approx[0, 1] - 1), 1, (255, 0, 255), 1)
        colors.append(image[approx[0, 1] - 1, approx[0, 0] - 1])
        cnt += 1
    cav = np.mean(colors, axis=0)
    cv2.circle(clr_img_cp, center_of_circle, 100, cav, 20)

write_out(clr_img_cp, 'out')

```

先ほどの認識概要に書いていたものをソースコードに落とすとこんな感じです。こんな感じで`Python`でプロトタイピングしてから `Objective-C` に移植して @Taillook にSwiftとブリッヂしてもらいました。

## 結果

- `HSV` 色空間への変換をして `S` のみ抽出
- `判別分析法` による二値化
- 辺検出
- 辺の色を抽出

の4工程それぞれの結果を載せます。

### `HSV` 色空間への変換をして `S` のみ抽出

```python:extract_s_of_hsv.py
cv2.cvtColor(image, cv2.COLOR_BGR2HSV)[:, :, 1] # 0: H, 1: S, 2: V
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115622/8bf1606d-c656-7123-07a5-63546ebf9ac0.png)

もうこの時点でほぼ二値化なんですよね。すぎょい。

### `判別分析法` による二値化

`cv2.THRESH_OTSU`を指定するだけ。イージー。

```python:thresold_with_otsu.py
cv2.threshold(extracted, 0, 255, cv2.THRESH_OTSU)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115622/31400e76-3e1a-7c7d-dde7-e5be2ca4e825.png)

少しノイズが入る場合は、このあと`morphologyEX`をかけてノイズ除去をするとよきです。

### 辺検出

もうもはやOpenCVがすごい。`findContours`がすごい。論文を一応読んだんですが、辺の構造情報まで認識するとか変態的すぎる。


```python:find_contours_with_structure.py
_, contours, hierarchy = cv2.findContours(thrshld_ex, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
```

`hierarchy`に、`contours`内のどのindexの辺がのindexの辺の**横にある**とか、**内包してるとか**を格納しています。すぎょい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115622/7c6b2b14-beed-7759-9e33-c16ccf20908a.png)

辺を点の集合で表し、紫色で描画しています。

### 辺の色を抽出 + 描画

ここはゴリ押しです。色の平均をこんなにも簡単に取れる `numpy` に感謝。

```python:sampling_color_and_averaging_and_plotting.py
clr_img_cp = image.copy()
for circle in inside_contours:
    colors = []
    av_color = 0
    M = cv2.moments(circle)
    center_of_circle = (int(M['m10']/M['m00']), int(M['m01']/M['m00']))
    cnt = 0
    for approx in circle:
        if cnt == 20:
            break
        cv2.circle(clr_img_cp, (approx[0, 0] - 1, approx[0, 1] - 1), 1, (255, 0, 255), 1)
        colors.append(image[approx[0, 1] - 1, approx[0, 0] - 1])
        cnt += 1
    cav = np.mean(colors, axis=0)
    cv2.circle(clr_img_cp, center_of_circle, 100, cav, 20)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115622/5fbbb764-669e-a763-00b9-23e63b65b893.png)

図形の中心を割り出し、そこに平均をとった色で円を描画しています。
(実はこの画像よく見ると、20サンプルの対象ピクセルに紫色がついています。)
だいたい辺と同じ色で良い感じ。

## まとめ

ということで、学生時代に組んだ画像処理のプログラムをここで供養しました :coffin:

僕からすれば振り返りだったのですが、これを読んで`Python` + `OpenCV` の可能性を感じていただければ幸いです。

ただ一つ注意点として、`OpenCV` は理論を知っていなくても多くの事例から学んで使えてしまいますが、やはり問題解決に対する手段適用の最短経路を導くには理論が先決な気がしているので、**情報工学は大事**です。僕は軽くすっぽかしたためぎりぎりの理論しかありません。

ただ以下は確実に言えるでしょう。

- `OpenCV` はすごい
- `Python` + `OpenCV` はやりやすい
    - `numpy` との相性がいい

ということです。実はこの記事を書く前に`Rust`による`OpenCV`を試したりもしたんですが、`Python` + `OpenCV`の使い心地と比べてしまうとイマイチでした。まぁ`Rust`の実装に慣れていない僕が言うので参考にはならないかもしれませんが:yum:

`Python`だし遅いんでしょー？って話もありそうですが、描画を除くとなんと **0.3 [sec]** ほど。
ただの `C-binding` だしね。すげえや。
僕が開発に携わっている[自転車通販サイト cyma -サイマ-](https://cyclemarket.jp)でも、なんだか画像認識をやる気運が上がってきたので、力を発揮していきたい。

@shimura_atsushi が [Ateam cyma Advent Calendar 2019](https://qiita.com/advent-calendar/2019/ateam-cyma) で2記事書いているので興味がある方はどうぞ。

- [Google Could PlatformのOCRサービスを使ってサイマの課題に挑戦する](https://qiita.com/shimura_atsushi/items/cb29b1d3841fc21d460b)
- [続 Google Could PlatformのOCRサービスを使ってサイマの課題に挑戦する
](https://qiita.com/shimura_atsushi/items/e37fc05dcb08e924125f)

## おわりに

[ゆるゆる新卒1年生 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/yuruyuru-freshman)、23日目いかがでしたか？

24日目は、バックエンドもわかるつよつよフロントエンドエンジニア @cheez921 のターンです。デュエル、スタンバイ！

よい**クリスマス**、よい**年末**、よい**年始**、よい**画像認識**を。