【告知】
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の公式ドキュメントを参考にしています。
前提条件
今回検出対象の画像は以下の前提条件を満たしているものに限定していますので、他の条件ではうまくいかない可能性があります。
- 硬貨の種類は2022年3月現在、日本銀行から発行されている有効な硬貨6種類(500円硬貨,100円硬貨,50円硬貨,10円硬貨,5円硬貨,1円硬貨)を対象とする。ただし、令和3年発行の新500円硬貨は除く。
- 背景は模様の少ない黒一色で硬貨に対する白飛びはないものとする。
- 画像には背景と硬貨のみが写っている。
- 画像に対する硬貨の大きさは画像の100分の1以上、5分の1以下とする。
- 各硬貨同士が重なって置かれることはない。
- 硬貨を正面から撮影した画像である。
くっついている硬貨の検出
第1弾の「硬貨(コイン)を検出してみよう」で行った検出アルゴリズムを少し変更して、くっついている硬貨を検出していく過程を順に説明していきます。
元画像
コインがくっついている画像を使います。
2値化
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値化処理と大きく変わったところはありません。
ラベリング処理
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)
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の値を少し広げます。また、各オブジェクトの輪郭線が切れないようにモルフォロジー変換でくっつけておきます。この時に硬貨が同士がくっついてしまいますが、直後の処理でそれらを分割していきます。
円の検出と分割
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
separated_img = circle_separator(bin_img)
オブジェクトの輪郭を抽出し、各オブジェクトを塗りつぶします。
ハフ変換による円検出で、硬貨のある部分を検出し、入力画像(bin_img)に対して円の輪郭を黒で描画します。
これで各硬貨がくっついていた部分を切り離すことができました。
輪郭抽出
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
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)
オブジェクトの輪郭を抽出し、その面積と輪郭の中心点からオブジェクトを包む最小の円の面積を比較します。
面積がほぼ同じ=円の形をしたオブジェクトのみを抽出します。
検出結果
以上の手順でくっついている硬貨の検出ができるようになりました。
以前のアルゴリズム | 今回のアルゴリズム |
---|---|
動画で処理をしてみた結果が以下の通りです。
以前のアルゴリズムと比較してみると、硬貨がくっついている場合もしっかり検出できているのが確認できるかと思います。
実装
実際の実装はこのようになります。
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()
さいごに
今回はくっついている硬貨(コイン)を検出していきました。前提条件が減って、ロバスト性が上がったと思います。
今後も画像処理に関しての記事を投稿していきますので、引き続きよろしくお願いいたします。
目次は以下の記事からご覧になれます。