【画像処理】硬貨(コイン)を検出してみよう

### 【告知】

2022年4月7日に予定しております当社のオンラインセミナーでは、この記事の内容について私が登壇してご説明させていただきます。
ご興味のある方はぜひご参加いただければと思います。参加は以下のURLからお願いいたします。
第3部 「AIを使わない!?物体検出」: https://sciencepark.connpass.com/event/241381/



今回は「画像に写っている硬貨を特定しよう」シリーズの第1弾で、硬貨(コイン)を検出していきます。

それぞれのバージョンはPython 3.8.2、OpenCV 4.5.3になります。また、今回の記事の内容はOpenCVの[公式ドキュメント](https://docs.opencv.org/4.5.3/d3/dc0/group__imgproc__shape.html)を参考にしています。

![coin_detect.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/88981dc4-d396-8c50-1769-0b2f69aed9b6.gif)


# 前提条件
今回検出対象の画像は以下の前提条件を満たしているものに限定していますので、他の条件ではうまくいかない可能性があります。

1. 硬貨の種類は2022年3月現在、日本銀行から発行されている有効な硬貨6種類(500円貨,100円貨,50円貨,10円貨,5円貨,1円貨)を対象とする。ただし、令和3年発行の新500円貨は除く。
1. 背景は模様の少ない黒一色で硬貨に対する白飛びはないものとする。
1. 画像には背景と硬貨のみが写っている。
1. 画像に対する硬貨の大きさは画像の100分の1以上、5分の1以下とする。
1. 各硬貨同士が重なるまたは隣接(15ピクセル以内)して置かれることはない。
1. 硬貨を正面から撮影した画像である。


# 硬貨の検出

硬貨を検出していく過程を順に説明していきます。

## 元画像
![coin1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/15de9877-8b89-d171-9302-21d97e52630e.png)


## 2値化

```Python:binalize

def binalize(src_img):
    gray = cv2.cvtColor(src_img, cv2.COLOR_BGR2GRAY)
    gaus = cv2.GaussianBlur(gray, (15, 15), 5)
    bin = cv2.adaptiveThreshold(gaus, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 81, 2)
    bin = cv2.morphologyEx(bin, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)))
    return bin

```

ガウシアンフィルタで背景にある細かいノイズを除きます。
![gaus.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/c78ff34a-c4e3-3e79-b7cd-f395b143295d.png)


適応的二値化により、影や光等の影響を抑えながら、2値化します。

今回のように画像内で輝度値の差が大きい場合(明るい部分と暗い部分が両方ある場合等)は、一律で閾値を決めてしまうと画像全体をうまく二値化できません。
適応的二値化は決められたカーネルサイズごとに閾値を決めるため、画像全体の輝度値の差に影響されずに二値化することができます。
![adap.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/f2b4cb00-7609-9fe1-7530-d4e1bbc4023d.png)

モルフォロジー変換をして背景を大きな1つのオブジェクトにくっつけてしまうと同時に、硬貨のふちが切れてしまうのを防ぎます。
![morph.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/c430b0ba-b61e-6fc0-bd30-ec65117c7490.png)

## ラベリング処理

```Python:filter_object

def filter_object(bin_img, thresh_w, thresh_h, thresh_area):
    nlabels, labels_img, stats, centroids = cv2.connectedComponentsWithStats(bin_img.astype(np.uint8))
    obj_stats_idx = np.where(
        (stats[1:, cv2.CC_STAT_WIDTH] > thresh_w[0])
        & (stats[1:, cv2.CC_STAT_WIDTH] < thresh_w[1])
        & (stats[1:, cv2.CC_STAT_HEIGHT] > thresh_h[0])
        & (stats[1:, cv2.CC_STAT_HEIGHT] < thresh_h[1])
        & (stats[1:, cv2.CC_STAT_AREA] > thresh_area[0])
        & (stats[1:, cv2.CC_STAT_AREA] < thresh_area[1])
    )
    return np.where(np.isin(labels_img - 1, obj_stats_idx), 255, 0).astype(np.uint8)

```


```Python:main

    height, width = src_img.shape[:2]
    max_area = math.ceil((width * height) / 5)
    min_area = math.ceil((width * height) / 100)
    bin_img = filter_object(bin_img, (0, (width / 2)), (0, (height / 2)), (min_area, max_area))

```

閾値外の大きさのオブジェクトを除外するために、ラベリング処理を行い、硬貨と思われる丸いオブジェクトのみを残します。

![coin_bin.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/9ab1d8be-7fdb-4e9b-0393-6707df20cf6e.png)


## 輪郭抽出
オブジェクトの輪郭を抽出し、その面積と輪郭の中心点からオブジェクトを包む最小の円の面積を比較します。
面積がほぼ同じ＝円の形をしたオブジェクトのみを抽出し、元画像に描画します。

```Python:filter_contours

def filter_contours(bin_img, thresh_area):
    contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
    new_cnt = []
    for cnt in contours:
        area = cv2.contourArea(cnt)
        if thresh_area[0] > area or area > thresh_area[1]:
            continue
        (center_x, center_y), radius = cv2.minEnclosingCircle(cnt)
        circle_area = int(radius * radius * np.pi)
        if circle_area <= 0:
            continue
        area_diff = circle_area / area
        if 0.9 > area_diff or area_diff > 1.1:
            continue
        new_cnt.append(cnt)

    return new_cnt

def render_contours(contours, src_img):
    contours_img = None
    for cnt in contours:
        (center_x, center_y), radius = cv2.minEnclosingCircle(cnt)
        contours_img = cv2.circle(src_img, (int(center_x), int(center_y)), int(radius), (0, 0, 255), 8)
    return contours_img

```

```Python:main

    contours = filter_contours(bin_img, (min_area, max_area))
    cnt_img = render_contours(contours, src_img)

```

![test.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/0e70f432-426c-24db-4d4d-fdd51f9d7ce4.png)

## 検出結果
以上の手順で硬貨の検出ができるようになりました。試しに動画で処理をしてみた結果が以下の通りです。
しっかり硬貨の検出できているのが確認できるかと思います。
![coin_detect.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/9a3381db-b210-b140-c496-ebf10b5a3405.gif)


## 実装
実際の実装はこのようになります。

```Python:coin_detection.py

import argparse
import math
import numpy as np

import cv2


def binalize(src_img):
    gray = cv2.cvtColor(src_img, cv2.COLOR_BGR2GRAY)
    gaus = cv2.GaussianBlur(gray, (15, 15), 5)
    bin = cv2.adaptiveThreshold(gaus, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 81, 2)
    bin = cv2.morphologyEx(bin, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)))
    return bin


def filter_object(bin_img, thresh_w, thresh_h, thresh_area):
    nlabels, labels_img, stats, centroids = cv2.connectedComponentsWithStats(bin_img.astype(np.uint8))
    obj_stats_idx = np.where(
        (stats[1:, cv2.CC_STAT_WIDTH] > thresh_w[0])
        & (stats[1:, cv2.CC_STAT_WIDTH] < thresh_w[1])
        & (stats[1:, cv2.CC_STAT_HEIGHT] > thresh_h[0])
        & (stats[1:, cv2.CC_STAT_HEIGHT] < thresh_h[1])
        & (stats[1:, cv2.CC_STAT_AREA] > thresh_area[0])
        & (stats[1:, cv2.CC_STAT_AREA] < thresh_area[1])
    )
    return np.where(np.isin(labels_img - 1, obj_stats_idx), 255, 0).astype(np.uint8)


def filter_contours(bin_img, thresh_area):
    contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
    new_cnt = []
    for cnt in contours:
        area = cv2.contourArea(cnt)
        if thresh_area[0] > area or area > thresh_area[1]:
            continue
        (center_x, center_y), radius = cv2.minEnclosingCircle(cnt)
        circle_area = int(radius * radius * np.pi)
        if circle_area <= 0:
            continue
        area_diff = circle_area / area
        if 0.9 > area_diff or area_diff > 1.1:
            continue
        new_cnt.append(cnt)

    return new_cnt


def render_contours(contours, src_img):
    contours_img = None
    for cnt in contours:
        (center_x, center_y), radius = cv2.minEnclosingCircle(cnt)
        contours_img = cv2.circle(src_img, (int(center_x), int(center_y)), int(radius), (0, 0, 255), 8)
    return contours_img


def parse_args() -> tuple:
    parser = argparse.ArgumentParser()
    parser.add_argument("IN_IMG", help="Input file")
    parser.add_argument("OUT_IMG", help="Output file")
    args = parser.parse_args()

    return (args.IN_IMG, args.OUT_IMG)


def main() -> None:
    (in_img, out_img) = parse_args()
    src_img = cv2.imread(in_img)
    if src_img is None:
        return
    height, width = src_img.shape[:2]
    bin_img = binalize(src_img)

    max_area = math.ceil((width * height) / 5)
    min_area = math.ceil((width * height) / 100)
    bin_img = filter_object(bin_img, (0, (width / 2)), (0, (height / 2)), (min_area, max_area))

    contours = filter_contours(bin_img, (min_area, max_area))
    cnt_img = render_contours(contours, src_img)
    cv2.imwrite(out_img, cnt_img)


if __name__ == "__main__":
    main()

```



# さいごに

今回は硬貨(コイン)を検出していきました。前提条件はあるものの、比較的簡単に検出ができたかと思います。
今後も画像処理に関しての記事を投稿していきますので、引き続きよろしくお願いいたします。


目次は以下の記事からご覧になれます。

https://qiita.com/spc_ehara/items/e425b6dcc0398299c40d#%E7%9B%AE%E6%AC%A1