OpenCV 비전 실무9장 · Morphology: 침식, 팽창, 열기, 닫기19 / 21← 이전목차다음 →MetaContents

9장. Morphology: 침식, 팽창, 열기, 닫기

프로젝트 1 — 부품 검사 사진의 이진 마스크 다듬기

강사가 "부품 개수 세기" 수업을 준비하며 작업대 위의 부품을 찍은 가상의 검사 사진 두 장을 만들었습니다. 8장처럼 Otsu 로 이진화했더니 부품 수가 엉뚱하게 세어집니다. 작업대의 먼지가 흰 잡티로, 부품 위의 반사광이 검은 구멍으로, 부품을 가로지르는 가는 금이 부품을 두 조각으로 나누었기 때문입니다. 열기와 닫기를 조합해 마스크를 다듬고, 덩어리 수가 정답 부품 수와 맞는지, 맞힌 칸 비율이 얼마인지 견줘 가장 좋은 조합을 골라 보세요.

데이터는 ch09_inspect/ 에 있습니다. 부품, 먼지, 반사광, 금은 모두 OpenCV 로 그린 가상의 그림이고, 실제 제품이나 회사와 관계없습니다.

파일내용
tray_1.png동전 모양 부품 셋, 네모 부품 둘, 와셔 하나를 찍은 검사 사진 (240×180, 흑백)
tray_2.png네모 부품 셋, 동전 모양 부품 둘, 와셔 둘을 찍은 검사 사진
tray_N_mask.png사진 N 의 정답 마스크. 그림을 만들 때 부품 자리를 따로 저장했다. 부품 칸 255, 바탕 0

요구사항

  1. 사진 두 장과 정답 마스크를 흑백으로 읽습니다. 부품이 흰색이 되도록 THRESH_BINARY_INV + THRESH_OTSU 로 이진화하고, 사진마다 Otsu 가 고른 값, 정답 부품 수, 이진화 결과의 덩어리 수, 맞힌 칸 비율을 찍습니다.
  2. 순서 두 가지(열기 → 닫기, 닫기 → 열기)와 타원 도장 크기 세 가지(3, 5, 7)의 여섯 조합을 사진마다 적용해, 덩어리 수, 개수가 맞는지, 맞힌 칸 비율을 표로 찍습니다.
  3. 두 사진 모두 개수가 맞는 조합 가운데 평균 맞힌 칸 비율이 가장 높은 조합을 골라 찍습니다.
  4. 고른 조합으로 다듬은 두 마스크를 가로로 이어 out_ch09_cleaned.png 로 저장합니다.
  5. 줄은 사진, 칸은 원본·이진화·다듬은 결과·정답인 격자 그림을 out_ch09_compare.png 로 저장하고 화면에 띄웁니다.

힌트

  1. 정답 부품 수는 정답 마스크의 덩어리 수입니다. cv2.connectedComponents(mask)[0] - 1 로 셉니다.
  2. 부품이 대부분 둥글어서 cv2.MORPH_ELLIPSE 도장을 씁니다. 크기 k 의 도장은 cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)) 입니다.
  3. "순서, 크기"를 받아 다듬은 마스크를 돌려주는 함수를 하나 만들면 여섯 조합을 반복문 두 개로 돌릴 수 있습니다.
  4. 표는 pandas 의 DataFrame 으로 만들고, groupby(["순서", "크기"]) 로 조합마다 "두 사진 모두 맞았나"(all)와 평균 맞힌 칸 비율(mean)을 냅니다.
  5. 두 그림을 가로로 잇는 것은 cv2.hconcat([a, b])(horizontal concatenate, 가로로 잇기)입니다.

채점 기준

항목기준점수
이진화부품이 흰색이 되게 Otsu 로 이진화하고 네 값을 찍는다15
조합 표여섯 조합을 두 사진에 적용해 덩어리 수와 맞힌 칸 비율을 표로 찍는다30
고르기개수가 맞는 조합 가운데 평균이 가장 높은 것을 고른다20
저장다듬은 마스크를 out_ch09_cleaned.png 로 저장한다15
격자 그림원본·이진화·다듬은 결과·정답을 격자로 저장하고 띄운다20

코드의 흐름

이진화, 조합 만들기, 견주기, 고르기, 저장의 다섯 단계를 이은 흐름도

그림의 다섯 단계를 예시 풀이의 단계와 같은 순서로 짰습니다. 셋째 단계에서 맞힌 칸 비율만 보지 않고 덩어리 수를 함께 보는 까닭은, 금 하나가 부품을 두 조각으로 나눠도 칸으로는 수십 칸밖에 차이 나지 않기 때문입니다. "부품이 몇 개인가"를 세는 수업이라면 개수가 맞는 것이 먼저입니다.

실제 데이터로 해 보려면

실제 부품이나 제품 사진으로 해 보려면 AI Hub(aihub.or.kr)처럼 제조 공정·외관 검사 이미지를 공개한 곳에서 내려받습니다. 내려받기 전에 데이터마다 이용 약관(상업적 이용 가능 여부, 재배포 금지 등)을 확인합니다. 작업장 사진에는 작업자의 얼굴이나 이름표가 찍혀 있을 수 있으므로, 그런 사진은 빼거나 그 부분을 가린 뒤 씁니다. 실제 사진에는 정답 마스크가 없을 때가 많으므로, 몇 장만 골라 사람이 부품 자리를 칠해 정답을 만들거나, 부품 개수만 세어 덩어리 수와 견줍니다.

결과 보기 (예시 풀이)

1단계. 사진 읽고 Otsu 로 이진화하기

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

def blobs(binary):
    """흰 덩어리 수"""
    return cv2.connectedComponents(binary)[0] - 1

def accuracy(out, truth):
    """맞힌 칸 비율: 정답과 같은 칸의 비율"""
    return round(float((out == truth).mean()), 3)

names = ["tray_1", "tray_2"]
images, truths, binaries = {}, {}, {}
for name in names:
    images[name] = cv2.imread(f"ch09_inspect/{name}.png", cv2.IMREAD_GRAYSCALE)
    truths[name] = cv2.imread(f"ch09_inspect/{name}_mask.png", cv2.IMREAD_GRAYSCALE)
    # 요구사항 1: 어두운 부품이 흰색이 되도록 _INV 와 Otsu 를 함께 쓴다
    t, binaries[name] = cv2.threshold(images[name], 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    print(f"{name}: Otsu {int(t)}, 정답 부품 {blobs(truths[name])}개, "
          f"이진화 덩어리 {blobs(binaries[name])}개, 맞힌 칸 비율 {accuracy(binaries[name], truths[name])}")
출력
tray_1: Otsu 134, 정답 부품 6개, 이진화 덩어리 34개, 맞힌 칸 비율 0.973
tray_2: Otsu 134, 정답 부품 7개, 이진화 덩어리 33개, 맞힌 칸 비율 0.97

2단계. 여섯 조합을 적용해 표 만들기

Python
def clean_mask(binary, order, size):
    """order 순서대로 열기·닫기를 크기 size 의 타원 도장으로 건다."""
    se = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (size, size))
    if order == "열기 → 닫기":
        first, second = cv2.MORPH_OPEN, cv2.MORPH_CLOSE
    else:
        first, second = cv2.MORPH_CLOSE, cv2.MORPH_OPEN
    return cv2.morphologyEx(cv2.morphologyEx(binary, first, se), second, se)

orders = ["열기 → 닫기", "닫기 → 열기"]
sizes = [3, 5, 7]
rows, cleaned = [], {}
for name in names:
    answer = blobs(truths[name])
    for order in orders:
        for size in sizes:
            out = clean_mask(binaries[name], order, size)
            cleaned[(name, order, size)] = out
            # 요구사항 2: 덩어리 수, 개수가 맞는지, 맞힌 칸 비율
            rows.append({"사진": name, "순서": order, "크기": size, "덩어리": blobs(out),
                         "개수 맞음": blobs(out) == answer,
                         "맞힌 칸 비율": accuracy(out, truths[name])})

report = pd.DataFrame(rows)
print(report.to_string(index=False))
출력
    사진      순서  크기  덩어리  개수 맞음  맞힌 칸 비율
tray_1 열기 → 닫기   3   32  False    0.973
tray_1 열기 → 닫기   5    6   True    0.984
tray_1 열기 → 닫기   7    6   True    0.982
tray_1 닫기 → 열기   3   32  False    0.973
tray_1 닫기 → 열기   5    6   True    0.983
tray_1 닫기 → 열기   7    6   True    0.987
tray_2 열기 → 닫기   3   32  False    0.970
tray_2 열기 → 닫기   5    7   True    0.981
tray_2 열기 → 닫기   7    7   True    0.982
tray_2 닫기 → 열기   3   32  False    0.970
tray_2 닫기 → 열기   5    7   True    0.981
tray_2 닫기 → 열기   7    7   True    0.987

3단계. 가장 좋은 조합 고르기

Python
# 요구사항 3: 조합마다 두 사진 모두 맞았는지(all)와 평균 맞힌 칸 비율(mean)
summary = report.groupby(["순서", "크기"]).agg(모두_맞음=("개수 맞음", "all"),
                                              평균_비율=("맞힌 칸 비율", "mean")).reset_index()
summary["평균_비율"] = summary["평균_비율"].round(4)
print(summary.to_string(index=False))

ok = summary[summary["모두_맞음"]]
best = ok.loc[ok["평균_비율"].idxmax()]
best_order, best_size = best["순서"], int(best["크기"])
print()
print(f"고른 조합: {best_order}, 타원 {best_size}×{best_size}, 평균 맞힌 칸 비율 {float(best['평균_비율'])}")

# 요구사항 4: 다듬은 두 마스크를 가로로 이어 저장한다
final = {name: cleaned[(name, best_order, best_size)] for name in names}
cv2.imwrite("out_ch09_cleaned.png", cv2.hconcat([final[name] for name in names]))
print("저장: out_ch09_cleaned.png")
출력
     순서  크기  모두_맞음  평균_비율
닫기 → 열기   3  False 0.9715
닫기 → 열기   5   True 0.9820
닫기 → 열기   7   True 0.9870
열기 → 닫기   3  False 0.9715
열기 → 닫기   5   True 0.9825
열기 → 닫기   7   True 0.9820

고른 조합: 닫기 → 열기, 타원 7×7, 평균 맞힌 칸 비율 0.987
저장: out_ch09_cleaned.png

4단계. 전후 격자 그림 저장하기

Python
fig, axes = plt.subplots(len(names), 4, figsize=(12, 5.6))
for i, name in enumerate(names):
    panels = [("원본", images[name]), (f"이진화 (덩어리 {blobs(binaries[name])})", binaries[name]),
              (f"다듬은 결과 (덩어리 {blobs(final[name])})", final[name]),
              (f"정답 (부품 {blobs(truths[name])})", truths[name])]
    for j, (title, im) in enumerate(panels):
        axes[i, j].imshow(im, cmap="gray", vmin=0, vmax=255)
        axes[i, j].set_title(title)
        axes[i, j].set_xticks([])
        axes[i, j].set_yticks([])
    axes[i, 0].set_ylabel(name)
plt.tight_layout()
# 요구사항 5: 저장을 먼저 하고 띄운다
plt.savefig("out_ch09_compare.png", dpi=100)
print("저장: out_ch09_compare.png")
plt.show()
출력
저장: out_ch09_compare.png
검사 사진 두 장의 원본, Otsu 이진화, 다듬은 결과, 정답을 2행 4열로 놓은 격자

표를 보면 이진화 그대로는 덩어리가 34개와 33개로, 정답 부품 수(6개, 7개)의 다섯 배 가까이 세어집니다. 작업대의 먼지가 흰 잡티로 남았기 때문입니다. 타원 3×3 도장은 순서와 관계없이 덩어리가 32개로 거의 줄지 않았습니다. 3×3 타원은 십자와 같은 모양이라 반지름 1~2 의 먼지를 거의 지우지 못합니다. 크기 5 와 7 에서는 네 조합 모두 두 사진의 개수가 맞았습니다. 열기가 먼지를 지우고, 닫기가 반사광 구멍과 부품을 가르던 금을 메웠기 때문입니다. 개수가 맞는 조합 가운데 평균 맞힌 칸 비율은 닫기 → 열기, 타원 7×7 이 0.987 로 가장 높아 이 조합을 골랐습니다. 같은 크기 7 이라도 열기 → 닫기는 0.982 로 낮았는데, 열기의 침식이 반사광 구멍 둘레를 먼저 깎아 넓혀 놓아 뒤의 닫기가 다 메우지 못했기 때문입니다. 격자의 다듬은 결과를 보면 와셔의 가운데 구멍은 7×7 도장보다 커서 메워지지 않고 남았고, tray_2 의 두 네모 부품 사이 틈도 붙지 않았습니다. 맞힌 칸 비율이 1.0 이 되지 않는 것은 사진의 부품 가장자리가 부드럽게(안티에일리어싱) 그려져 정답 마스크와 테두리 한 칸쯤 어긋나고, 7×7 타원이 네모 부품의 귀퉁이를 조금 둥글게 깎았기 때문입니다. 부품 수를 세는 수업이라면 이 조합으로 다듬은 마스크를 12장의 윤곽선 찾기에 넘기면 됩니다.

← 이전 절9장 · 실습 5 — 순서 비교와 그레이디언트로 테두리 보기다음 절 →9장 · 정리