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

### 【告知】

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



第1弾 硬貨(コイン)を検出してみよう

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

第2弾 硬貨(コイン)の種類を判別してみよう

https://qiita.com/spc_ehara/private/b0c913a89877615d30b2

前回、前々回の2記事(上記参照)で、硬貨(コイン)の検出・判別ができるようになりました。しかし、実際には硬貨同士がくっついて置かれることも十分に考えられるかと思います。
そこで今回は「画像に写っている硬貨を特定しよう」シリーズの第3弾で、くっついている硬貨(コイン)を検出していきたいと思います。
これによって、以下のように前提条件を少しだけ緩く設定することができます。

> 「各硬貨同士が重なるまたは隣接(15ピクセル以内)して置かれることはない。」
> ↓
> 「各硬貨同士が重なって置かれることはない。」


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


![compress_coin3_new.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/d3d9797f-4b81-67ea-9923-ba24ca92494c.gif)



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

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


# くっついている硬貨の検出

第1弾の[「硬貨(コイン)を検出してみよう」](https://qiita.com/spc_ehara/items/e627f5633d0e9d98a39a)で行った検出アルゴリズムを少し変更して、くっついている硬貨を検出していく過程を順に説明していきます。

## 元画像
![0005.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/163f2884-60bc-a2ab-b515-0b58af8f18c3.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_GAUSSIAN_C, cv2.THRESH_BINARY, 81, 4)
    bin = cv2.morphologyEx(bin, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), iterations=3)
    return bin


```

①ガウシアンフィルタで背景にある細かいノイズを除きます。
②適応的二値化により、影や光等の影響を抑えながら、2値化します。
③モルフォロジー変換をして背景を大きな1つのオブジェクトにくっつけてしまうと同時に、硬貨のふちが切れてしまうのを防ぎます。

ここまでは前回の2値化処理と大きく変わったところはありません。


## ラベリング処理

```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) / 2)
    min_area = math.ceil((width * height) / 100)
    bin_img = filter_object(bin_img, (0, (width / 1.5)), (0, (height / 1.5)), (min_area, max_area))
    bin_img = cv2.morphologyEx(
        bin_img, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), iterations=5
    )


```

閾値外の大きさのオブジェクトを除外するために、ラベリング処理を行い、硬貨と思われる丸いオブジェクトのみを残します。
ただし、今回は硬貨がくっついている場合を考慮して、max_areaの値を少し広げます。また、各オブジェクトの輪郭線が切れないようにモルフォロジー変換でくっつけておきます。この時に硬貨が同士がくっついてしまいますが、直後の処理でそれらを分割していきます。

![coin_bin.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/81faa40c-4647-5f71-04b0-4ad737da5505.png)



## 円の検出と分割

```Python:circle_separator

def circle_separator(bin_img):
    fill_bin = np.zeros_like(bin_img)
    contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    cv2.drawContours(fill_bin, contours, -1, 255, -1)
    circles = cv2.HoughCircles(
        fill_bin, cv2.HOUGH_GRADIENT, dp=1, minDist=180, param1=20, param2=10, minRadius=100, maxRadius=250
    )
    if circles is None:
        return bin_img
    circles = np.uint16(np.around(circles))
    separated_img = bin_img.copy()
    for i in circles[0, :]:
        cv2.circle(separated_img, (i[0], i[1]), i[2], 0, 2)
    return separated_img


```

```Python:main

    separated_img = circle_separator(bin_img)

```


オブジェクトの輪郭を抽出し、各オブジェクトを塗りつぶします。
![fill_bin.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/91f82aab-d75f-9422-da98-b6a999599296.png)

ハフ変換による円検出で、硬貨のある部分を検出し、入力画像(bin_img)に対して円の輪郭を黒で描画します。
これで各硬貨がくっついていた部分を切り離すことができました。
![separated_img.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/ea987d5c-645b-40bc-aaf8-d3872ec04bee.png)


## 輪郭抽出

```Python:find_circle_contours

def find_circle_contours(contours, thresh_area):
    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


```

```Python:main

    contours, hierarchy = cv2.findContours(separated_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
    coin_contours = find_circle_contours(contours, (min_area, max_area))

    dst_img = src_img.copy()
    for cnt in coin_contours:
        (center_x, center_y), radius = cv2.minEnclosingCircle(cnt)
        cv2.circle(dst_img, (int(center_x), int(center_y)), int(radius), (0, 0, 255), 15)
    cv2.imwrite("dst_img.png", dst_img)


```


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


## 検出結果

以上の手順でくっついている硬貨の検出ができるようになりました。

|以前のアルゴリズム|今回のアルゴリズム|
|:-:|:-:|
|![tt.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/e94796cc-e9d1-9c97-ed73-7418149242e9.jpeg)|![t.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/592f5055-d909-670f-198c-7354bcce8627.jpeg)|


動画で処理をしてみた結果が以下の通りです。

以前のアルゴリズム↓
![compress_coin3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/2c1a0785-75bc-b0e0-0c44-c60ee8dab498.gif)

今回のアルゴリズム↓
![compress_coin3_new.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/a152cfff-4eea-ff30-dfd8-f4845435a23f.gif)

以前のアルゴリズムと比較してみると、硬貨がくっついている場合もしっかり検出できているのが確認できるかと思います。


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

```Python:coin_detection_v2.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_GAUSSIAN_C, cv2.THRESH_BINARY, 81, 4)
    bin = cv2.morphologyEx(bin, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), iterations=3)
    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 find_circle_contours(contours, thresh_area):
    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 circle_separator(bin_img):
    fill_bin = np.zeros_like(bin_img)
    contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    cv2.drawContours(fill_bin, contours, -1, 255, -1)
    circles = cv2.HoughCircles(
        fill_bin, cv2.HOUGH_GRADIENT, dp=1, minDist=180, param1=20, param2=10, minRadius=100, maxRadius=250
    )
    if circles is None:
        return bin_img
    circles = np.uint16(np.around(circles))
    separated_img = bin_img.copy()
    for i in circles[0, :]:
        cv2.circle(separated_img, (i[0], i[1]), i[2], 0, 2)
    return separated_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) / 2)
    min_area = math.ceil((width * height) / 100)
    bin_img = filter_object(bin_img, (0, (width / 1.5)), (0, (height / 1.5)), (min_area, max_area))
    bin_img = cv2.morphologyEx(
        bin_img, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), iterations=5
    )

    separated_img = circle_separator(bin_img)
    contours, hierarchy = cv2.findContours(separated_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
    coin_contours = find_circle_contours(contours, (min_area, max_area))

    dst_img = src_img.copy()
    for cnt in coin_contours:
        (center_x, center_y), radius = cv2.minEnclosingCircle(cnt)
        cv2.circle(dst_img, (int(center_x), int(center_y)), int(radius), (0, 0, 255), 15)
    cv2.imwrite("dst.png", dst_img)


if __name__ == "__main__":
    main()


```



# さいごに

今回はくっついている硬貨(コイン)を検出していきました。前提条件が減って、ロバスト性が上がったと思います。
今後も画像処理に関しての記事を投稿していきますので、引き続きよろしくお願いいたします。


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

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