OpenCV 비전 실무12장 · Contour Detection 과 객체 분석19 / 21← 이전목차다음 →MetaContents

12장. Contour Detection 과 객체 분석

프로젝트 1 — 컨베이어 부품 개수와 크기 검사

작은 부품 공장의 검사 수업을 준비하는 강사라고 해 봅시다. 컨베이어 위를 지나가는 부품을 위에서 찍은 사진 두 장이 있습니다. 부품은 원과 네모 두 모양이고, 크기가 작음, 보통, 큼의 세 종류입니다. 사진마다 부품이 몇 개인지, 크기 종류별로 몇 개인지, 어디에 있는지를 표로 내고, 부품마다 상자와 번호를 그린 결과 그림을 만듭니다. 사진을 그릴 때 정답표도 함께 만들어 두었으므로, 결과가 맞았는지 확인할 수 있습니다.

데이터는 ch12_conveyor/ 에 있습니다. 모두 OpenCV 로 그려 만든 가상의 사진이고, 실제 공장이나 제품과 관계없습니다.

파일내용
belt_1.png부품 9개, 먼지, 부품 위의 작은 반사점이 있는 사진 (320×240, 흑백)
belt_2.png부품 12개, 먼지, 반사점이 있는 사진
answer.csv정답표. 열은 사진, 모양(원·네모), 크기(작음·보통·큼), x, y(그릴 때 정한 부품의 가운데)

크기 종류는 그릴 때 이렇게 정했습니다. 네모 가운데 몇 개는 비스듬히 돌려 놓았습니다.

크기원의 반지름네모의 한 변
작음1018
보통1628
큼2442

요구사항

  1. 두 사진을 흑백으로 읽고 Otsu INV 로 이진화합니다. 정답표를 읽어 사진마다 정답 개수를 찍습니다.
  2. RETR_EXTERNAL 로 윤곽선을 찾고, 면적 100 미만은 먼지로 보고 버립니다. 사진마다 버리기 전과 뒤의 개수를 찍습니다.
  3. 남은 부품마다 면적으로 크기를 "작음, 보통, 큼" 으로 나눕니다. 기준 면적은 위 표의 크기로 손계산해 정하고, 어떻게 정했는지 주석으로 적습니다.
  4. 부품마다 번호, 크기, 면적, 중심(cx, cy), 곧은 상자(x, y, w, h)를 표로 찍습니다. 번호는 위 줄부터, 같은 줄에서는 왼쪽부터 매깁니다.
  5. 사진마다 크기 종류별 개수를 정답과 견주고, 부품마다 가장 가까운 정답의 크기와 같은지 확인해 맞힌 수를 찍습니다.
  6. 두 사진에 상자와 번호, 크기 글자(S, M, L)를 그려 나란히 붙인 그림을 out_ch12_parts.png 로 저장하고 띄웁니다.
  7. 결론을 실제 숫자로 적습니다.

힌트

  1. 이진화부터 거르기까지는 본문 "개수 세기와 잡티 걸러 내기" 절의 짧은 예시와 같습니다.
  2. 크기 기준은 크기마다 넓이를 손으로 구해 정합니다. 작은 원은 3.14 × 10 × 10 = 314, 작은 네모는 18 × 18 = 324 입니다. 작음과 보통, 보통과 큼 사이의 한가운데쯤에 기준을 둡니다.
  3. 사진 하나를 받아 표(DataFrame)를 돌려주는 함수를 만들면 두 사진에 똑같이 쓸 수 있습니다.
  4. 크기별 개수는 value_counts() 로 셉니다. 정답표와 결과표를 같은 순서(작음, 보통, 큼)로 놓으면 견주기 쉽습니다.
  5. 가장 가까운 정답은 중심 사이의 거리 √((x1 − x2)² + (y1 − y2)²) 가 가장 작은 행입니다. 거리가 크면(예: 10칸 넘게) 짝이 없다고 봅니다.
  6. 두 그림을 가로로 붙이는 데는 2장에서 본 cv2.hconcat 을 씁니다. cv2.putText 는 한글을 쓸 수 없으므로 크기는 S, M, L 로 씁니다.

채점 기준

항목기준점수
이진화와 정답 읽기Otsu INV 로 부품을 흰색으로 만들고 정답 개수를 찍는다10
먼지 거르기EXTERNAL 로 찾고 면적 기준으로 걸러 개수를 찍는다15
크기 나누기손계산으로 기준 면적을 정하고 까닭을 적는다20
결과표번호·크기·면적·중심·상자를 위치 순서로 찍는다20
정답 대조크기별 개수와 부품마다의 맞힘을 견준다15
결과 그림상자·번호·크기 글자를 그려 저장하고 띄운다10
결론실제 숫자로 적는다10

코드의 흐름

이진화, 거르기, 분류, 표와 그림, 정답 대조의 다섯 단계를 이은 흐름도

그림의 다섯 단계를 예시 풀이의 단계와 같은 순서로 짰습니다. 둘째 단계에서 먼지를 버린 뒤에야 셋째 단계의 크기 나누기가 의미가 있습니다. 먼지가 남아 있으면 "작음"이 엉뚱하게 늘어납니다. 다섯째 단계는 개수만 견주지 않고 부품마다 짝을 지어 봅니다. 개수가 맞아도 작은 것과 보통을 서로 바꿔 판정했을 수 있기 때문입니다.

실제 데이터로 해 보려면

실제 사진으로 해 보려면 AI Hub(aihub.or.kr)에서 제조 부품이나 불량 검사 이미지 자료를 찾아 내려받습니다. 내려받기 전에 이용 약관(상업적 이용 가능 여부, 재배포 금지 등)을 확인하고, 작업자 같은 사람이 찍힌 사진은 얼굴이 드러나지 않게 다룹니다. 실제 사진은 조명이 고르지 않아 Otsu 한 번으로 부품이 깔끔하게 나오지 않을 수 있으니, 8장의 적응형 이진화와 9장의 열기·닫기를 먼저 해 봅니다. 크기를 mm 로 말하려면 자나 크기를 아는 기준 물체를 함께 찍어 "한 칸이 몇 mm" 인지 구해 둡니다.

결과 보기 (예시 풀이)

1단계. 사진과 정답 읽고 이진화하기

Python
import cv2
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

plt.rcParams["font.family"] = "Malgun Gothic"
plt.rcParams["axes.unicode_minus"] = False

names = ["belt_1.png", "belt_2.png"]
# 요구사항 1: 정답표를 읽고 사진마다 정답 개수를 센다
answer = pd.read_csv("ch12_conveyor/answer.csv")
print(answer.groupby("사진").size())

grays, binaries = {}, {}
for name in names:
    grays[name] = cv2.imread(f"ch12_conveyor/{name}", cv2.IMREAD_GRAYSCALE)
    # 부품이 작업대보다 어두우므로 INV + Otsu 로 부품을 흰색으로
    t, binaries[name] = cv2.threshold(grays[name], 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    print(f"{name}: 모양 {grays[name].shape}, Otsu 기준값 {int(t)}")
출력
사진
belt_1.png     9
belt_2.png    12
dtype: int64
belt_1.png: 모양 (240, 320), Otsu 기준값 135
belt_2.png: 모양 (240, 320), Otsu 기준값 135

2단계. 윤곽선 찾고 먼지 거르기

Python
MIN_AREA = 100      # 요구사항 2: 이 면적보다 작은 덩어리는 먼지로 본다

parts = {}
for name in names:
    contours, _ = cv2.findContours(binaries[name], cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    parts[name] = [c for c in contours if cv2.contourArea(c) >= MIN_AREA]
    print(f"{name}: 윤곽선 {len(contours)}개 → 면적 {MIN_AREA} 이상 {len(parts[name])}개")
출력
belt_1.png: 윤곽선 43개 → 면적 100 이상 9개
belt_2.png: 윤곽선 46개 → 면적 100 이상 12개

3단계. 크기 나누고 결과표 만들기

Python
# 요구사항 3: 크기마다 넓이를 손으로 구한다
#   작음: 원 3.14 × 10 × 10 = 314, 네모 18 × 18 = 324
#   보통: 원 3.14 × 16 × 16 = 804, 네모 28 × 28 = 784
#   큼  : 원 3.14 × 24 × 24 = 1809, 네모 42 × 42 = 1764
# 작음(약 320)과 보통(약 800)의 한가운데쯤인 550, 보통(약 800)과 큼(약 1790)의 한가운데쯤인 1300 을 기준으로 한다
def size_of(area):
    if area < 550:
        return "작음"
    if area < 1300:
        return "보통"
    return "큼"

def analyze(contours):
    """윤곽선 목록을 받아 부품마다 한 줄인 표를 위치 순서로 돌려준다."""
    rows = []
    for c in contours:
        m = cv2.moments(c)
        x, y, w, h = cv2.boundingRect(c)
        area = cv2.contourArea(c)
        rows.append({"크기": size_of(area), "면적": area,
                     "cx": round(m["m10"] / m["m00"], 1), "cy": round(m["m01"] / m["m00"], 1),
                     "x": x, "y": y, "w": w, "h": h})
    table = pd.DataFrame(rows)
    # 요구사항 4: 위 줄부터(줄 높이 100칸), 같은 줄에서는 왼쪽부터 번호를 매긴다
    table["줄"] = (table["cy"] // 100).astype(int)
    table = table.sort_values(["줄", "cx"]).drop(columns="줄").reset_index(drop=True)
    table.insert(0, "번호", table.index + 1)
    return table

results = {name: analyze(parts[name]) for name in names}
for name in names:
    print(f"[{name}]")
    print(results[name].to_string(index=False))
출력
[belt_1.png]
 번호 크기     면적    cx    cy   x   y  w  h
  1  큼 1832.0  50.0  55.0  26  31 49 49
  2 보통  784.0 130.0  50.0 116  36 29 29
  3 작음  320.0 200.0  50.0 190  40 21 21
  4  큼 1824.0 270.0  60.0 243  33 55 55
  5 보통  830.0  50.0 160.0  34 144 33 33
  6 작음  362.0 120.0 150.0 107 137 27 27
  7 보통  822.0 190.0 170.0 174 154 33 33
  8  큼 1832.0 270.0 180.0 246 156 49 49
  9 작음  320.0 125.0 210.0 115 200 21 21
[belt_2.png]
 번호 크기     면적    cx    cy   x   y  w  h
  1 작음  320.0  40.0  40.0  30  30 21 21
  2 보통  822.0 100.0  50.0  84  34 33 33
  3 작음  312.0 170.0  45.0 160  35 21 21
  4  큼 1832.0 240.0  45.0 216  21 49 49
  5 작음  320.0 295.0  40.0 285  30 21 21
  6 보통  849.0  45.0 130.0  26 111 39 39
  7 작음  320.0 115.0 125.0 105 115 21 21
  8 보통  822.0 185.0 130.0 169 114 33 33
  9  큼 1764.0 265.0 140.0 244 119 43 43
 10 보통  830.0  40.0 205.0  24 189 33 33
 11 작음  324.0 110.0 200.0 101 191 19 19
 12  큼 1832.0 200.0 200.0 176 176 49 49

4단계. 정답과 견주기

Python
order = ["작음", "보통", "큼"]
for name in names:
    truth = answer[answer["사진"] == name]
    # 요구사항 5-1: 크기 종류별 개수 견주기
    count = pd.DataFrame({"정답": truth["크기"].value_counts().reindex(order, fill_value=0),
                          "결과": results[name]["크기"].value_counts().reindex(order, fill_value=0)})
    print(f"[{name}]")
    print(count.T)

    # 요구사항 5-2: 부품마다 가장 가까운 정답과 크기가 같은지
    hit = 0
    for _, row in results[name].iterrows():
        dist = np.sqrt((truth["x"] - row["cx"]) ** 2 + (truth["y"] - row["cy"]) ** 2)
        nearest = truth.loc[dist.idxmin()]
        if dist.min() <= 10 and nearest["크기"] == row["크기"]:
            hit += 1
    print(f"맞힌 부품: {hit} / {len(truth)}")
    far = max(np.sqrt((truth["x"] - r["cx"]) ** 2 + (truth["y"] - r["cy"]) ** 2).min()
              for _, r in results[name].iterrows())
    print(f"중심과 정답 위치의 가장 큰 어긋남: {round(float(far), 2)}칸")
출력
[belt_1.png]
크기  작음  보통  큼
정답   3   3  3
결과   3   3  3
맞힌 부품: 9 / 9
중심과 정답 위치의 가장 큰 어긋남: 0.0칸
[belt_2.png]
크기  작음  보통  큼
정답   5   4  3
결과   5   4  3
맞힌 부품: 12 / 12
중심과 정답 위치의 가장 큰 어긋남: 0.0칸

5단계. 결과 그림 저장하기

Python
letter = {"작음": "S", "보통": "M", "큼": "L"}      # putText 는 한글을 쓸 수 없어 영문 글자로
panels = []
for name in names:
    canvas = cv2.cvtColor(grays[name], cv2.COLOR_GRAY2BGR)
    for _, row in results[name].iterrows():
        x, y, w, h = int(row["x"]), int(row["y"]), int(row["w"]), int(row["h"])
        cv2.rectangle(canvas, (x, y), (x + w - 1, y + h - 1), (0, 160, 0), 2)
        label = f"{int(row['번호'])}{letter[row['크기']]}"          # 예: 3M = 3번, 보통
        cv2.putText(canvas, label, (x, max(y - 3, 12)), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 255), 2)
    panels.append(canvas)

# 요구사항 6: 두 사진을 가로로 붙여(2차시의 hconcat) 저장한 뒤 띄운다
both = cv2.hconcat(panels)
cv2.imwrite("out_ch12_parts.png", both)
print("저장: out_ch12_parts.png", both.shape)

plt.figure(figsize=(11, 4.4))
plt.imshow(cv2.cvtColor(both, cv2.COLOR_BGR2RGB))
plt.title("부품마다 번호와 크기(S 작음, M 보통, L 큼)")
plt.axis("off")
plt.show()
출력
저장: out_ch12_parts.png (240, 640, 3)
컨베이어 사진 두 장의 부품마다 초록 상자와 번호, 크기 글자 S·M·L 을 그려 나란히 붙인 결과 그림

두 사진 모두 맞았습니다. 먼지와 부품이 함께 흰색이 되어 윤곽선은 belt_1 이 43개, belt_2 가 46개 잡혔지만, 면적 100 미만을 버리자 정답과 같은 9개, 12개만 남았습니다. 크기 종류별 개수도 belt_1 이 작음·보통·큼 3, 3, 3, belt_2 가 5, 4, 3 으로 정답과 같았고, 부품마다 가장 가까운 정답과 짝지어 보아도 9개 가운데 9개, 12개 가운데 12개의 크기가 맞았습니다. 무게중심은 정답표의 가운데 좌표와 어긋남 없이(0.0칸) 같았습니다. 잘 맞은 까닭은 면적의 무리 사이가 넓었기 때문입니다. 작음은 312~362, 보통은 784~849, 큼은 1,764~1,832 로 나와, 기준 550 과 1,300 에서 모두 200 넘게 떨어져 있습니다. 비스듬히 돌린 작은 네모(belt_1 의 6번)가 362 로 손계산 324 보다 컸는데, 비스듬한 테두리를 부드럽게 칠하며 생긴 회색 칸 일부가 이진화에서 부품 쪽으로 들어갔기 때문입니다. 수업에서는 이렇게 정리합니다. 이 방법은 부품끼리 떨어져 있고 크기 종류 사이가 넓을 때 잘 맞습니다. 실제 사진에서는 부품이 맞닿거나, 조명 때문에 이진화가 흔들리거나, 크기 종류가 비슷할 수 있습니다. 그때는 9장의 열기로 부품을 떼고, 크기 기준을 정하기 전에 실습 5처럼 면적 분포를 먼저 찍어 무리 사이의 빈 구간을 확인합니다.

← 이전 절12장 · 실습 5 — 잡티 걸러 개수 세기와 connectedComponentsWithStats다음 절 →12장 · 정리