囲碁の盤面認識APIについて

# 盤面認識API

技育展で開発したものをずっとそのままにして紹介するのを忘れてました。いい機会なので、何をしていたか説明します。

## そもそも何がしたかったのか

技育展では「プロ棋士サーバ」というプロダクトを作りました。これは囲碁を対面で打つにあたってのさまざまな不便を解消するためのWebアプリでした。

盤面認識APIを作ることになったのは、「対局中に棋譜を覚える」という面倒を解消する機能を製作する一環でした。もちろん、ユーザに盤面の四隅を指定してもらってそこから石の色を取得するという方法でもよかったのですが、せっかくハッカソンなので新しいことに挑戦しようということで作り始めました。

## 始める前の構想

いろいろ調べていたところ、次の記事にたどり着きました。
「OpenCVでオセロ盤の盤面を認識する」
https://qiita.com/tanaka-a/items/fe6b95ae922b684021cc

この記事では大きく次のステップで盤面を認識していました。
- 緑の領域を盤面と判断して四隅を特定
- 膨張・収縮によって余計な線などの雑音を消す
- 射影変換
- 色の確定

この時点では盤面の判定が最も大きな障害でした。オセロとちがって碁盤は茶色であるため、まったく関係ない領域を碁盤と判定してしまう可能性がかなり上がるからです。そこで、ここだけはオリジナルで作ることにしました。
幸い碁石もオセロと同じ白黒だったので、盤面の判定以外はそのまま参考にさせていただきました。（これが将棋だったらと思うと若干震えます）

## 実装
### 四隅の取得

石の置かれている碁盤の位置を取得することはおそらく困難であろうと考えました。そこで、最初のまっさらな碁盤についてのみ四隅の取得をおこない、あとはカメラが動かないという仮定のもとで同じ四隅の座標を使い回すことにしました。

基本的な発想は「盤面上の線分のみを検出して、その端点のうち画像の一番上下左右にあるやつが四隅なんじゃない？」っていうものです。

しかし、碁盤の縦線または横線が画像に対して平行になっていた場合、隅の点が上下左右のはじにくるとは限りません。そこで、碁盤の縦線、横線と平行にならないような傾きを探しにいきました。

大まかに次のような流れで四隅の座標を取得しました。
- 白黒画像に変換
![gray.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2327508/ba8ddd68-0eb7-110b-09f7-577f9aa5b2a3.jpeg)

- エッジを検出
![edge.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2327508/54648973-ddee-84e7-e533-fdd188a24321.jpeg)

- 膨張・収縮を利用してエッジ間の隙間を消し、なおかつ関係ない線分も消す
![dilate.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2327508/a94e3147-e83a-ae9b-18a7-6578acb7098d.jpeg)
![erode.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2327508/f7bde9d3-4888-c9e8-e729-5425c314c927.jpeg)

続いて、以下の手順を実行します。
- 十分に長い線分（画像幅の10分の1以上）を取得
- 各線分の角度を計算
- ソートして角度が急に変わるタイミングを取得

上の手順を踏むと、縦線と横線それぞれの角度が大体わかります。この角度ともっとも離れた傾きの直線を2本とり、端点の中でその2本の直線に関して一番離れているものを隅の点とします。

この画像のように四隅の位置が把握できています。
![line.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2327508/b3a15ed6-1706-cccc-3643-7105c0f99d18.jpeg)

```python
from math import atan, cos, sin
import cv2
import numpy as np


def det_board(image):
    neiborhood = np.array([[1, 1, 1], [1, 1, 1], [1, 1, 1]])

    # 白黒画像に変換
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    # エッジ検出
    split = cv2.Canny(gray, 30, 300, L2gradient=True)

    # 線の膨張、収縮でエッジ間の隙間を消す
    dilate_image = cv2.dilate(split, neiborhood, iterations=1)
    erode_image = cv2.erode(dilate_image, neiborhood, iterations=2)
    
    # 直線を検出、画像幅の10分の1以上の長さの線のみ認識
    lines = cv2.HoughLinesP(
        erode_image,
        rho=1,
        theta=np.pi / 360,
        threshold=50,
        minLineLength=len(image[0]) // 10,
        maxLineGap=len(image[0]) // 20,
    )

    # リストに変換
    line_array = [list(line[0]) for line in lines]
    line_theta = []

    # 各線分の頂点情報の末尾に角度の情報を追加
    for line in line_array:
        x1, y1, x2, y2 = line
        line_theta.append([[x1, y1], [x2, y2], atan((y1 - y2) / (x1 - x2))])

    # 線分を角度の大きさ順にソート
    line_theta.sort(key=lambda x: x[-1])

    theta_border = np.pi / 6

    # 線分を順に見ていき、角度の変化が30度以上あった部分を
    # 縦線、横線の境界とする
    # 縦線、横線、いずれかの角度の平均値を計算
    para_theta = []
    sum_flag = False
    for i in range(len(line_theta)):
        if line_theta[i][-1] - line_theta[i - 1][-1] > theta_border:
            sum_flag = not sum_flag
        if sum_flag:
            para_theta.append(line_theta[i][-1])

    mean_theta = sum(para_theta) / len(para_theta)

    # 求めた平均値との差が45度である角度を求める
    theta = [mean_theta + np.pi / 4, mean_theta - np.pi / 4]

    # 全ての線分の端点についてその点を通る角度thetaの直線のy切片の
    # 最大値、最小値を取る点を求める
    # その点は碁盤の四隅とみなせる
    min_p = [10**10, 10**10]
    max_p = [0, 0]
    cnrs = [[[], []], [[], []]]
    for i in [0, 1]:
        c = cos(theta[i])
        s = sin(theta[i])
        for j in range(2):
            for line in line_theta:
                y_const = line[j][1] * c - line[j][0] * s
                if min_p[i] > y_const:
                    min_p[i] = y_const
                    cnrs[i][0] = line[j]
                if max_p[i] < y_const:
                    max_p[i] = y_const
                    cnrs[i][1] = line[j]

    ret_x = [cnrs[0][0][0], cnrs[1][0][0], cnrs[0][1][0], cnrs[1][1][0]]
    ret_y = [cnrs[0][0][1], cnrs[1][0][1], cnrs[0][1][1], cnrs[1][1][1]]
    return ret_x, ret_y
```

### 上から見た画像に変換
OpenCVの`getPerspectiveTransform`と`warpPerspective`を使って、比較的簡単に実装できました。
さっきの画像に適用すると奥の方が少しずれてますね。
![corrected.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2327508/83b02610-1d70-6021-5b5b-df3ec0ac2453.jpeg)


```python
import cv2
import numpy as np

def cor_board(image, x, y):
    image = cv2.blur(image, (3, 3))

    p1 = np.array([x[0], y[0]])
    p2 = np.array([x[1], y[1]])
    p3 = np.array([x[2], y[2]])
    p4 = np.array([x[3], y[3]])

    offset = 40
    height = 20 * offset * 2

    src = np.float32([p1, p2, p3, p4])
    dst = np.float32(
        [
            [offset, offset],
            [height - offset, offset],
            [height - offset, height - offset],
            [offset, height - offset],
        ]
    )

    M = cv2.getPerspectiveTransform(src, dst)
    output = cv2.warpPerspective(image, M, (height, height))
```

### 各座標の色を取得

画像を正方形に変形したので、各石の中心の位置は簡単に取得できます。このコードでは、中心を含む`(5,5)`の正方形領域の輝度値を平均した値を計算し、その値としきい値の大小で、黒石、白石、石なしを判別しています。

```python
import cv2
from utility import gen_boardimg
import numpy as np

BOARD = 19

def color_array(image):
    image = cv2.blur(image, (3, 3))

    rgbImage = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
    hsvImage = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)

    ost = 800 / BOARD
    x = ost / 2
    y = ost / 2
    ret = [[0] * BOARD for _ in range(BOARD)]

    print(len(rgbImage))

    for i in range(BOARD):
        for j in range(BOARD):
            centerX = int(x + ost * j)
            centerY = int(y + ost * i)
            center = (centerX, centerY)
            circle_image = cv2.circle(image, center, 3, (255, 0, 0), thickness=-1)
            pixel = hsvImage[centerY - 2 : centerY + 3, centerX - 2 : centerX + 3, 2]
            bright = np.mean(pixel)
            if bright > 230:
                ret[i][j] = 2
            elif bright > 100:
                ret[i][j] = 0
            else:
                ret[i][j] = 1

    gen_boardimg(ret)
    return ret
```

## 結論

インターネット上の碁盤の画像で試してみた結果、エラー率は体感で20％程度でした。そもそも実行時エラーが発生してしまうもの、四隅の位置がずれてしまうもの、色を正確に判別できていないものがちょうど同じ数ずつ程度起きていました。もっとうまい方法があったんじゃないかな？？

２週間程度で完成させた割には、うまく動いてるんじゃないかと思ってます。ただ、実用に耐えるかというとまったくそんなことはないですね......

それでも、ハッカソンのクオリティとしては十分だったようで、技育展の発表の日にも何人の方からかお褒めの言葉をいただきました。実際、「プロ棋士サーバ」は最終的に優秀賞をいただくことができたので、個人的には大満足です！

最後に、一緒に出場してくれたHaru、Reso、Toyoshinには改めて感謝の言葉を送らせていただきます。
ありがとーー！！！