9장. Morphology: 침식, 팽창, 열기, 닫기
프로젝트 1 — 부품 검사 사진의 이진 마스크 다듬기
강사가 "부품 개수 세기" 수업을 준비하며 작업대 위의 부품을 찍은 가상의 검사 사진 두 장을 만들었습니다. 8장처럼 Otsu 로 이진화했더니 부품 수가 엉뚱하게 세어집니다. 작업대의 먼지가 흰 잡티로, 부품 위의 반사광이 검은 구멍으로, 부품을 가로지르는 가는 금이 부품을 두 조각으로 나누었기 때문입니다. 열기와 닫기를 조합해 마스크를 다듬고, 덩어리 수가 정답 부품 수와 맞는지, 맞힌 칸 비율이 얼마인지 견줘 가장 좋은 조합을 골라 보세요.
데이터는 ch09_inspect/ 에 있습니다. 부품, 먼지, 반사광, 금은 모두 OpenCV 로 그린 가상의 그림이고, 실제 제품이나 회사와 관계없습니다.
| 파일 | 내용 |
|---|---|
tray_1.png | 동전 모양 부품 셋, 네모 부품 둘, 와셔 하나를 찍은 검사 사진 (240×180, 흑백) |
tray_2.png | 네모 부품 셋, 동전 모양 부품 둘, 와셔 둘을 찍은 검사 사진 |
tray_N_mask.png | 사진 N 의 정답 마스크. 그림을 만들 때 부품 자리를 따로 저장했다. 부품 칸 255, 바탕 0 |
요구사항
- 사진 두 장과 정답 마스크를 흑백으로 읽습니다. 부품이 흰색이 되도록
THRESH_BINARY_INV + THRESH_OTSU로 이진화하고, 사진마다 Otsu 가 고른 값, 정답 부품 수, 이진화 결과의 덩어리 수, 맞힌 칸 비율을 찍습니다. - 순서 두 가지(열기 → 닫기, 닫기 → 열기)와 타원 도장 크기 세 가지(3, 5, 7)의 여섯 조합을 사진마다 적용해, 덩어리 수, 개수가 맞는지, 맞힌 칸 비율을 표로 찍습니다.
- 두 사진 모두 개수가 맞는 조합 가운데 평균 맞힌 칸 비율이 가장 높은 조합을 골라 찍습니다.
- 고른 조합으로 다듬은 두 마스크를 가로로 이어
out_ch09_cleaned.png로 저장합니다. - 줄은 사진, 칸은 원본·이진화·다듬은 결과·정답인 격자 그림을
out_ch09_compare.png로 저장하고 화면에 띄웁니다.
힌트
- 정답 부품 수는 정답 마스크의 덩어리 수입니다.
cv2.connectedComponents(mask)[0] - 1로 셉니다. - 부품이 대부분 둥글어서
cv2.MORPH_ELLIPSE도장을 씁니다. 크기 k 의 도장은cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))입니다. - "순서, 크기"를 받아 다듬은 마스크를 돌려주는 함수를 하나 만들면 여섯 조합을 반복문 두 개로 돌릴 수 있습니다.
- 표는 pandas 의
DataFrame으로 만들고,groupby(["순서", "크기"])로 조합마다 "두 사진 모두 맞았나"(all)와 평균 맞힌 칸 비율(mean)을 냅니다. - 두 그림을 가로로 잇는 것은
cv2.hconcat([a, b])(horizontal concatenate, 가로로 잇기)입니다.
채점 기준
| 항목 | 기준 | 점수 |
|---|---|---|
| 이진화 | 부품이 흰색이 되게 Otsu 로 이진화하고 네 값을 찍는다 | 15 |
| 조합 표 | 여섯 조합을 두 사진에 적용해 덩어리 수와 맞힌 칸 비율을 표로 찍는다 | 30 |
| 고르기 | 개수가 맞는 조합 가운데 평균이 가장 높은 것을 고른다 | 20 |
| 저장 | 다듬은 마스크를 out_ch09_cleaned.png 로 저장한다 | 15 |
| 격자 그림 | 원본·이진화·다듬은 결과·정답을 격자로 저장하고 띄운다 | 20 |
코드의 흐름
그림의 다섯 단계를 예시 풀이의 단계와 같은 순서로 짰습니다. 셋째 단계에서 맞힌 칸 비율만 보지 않고 덩어리 수를 함께 보는 까닭은, 금 하나가 부품을 두 조각으로 나눠도 칸으로는 수십 칸밖에 차이 나지 않기 때문입니다. "부품이 몇 개인가"를 세는 수업이라면 개수가 맞는 것이 먼저입니다.
실제 데이터로 해 보려면
실제 부품이나 제품 사진으로 해 보려면 AI Hub(aihub.or.kr)처럼 제조 공정·외관 검사 이미지를 공개한 곳에서 내려받습니다. 내려받기 전에 데이터마다 이용 약관(상업적 이용 가능 여부, 재배포 금지 등)을 확인합니다. 작업장 사진에는 작업자의 얼굴이나 이름표가 찍혀 있을 수 있으므로, 그런 사진은 빼거나 그 부분을 가린 뒤 씁니다. 실제 사진에는 정답 마스크가 없을 때가 많으므로, 몇 장만 골라 사람이 부품 자리를 칠해 정답을 만들거나, 부품 개수만 세어 덩어리 수와 견줍니다.
결과 보기 (예시 풀이)
1단계. 사진 읽고 Otsu 로 이진화하기
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.972단계. 여섯 조합을 적용해 표 만들기
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.9873단계. 가장 좋은 조합 고르기
# 요구사항 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.png4단계. 전후 격자 그림 저장하기
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
표를 보면 이진화 그대로는 덩어리가 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장의 윤곽선 찾기에 넘기면 됩니다.