OpenCV 비전 실무13장 · Shape 분석과 ROI 처리19 / 21← 이전목차다음 →MetaContents

13장. Shape 분석과 ROI 처리

프로젝트 1 — 교통 표지판 모양 분류기

도로 안전 수업을 준비하는 강사가 표지판 그림에서 모양별로 표지판을 골라 보여 주려고 합니다. 표지판 여덟 개가 서 있는 가상의 그림(ch13_signs.png)에서 윤곽선을 찾고, 모양 특징과 규칙으로 표지판마다 "원, 세모, 네모, 팔각형"을 붙여 보세요. 그리고 표지판마다 ROI 를 잘라 한 줄 미리보기로 이어 붙여 저장합니다. 그림을 만들 때 정답 모양을 함께 적어 두었으므로, 몇 개를 맞혔는지 셀 수 있습니다.

표지판은 실제 교통 표지 도안이 아니라, 단순한 도형에 영문과 숫자만 넣어 그린 연습용 가상 그림입니다.

파일내용
ch13_signs.png표지판 8개가 선 장면 (320×240, 컬러). 하늘과 보도는 채도가 낮은 회색 계열, 표지판은 빨강·노랑·파랑
ch13_signs_answer.csv정답 표. sign_id(번호), cx, cy(표지판 가운데), shape(circle, triangle, square, octagon)

정답 표의 모양은 원 2개, 세모 2개, 네모 2개, 팔각형 2개입니다. 그 가운데 하나씩은 작게 그리거나(5번 팔각형, 7번 세모) 15도 기울여(6번 네모) 그렸습니다.

요구사항

  1. 그림을 읽고 HSV 의 S(채도)로 표지판만 흰색인 마스크를 만든 뒤, 바깥 윤곽선을 찾습니다. 넓이 200 이하의 작은 덩어리는 버립니다.
  2. 윤곽선마다 꼭짓점 수(epsilon 비율 0.01), 원형도, 볼록 비율, boundingRect 를 재어 표로 찍습니다.
  3. 꼭짓점 수로 모양을 정하는 규칙(3 → triangle, 4 → square, 8 → octagon, 9 이상 → circle, 그 밖 → unknown)으로 이름을 붙입니다.
  4. 윤곽선의 가운데와 가장 가까운 정답 표의 줄을 짝지어 맞혔는지 표시하고, 맞힌 개수를 찍습니다. epsilon 비율 0.02 로도 해서 두 비율의 맞힌 개수를 표로 견줍니다.
  5. 표지판마다 여백 6칸을 주고 그림 밖으로 나가지 않게 ROI 를 잘라 64×64 로 맞추고, 아래에 규칙의 답을 적어 한 줄로 이어 붙인 그림을 out_ch13_signs.png 로 저장하고 띄웁니다.

힌트

  1. 하늘과 보도, 기둥은 회색이라 채도(S)가 낮고, 표지판은 높습니다. cv2.threshold(hsv[:, :, 1], 80, 255, cv2.THRESH_BINARY) 로 나눕니다.
  2. 표지판 안의 흰 글자와 기호는 바깥 윤곽선(RETR_EXTERNAL)만 찾으면 신경 쓰지 않아도 됩니다.
  3. 윤곽선의 가운데는 boundingRect 의 x + w / 2, y + h / 2 로 어림합니다. 정답 표의 cx, cy 와의 거리 제곱이 가장 작은 줄을 고릅니다.
  4. 미리보기는 cv2.resize(조각, (64, 64)) 로 크기를 맞추고, cv2.copyMakeBorder 로 아래에 글자 칸을 붙인 뒤 cv2.hconcat 으로 잇습니다.
  5. 실습 4의 safe_crop() 을 그대로 가져다 씁니다.

채점 기준

항목기준점수
윤곽선채도 마스크로 표지판 8개의 바깥 윤곽선을 찾는다20
특징 표꼭짓점, 원형도, 볼록 비율, 상자를 표로 찍는다20
규칙꼭짓점 수 규칙으로 이름을 붙인다15
맞힌 개수정답 표와 짝지어 두 비율의 맞힌 개수를 견준다25
미리보기여백을 주고 안전하게 잘라 한 줄로 이어 저장하고 띄운다20

코드의 흐름

채도 마스크, 윤곽선, 꼭짓점 세기, 정답과 짝짓기, 미리보기 저장의 다섯 단계 흐름도

그림의 다섯 단계를 예시 풀이의 단계와 같은 순서로 짰습니다. 첫 단계에서 색 범위 대신 채도 하나로 고르는 까닭은 빨강, 노랑, 파랑 표지판을 한 번에 고르기 위해서입니다. 셋째 단계의 규칙은 꼭짓점 수 하나만 쓰므로 epsilon 비율이 맞지 않으면 원과 팔각형이 바로 섞입니다. 넷째 단계에서 두 비율의 맞힌 개수를 견주는 까닭이 이것입니다.

실제 데이터로 해 보려면

실제 도로 사진으로 해 보려면 AI Hub(aihub.or.kr) 같은 곳에 공개된 교통 표지판·도로 주행 이미지를 내려받습니다. 내려받기 전에 데이터마다 이용 약관(상업적 이용 가능 여부, 재배포 금지 등)을 확인하고, 사진에 찍힌 사람의 얼굴이나 차량 번호판 같은 개인정보의 처리 기준을 먼저 확인합니다. 실제 사진은 하늘, 간판, 자동차에도 채도가 높은 색이 많아서 채도 하나로는 표지판만 골라지지 않습니다. 3장의 HSV 범위로 빨강·파랑·노랑을 따로 고르고, 넓이와 볼록 비율로 표지판이 아닌 덩어리를 걸러 내야 합니다. 표지판이 비스듬히 찍히면 원이 타원, 팔각형이 찌그러진 모양이 되므로, 14장의 기하 변환으로 바로 세운 뒤 재는 방법도 있습니다.

결과 보기 (예시 풀이)

1단계. 채도 마스크로 윤곽선 찾기

Python
import math
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

img = cv2.imread("ch13_signs.png")
answer = pd.read_csv("ch13_signs_answer.csv")
print("정답 표:", len(answer), "줄 /", answer["shape"].value_counts().to_dict())

# 요구사항 1: 채도(S)가 높은 곳만 흰색. 하늘·보도·기둥은 회색이라 채도가 낮다
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
_, mask = cv2.threshold(hsv[:, :, 1], 80, 255, cv2.THRESH_BINARY)
found, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 넓이 200 이하의 작은 덩어리는 버리고, 위 줄 → 아래 줄, 왼쪽 → 오른쪽으로 늘어놓는다
signs = [c for c in found if cv2.contourArea(c) > 200]
signs = sorted(signs, key=lambda c: (cv2.boundingRect(c)[1] // 120, cv2.boundingRect(c)[0]))
print("찾은 덩어리:", len(found), "/ 표지판으로 남긴 것:", len(signs))
출력
정답 표: 8 줄 / {'circle': 2, 'triangle': 2, 'square': 2, 'octagon': 2}
찾은 덩어리: 8 / 표지판으로 남긴 것: 8

2단계. 특징 표와 규칙

Python
def measure(c, ratio):
    """요구사항 2: 윤곽선 하나의 특징."""
    area = cv2.contourArea(c)
    peri = cv2.arcLength(c, True)
    x, y, w, h = cv2.boundingRect(c)
    return {"꼭짓점": len(cv2.approxPolyDP(c, ratio * peri, True)),
            "원형도": round(4 * math.pi * area / peri ** 2, 3),
            "볼록비율": round(area / cv2.contourArea(cv2.convexHull(c)), 3),
            "box": (x, y, w, h)}

def classify(v):
    """요구사항 3: 꼭짓점 수로 모양 이름을 붙인다."""
    if v == 3:
        return "triangle"
    if v == 4:
        return "square"
    if v == 8:
        return "octagon"
    if v > 8:
        return "circle"
    return "unknown"

rows = []
for c in signs:
    f = measure(c, 0.01)
    rows.append({**f, "규칙의 답": classify(f["꼭짓점"])})
table = pd.DataFrame(rows)
print(table.to_string(index=False))
출력
 꼭짓점   원형도  볼록비율                box    규칙의 답
  12 0.885 0.979   (14, 29, 53, 53)   circle
   3 0.550 0.968   (94, 30, 53, 46) triangle
   4 0.785 1.000  (177, 32, 47, 47)   square
   8 0.948 1.000  (255, 30, 51, 51)  octagon
   8 0.948 1.000  (23, 143, 35, 35)  octagon
   4 0.684 0.969  (93, 138, 55, 55)   square
   3 0.549 0.956 (183, 148, 35, 31) triangle
  12 0.882 0.980 (256, 141, 49, 49)   circle

3단계. 정답과 짝지어 맞힌 개수 세기

Python
def match(c):
    """요구사항 4: 윤곽선 가운데와 가장 가까운 정답 표의 줄."""
    x, y, w, h = cv2.boundingRect(c)
    cx, cy = x + w / 2, y + h / 2
    d2 = (answer["cx"] - cx) ** 2 + (answer["cy"] - cy) ** 2   # 거리의 제곱
    return answer.loc[d2.idxmin()]

check = []
for c, guess in zip(signs, table["규칙의 답"]):
    row = match(c)
    f2 = measure(c, 0.02)                               # 비율 0.02 로 다시 잰다
    check.append({"번호": int(row["sign_id"]), "정답": row["shape"],
                  "비율 0.01": guess, "비율 0.02": classify(f2["꼭짓점"])})
check = pd.DataFrame(check).sort_values("번호")
print(check.to_string(index=False))

summary = pd.DataFrame({
    "epsilon 비율": ["0.01", "0.02"],
    "맞힌 개수": [int((check["정답"] == check["비율 0.01"]).sum()),
                 int((check["정답"] == check["비율 0.02"]).sum())],
})
summary["전체"] = len(check)
print(summary.to_string(index=False))
출력
 번호       정답  비율 0.01  비율 0.02
  1   circle   circle  octagon
  2 triangle triangle triangle
  3   square   square   square
  4  octagon  octagon  octagon
  5  octagon  octagon  octagon
  6   square   square   square
  7 triangle triangle triangle
  8   circle   circle  octagon
epsilon 비율  맞힌 개수  전체
      0.01      8   8
      0.02      6   8

4단계. 표지판 ROI 미리보기 저장하기

Python
def safe_crop(image, box, margin):
    """실습 4와 같다: 여백을 붙여 자르되 그림 밖으로 나가지 않게 묶는다."""
    H, W = image.shape[:2]
    x, y, w, h = box
    x0, x1 = int(np.clip(x - margin, 0, W)), int(np.clip(x + w + margin, 0, W))
    y0, y1 = int(np.clip(y - margin, 0, H)), int(np.clip(y + h + margin, 0, H))
    return image[y0:y1, x0:x1].copy()

tiles = []
for box, guess in zip(table["box"], table["규칙의 답"]):
    # 요구사항 5: 여백 6칸, 64×64 로 맞춘다
    piece = cv2.resize(safe_crop(img, box, 6), (64, 64), interpolation=cv2.INTER_AREA)
    # copyMakeBorder(그림, 위, 아래, 왼쪽, 오른쪽, 방식, value=색): 아래에 흰 글자 칸 18줄을 붙인다
    tile = cv2.copyMakeBorder(piece, 0, 18, 1, 1, cv2.BORDER_CONSTANT, value=(255, 255, 255))
    cv2.putText(tile, guess, (2, 78), cv2.FONT_HERSHEY_SIMPLEX, 0.32, (0, 0, 0), 1, cv2.LINE_AA)
    tiles.append(tile)
# hconcat(horizontal concatenate): 같은 높이의 그림들을 가로로 잇는다
strip = cv2.hconcat(tiles)
print("미리보기 크기:", strip.shape)
print("저장:", cv2.imwrite("out_ch13_signs.png", strip))

plt.figure(figsize=(11, 2.4))
plt.imshow(cv2.cvtColor(strip, cv2.COLOR_BGR2RGB))
plt.title("표지판 ROI 미리보기와 규칙의 답 (비율 0.01)")
plt.axis("off")
plt.show()
출력
미리보기 크기: (82, 528, 3)
저장: True
표지판 여덟 개의 ROI 를 64×64 로 잘라 아래에 규칙의 답을 적고 한 줄로 이어 붙인 미리보기

채도 80 으로 나눈 마스크에서 덩어리 8개가 모두 표지판으로 남았습니다. 비율 0.01 에서는 8개를 모두 맞혔습니다. 원 두 개는 꼭짓점 12개, 팔각형 두 개는 8개로 갈렸고, 작게 그린 5번 팔각형(35×35)과 7번 세모(35×31), 15도 기운 6번 네모도 꼭짓점 수는 그대로였습니다. 비율 0.02 에서는 6개만 맞혔습니다. 원 두 개(1번, 8번)가 꼭짓점 8개가 되어 팔각형으로 틀렸습니다. 원형도로 둘을 가르려 해도 원은 0.885 와 0.882, 팔각형은 둘 다 0.948 이라 팔각형 쪽이 더 커서, "원형도가 크면 원"이라는 규칙은 오히려 거꾸로 답합니다. 기운 6번 네모는 원형도 0.684 로 바로 선 3번 네모(0.785)보다 작았지만 꼭짓점 규칙에는 영향이 없었습니다. 미리보기(가로 528, 세로 82)에는 여백 6칸 때문에 기둥의 윗부분도 함께 들어갔습니다. 이 그림에서는 0.01 이 맞았지만, 실제 사진에서는 표지판의 크기와 노이즈에 따라 알맞은 비율이 달라집니다. 정답을 아는 그림 몇 장으로 비율을 먼저 시험해 보고 정합니다.

← 이전 절13장 · 실습 5 — 마스크 ROI 와 ROI 안에서만 처리하기다음 절 →13장 · 정리