4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-03-22

【告知】

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

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

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

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

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

「各硬貨同士が重なって置かれることはない。」

それぞれのバージョンはPython 3.8.2、OpenCV 4.5.3になります。また、今回の記事の内容はOpenCVの公式ドキュメントを参考にしています。

compress_coin3_new.gif

前提条件

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

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

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

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

元画像

0005.png

コインがくっついている画像を使います。

2値化

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値化処理と大きく変わったところはありません。

ラベリング処理

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)

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

円の検出と分割

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


main

    separated_img = circle_separator(bin_img)

オブジェクトの輪郭を抽出し、各オブジェクトを塗りつぶします。
fill_bin.png

ハフ変換による円検出で、硬貨のある部分を検出し、入力画像(bin_img)に対して円の輪郭を黒で描画します。
これで各硬貨がくっついていた部分を切り離すことができました。
separated_img.png

輪郭抽出

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


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 t.jpg

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

以前のアルゴリズム↓
compress_coin3.gif

今回のアルゴリズム↓
compress_coin3_new.gif

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

実装

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

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()


さいごに

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

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

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?