【画像処理】硬貨(コイン)の種類を判別してみよう

### 【告知】

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


前回は「画像に写っている硬貨を特定しよう」シリーズの第1弾「[【画像処理】硬貨(コイン)を検出してみよう](https://qiita.com/spc_ehara/items/e627f5633d0e9d98a39a)」で、硬貨(コイン)を検出しました。
結果として、硬貨を検出することはできましたが、種類の判別まではできていませんでした。
そこで今回はその続編「画像に写っている硬貨を特定しよう」シリーズの第2弾で、硬貨(コイン)の種類を判別していきます。

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

![cointype.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/e88bba21-1f1e-7d88-3671-90b6ad1fecde.gif)

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

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


# 硬貨の識別

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

## 硬貨(コイン)の検出

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

上記画像のように、前回の記事「[【画像処理】硬貨(コイン)を検出してみよう](https://qiita.com/spc_ehara/items/e627f5633d0e9d98a39a)」でコインの検出までは終わっていますので、ここからは各コインの特徴量を抽出して、硬貨(コイン)の種類を判別していきます。

各コインの特徴として挙げられるのは大まかに以下の通りだと思います。

1. 形（穴が開いているかどうか）
1. 色（黄色っぽいのか白っぽいのか）
1. 大きさ（他のコインと比べて大きいのか小さいのか）
1. 表面・裏面の情報（ex. 500, 平成十九年, 等）

これらすべてを使えば精度の高い硬貨の種類判別ができそうですが、今回は「簡単」がテーマなので、その中でも形と色の情報のみを使って識別をしていきたいと思います。


## 形の特徴

硬貨に穴が開いているかどうかを判別します。

```Python:find_hole_contours

def find_hole_contours(contours, hierarchy):
    hole_cnt = []
    for cnt_idx, cnt in enumerate(contours):
        for hier_idx, info in enumerate(hierarchy[0]):
            if info[3] == cnt_idx:
                hole_area = cv2.contourArea(contours[hier_idx])
                parent_area = cv2.contourArea(cnt)
                if hole_area < (parent_area * 0.03128) or hole_area > (parent_area * 0.05665):
                    continue
                (center_x, center_y), radius = cv2.minEnclosingCircle(contours[hier_idx])
                circle_area = int(radius * radius * np.pi)
                if circle_area <= 0:
                    continue
                area_diff = circle_area / hole_area
                if 0.8 > area_diff or area_diff > 1.3:
                    continue
                hole_cnt.append(contours[hier_idx])

    return hole_cnt

```

```Python
    contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    hole_contours = find_hole_contours(contours, hierarchy)

```


ここでは輪郭を抽出した後に、硬貨の内側にある輪郭群の中から穴を探しています。
穴(孔)の特徴は以下の2つです。
1. 穴の面積（硬貨の面積と孔の面積比は決まっている）
1. 穴の丸み（きれいな丸みを持っている）


結果として、穴の判別によって以下の通りに分けることができるようになります。
穴あり→50円硬貨、5円硬貨
穴なし→500円硬貨、100円硬貨、10円硬貨、1円硬貨

![comress.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/d777c8d2-5293-9b9e-7a9c-d0a1efd77eb4.png)



## 色の特徴

各硬貨の情報をまとめて取得し、それらの情報をもとに硬貨の種類を判別します。

```Python:extract_feature

def extract_feature(src_img, coin_contours, hole_contours):
    if src_img.shape[2] != 3:
        return
    bgr_img = cv2.split(src_img)
    coins_color = []
    hole_features = []
    coins_area = []
    for i, cnt in enumerate(coin_contours):
        blank_img = np.zeros_like(bgr_img[0])
        coin_img = cv2.drawContours(blank_img, [cnt], -1, 255, -1)
        hole_flag = False
        for hcnt in hole_contours:
            if _isinclude_cnt(cnt, hcnt):
                coin_img = cv2.drawContours(coin_img, [hcnt], -1, 0, -1)
                hole_flag = True

        coin_pixels = np.where(coin_img == 255)
        # print(coin_pixels)

        blue = []
        green = []
        red = []
        for p in zip(coin_pixels[0], coin_pixels[1]):
            blue.append(bgr_img[0][p[0]][p[1]])
            green.append(bgr_img[1][p[0]][p[1]])
            red.append(bgr_img[2][p[0]][p[1]])

        coins_color.append([blue, green, red])
        hole_features.append(hole_flag)
        coins_area.append(math.ceil(cv2.contourArea(cnt)))

    return (coins_color, hole_features, coins_area)

```

```Python:determine_coin_type

def determine_coin_type(coins_color, hole_features):
    coin_type = []
    for (cc, hf) in zip(coins_color, hole_features):
        b_ave = math.ceil(np.average(cc[0]))
        g_ave = math.ceil(np.average(cc[1]))
        r_ave = math.ceil(np.average(cc[2]))
        b_mode = math.ceil(statistics.mode(cc[0]))
        g_mode = math.ceil(statistics.mode(cc[1]))
        r_mode = math.ceil(statistics.mode(cc[2]))
        rb_ave_diff = r_ave - b_ave
        rg_ave_diff = r_ave - g_ave
        gb_ave_diff = g_ave - b_ave
        rb_mode_diff = r_mode - b_mode
        rg_mode_diff = r_mode - g_mode
        gb_mode_diff = g_mode - b_mode

        guess_type = 0
        if hf is True:
            if (b_ave / r_ave) < 0.6 and (b_ave / r_ave) > 0.4:
                guess_type = 5
            else:
                guess_type = 50
        else:
            if (b_ave / r_ave) < 0.6 and (b_ave / r_ave) > 0.4:
                guess_type = 10
            elif (rb_ave_diff + rg_ave_diff + gb_ave_diff) < 50:
                guess_type = 1
            elif (rb_mode_diff + rg_mode_diff + gb_mode_diff) < 135 or (rg_mode_diff - gb_mode_diff) > 70:
                guess_type = 100
            else:
                guess_type = 500

        coin_type.append(guess_type)

    return coin_type

```

```Python

    (coins_color, hole_features, coins_area) = extract_feature(src_img, coin_contours, hole_contours)
    coin_type = determine_coin_type(coins_color, hole_features)

```


extract_feature関数で、各硬貨のピクセル情報、穴の有無、硬貨の面積を抽出します。
対象となる各硬貨のピクセル範囲は以下のようになります。
![000.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/29870f67-025f-4498-2ddb-7caa307d9a95.png)
![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/51ec73e3-fa2b-0aef-1a7c-314441168c6c.gif)


そしてdetermine_coin_type関数で、各硬貨のピクセル範囲の色情報と穴の有無から種類を判別しています。


## 描画
検出した硬貨、穴、種類を画像に描画します。

```Python:render

def render(dst_img, coin_contours, hole_contours, coin_type):
    for h in hole_contours:
        cv2.drawContours(dst_img, [h], -1, (255, 0, 0), 12)

    for (cnt, type) in zip(coin_contours, coin_type):
        cv2.drawContours(dst_img, [cnt], -1, (0, 0, 255), 6)
        cv2.putText(dst_img, str(type), _get_moments(cnt), cv2.FONT_HERSHEY_PLAIN, 8, (0, 0, 255), 8, cv2.LINE_AA)

```


![comress.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/71af4945-72ef-c9f8-3fdd-fa6ab190d3be.png)

## 検出結果
以上の手順で硬貨の識別ができるようになりました。試しに動画で処理をしてみた結果が以下の通りです。
概ね硬貨を識別できているのが確認できるかと思います。

![cointype.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/e88bba21-1f1e-7d88-3671-90b6ad1fecde.gif)



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

```Python:coin_detection.py

import argparse
import math
import numpy as np
import statistics

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 find_hole_contours(contours, hierarchy):
    hole_cnt = []
    for cnt_idx, cnt in enumerate(contours):
        for hier_idx, info in enumerate(hierarchy[0]):
            if info[3] == cnt_idx:
                hole_area = cv2.contourArea(contours[hier_idx])
                parent_area = cv2.contourArea(cnt)
                if hole_area < (parent_area * 0.03128) or hole_area > (parent_area * 0.05665):
                    continue
                (center_x, center_y), radius = cv2.minEnclosingCircle(contours[hier_idx])
                circle_area = int(radius * radius * np.pi)
                if circle_area <= 0:
                    continue
                area_diff = circle_area / hole_area
                if 0.8 > area_diff or area_diff > 1.3:
                    continue
                hole_cnt.append(contours[hier_idx])

    return hole_cnt


def _get_moments(cnt):
    M = cv2.moments(cnt)
    cx = int(M["m10"] / M["m00"])
    cy = int(M["m01"] / M["m00"])
    return (cx, cy)


def _isinclude_cnt(cnt_1, cnt_2):
    cntM_2 = _get_moments(cnt_2)
    flag = cv2.pointPolygonTest(cnt_1, cntM_2, False)
    if flag >= 0:
        return True
    else:
        return False


def extract_feature(src_img, coin_contours, hole_contours):
    if src_img.shape[2] != 3:
        return
    bgr_img = cv2.split(src_img)
    coins_color = []
    hole_features = []
    coins_area = []
    for i, cnt in enumerate(coin_contours):
        blank_img = np.zeros_like(bgr_img[0])
        coin_img = cv2.drawContours(blank_img, [cnt], -1, 255, -1)
        hole_flag = False
        for hcnt in hole_contours:
            if _isinclude_cnt(cnt, hcnt):
                coin_img = cv2.drawContours(coin_img, [hcnt], -1, 0, -1)
                hole_flag = True

        coin_pixels = np.where(coin_img == 255)

        blue = []
        green = []
        red = []
        for p in zip(coin_pixels[0], coin_pixels[1]):
            blue.append(bgr_img[0][p[0]][p[1]])
            green.append(bgr_img[1][p[0]][p[1]])
            red.append(bgr_img[2][p[0]][p[1]])

        coins_color.append([blue, green, red])
        hole_features.append(hole_flag)
        coins_area.append(math.ceil(cv2.contourArea(cnt)))

    return (coins_color, hole_features, coins_area)


def determine_coin_type(coins_color, hole_features):
    coin_type = []
    for (cc, hf) in zip(coins_color, hole_features):
        b_ave = math.ceil(np.average(cc[0]))
        g_ave = math.ceil(np.average(cc[1]))
        r_ave = math.ceil(np.average(cc[2]))
        b_mode = math.ceil(statistics.mode(cc[0]))
        g_mode = math.ceil(statistics.mode(cc[1]))
        r_mode = math.ceil(statistics.mode(cc[2]))
        rb_ave_diff = r_ave - b_ave
        rg_ave_diff = r_ave - g_ave
        gb_ave_diff = g_ave - b_ave
        rb_mode_diff = r_mode - b_mode
        rg_mode_diff = r_mode - g_mode
        gb_mode_diff = g_mode - b_mode

        guess_type = 0
        if hf is True:
            if (b_ave / r_ave) < 0.6 and (b_ave / r_ave) > 0.4:
                guess_type = 5
            else:
                guess_type = 50
        else:
            if (b_ave / r_ave) < 0.6 and (b_ave / r_ave) > 0.4:
                guess_type = 10
            elif (rb_ave_diff + rg_ave_diff + gb_ave_diff) < 50:
                guess_type = 1
            elif (rb_mode_diff + rg_mode_diff + gb_mode_diff) < 135 or (rg_mode_diff - gb_mode_diff) > 70:
                guess_type = 100
            else:
                guess_type = 500

        coin_type.append(guess_type)

    return coin_type


def render(dst_img, coin_contours, hole_contours, coin_type):
    for h in hole_contours:
        cv2.drawContours(dst_img, [h], -1, (255, 0, 0), 12)

    for (cnt, type) in zip(coin_contours, coin_type):
        cv2.drawContours(dst_img, [cnt], -1, (0, 0, 255), 6)
        cv2.putText(dst_img, str(type), _get_moments(cnt), cv2.FONT_HERSHEY_PLAIN, 8, (0, 0, 255), 8, cv2.LINE_AA)


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]
    dst_img = src_img.copy()
    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))

    coin_contours = filter_contours(bin_img, (min_area, max_area))

    contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    hole_contours = find_hole_contours(contours, hierarchy)

    (coins_color, hole_features, coins_area) = extract_feature(src_img, coin_contours, hole_contours)

    coin_type = determine_coin_type(coins_color, hole_features)

    render(dst_img, coin_contours, hole_contours, coin_type)


if __name__ == "__main__":
    main()


```


# さいごに

今回は硬貨(コイン)の識別をしました。前提条件が多くありますが、比較的簡単に識別ができたかと思います。
ただし、ここから実用性を求めるためには、もう少し前提条件を細かく設定する＆ロバスト性の高いアルゴリズムを実装する必要があります。
前者ではカメラと光源に対して一定の制限を設けて、できる限り撮影条件が安定するようにします。後者ではある程度撮影条件にぶれがあっても幅広く対応できるようなアルゴリズムを実装します。
特に色の判定は光の具合やカメラによっても大きく変わり、非常に難しいため、前提条件にもっと強い制限(カメラと光源を固定する等)を加えるか、色の情報を使わずに処理を行う等のもうひと工夫が必要になります。

今後も画像処理に関しての記事を投稿していきますので、引き続きよろしくお願いいたします。


この講座の構成は、以下の記事より確認できます。

https://qiita.com/spc_ehara/items/e425b6dcc0398299c40d