20장. 교육 콘텐츠 설계와 종합 실습
실습 2 — 난이도 손잡이가 있는 연습 그림 생성기
함수 하나로 쉬움·보통·어려움 세 판의 연습 그림, 정답 마스크, 정답 개수를 만듭니다. 같은 씨앗이면 같은 그림이 나오는지 확인하고, 모범 풀이를 세 판에 돌려 난이도가 실제로 올라가는지 잰 뒤 out_ch20_exercise/ 에 저장합니다.
1단계. 손잡이 표와 생성 함수 만들기
import os
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
# 난이도마다 네 손잡이: 동전 수, 반지름 범위, 노이즈 표준편차, 조명 차(왼쪽 끝과 오른쪽 끝의 밝기 차)
LEVELS = {
"쉬움": {"count": 4, "radius": (22, 28), "noise": 0, "light": 0},
"보통": {"count": 7, "radius": (14, 22), "noise": 15, "light": 60},
"어려움": {"count": 10, "radius": (9, 16), "noise": 30, "light": 120},
}
def make_exercise(level, seed):
"""흑백 연습 그림, 정답 마스크, 정답 개수를 돌려준다. 같은 씨앗이면 같은 결과다."""
knob = LEVELS[level]
rng = np.random.default_rng(seed)
img = np.full((240, 320), 60.0) # 밝기 60 의 바탕(계산하기 쉽게 소수로)
mask = np.zeros((240, 320), np.uint8) # 정답 마스크
placed = [] # 그린 동전의 (x, y, 반지름)
tries = 0
while len(placed) < knob["count"] and tries < 500:
tries += 1
r = int(rng.integers(knob["radius"][0], knob["radius"][1] + 1))
x = int(rng.integers(r + 4, 320 - r - 4)) # 그림 밖으로 나가지 않게 테두리에서 띄운다
y = int(rng.integers(r + 4, 240 - r - 4))
# 이미 그린 동전과 6칸 넘게 떨어져 있을 때만 그린다(붙으면 개수가 헷갈린다)
if all((x - px) ** 2 + (y - py) ** 2 > (r + pr + 6) ** 2 for px, py, pr in placed):
placed.append((x, y, r))
cv2.circle(mask, (x, y), r, 255, -1)
img[mask > 0] = 190 # 정답 자리를 밝게 칠한다
# 조명: 가로 320칸에 걸쳐 -light/2 부터 +light/2 까지 고르게 더한다(왼쪽 어둡게, 오른쪽 밝게)
img += np.linspace(-knob["light"] / 2, knob["light"] / 2, 320)
if knob["noise"] > 0:
img += rng.normal(0, knob["noise"], img.shape) # 6차시의 가우시안 노이즈
return np.clip(img, 0, 255).astype(np.uint8), mask, len(placed)
print(pd.DataFrame.from_dict(LEVELS, orient="index")) count radius noise light
쉬움 4 (22, 28) 0 0
보통 7 (14, 22) 15 60
어려움 10 (9, 16) 30 1202단계. 세 판 만들고 같은 씨앗 확인하기
sets = {}
for level in LEVELS:
img, mask, count = make_exercise(level, 2001)
sets[level] = (img, mask, count)
print(f"{level:4} 동전 {count:2}개, 밝기 평균 {float(img.mean()):5.1f}, "
f"정답 칸 비율 {float((mask > 0).mean()):.3f}")
# 같은 씨앗으로 한 번 더 만들어 칸마다 같은지 본다
again, _, _ = make_exercise("보통", 2001)
other, _, _ = make_exercise("보통", 2002)
print("씨앗 2001 두 번:", np.array_equal(sets["보통"][0], again))
print("씨앗 2001 과 2002:", np.array_equal(sets["보통"][0], other))쉬움 동전 4개, 밝기 평균 71.8, 정답 칸 비율 0.091
보통 동전 7개, 밝기 평균 70.1, 정답 칸 비율 0.081
어려움 동전 10개, 밝기 평균 69.0, 정답 칸 비율 0.059
씨앗 2001 두 번: True
씨앗 2001 과 2002: False3단계. 세 판을 한눈에 보기
fig, axes = plt.subplots(2, 3, figsize=(11, 6))
for col, level in enumerate(LEVELS):
img, mask, count = sets[level]
# vmin, vmax 를 주면 세 그림의 밝기 눈금이 같아져 공정하게 견줄 수 있다
axes[0, col].imshow(img, cmap="gray", vmin=0, vmax=255)
axes[0, col].set_title(f"{level} (동전 {count}개)")
axes[1, col].imshow(mask, cmap="gray")
axes[1, col].set_title("정답 마스크")
for ax in axes.flat:
ax.axis("off")
plt.tight_layout()
plt.show()
4단계. 모범 풀이로 난이도 재고 저장하기
def model_answer(img):
"""이 차시의 모범 풀이: Gaussian 으로 노이즈를 줄이고 Otsu 로 이진화한다."""
blur = cv2.GaussianBlur(img, (5, 5), 0)
_, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
return binary
for level in LEVELS:
img, mask, count = sets[level]
ans = model_answer(img)
inter = int(((ans > 0) & (mask > 0)).sum())
union = int(((ans > 0) | (mask > 0)).sum())
print(f"{level:4} 모범 풀이의 IoU: {inter / union:.3f}")
# 파일 이름은 사전에 글자 그대로 적어 둔다(어느 판이 어느 파일인지 한눈에 보이게)
FILES = {
"쉬움": ("out_ch20_exercise/easy.png", "out_ch20_exercise/easy_mask.png"),
"보통": ("out_ch20_exercise/medium.png", "out_ch20_exercise/medium_mask.png"),
"어려움": ("out_ch20_exercise/hard.png", "out_ch20_exercise/hard_mask.png"),
}
os.makedirs("out_ch20_exercise", exist_ok=True)
rows = []
for level, (img_path, mask_path) in FILES.items():
img, mask, count = sets[level]
cv2.imwrite(img_path, img)
cv2.imwrite(mask_path, mask)
rows.append({"난이도": level, "그림": img_path, "정답 마스크": mask_path, "정답 개수": count, "씨앗": 2001})
pd.DataFrame(rows).to_csv("out_ch20_exercise/answer.csv", index=False, encoding="utf-8-sig")
print("저장한 파일:", sorted(os.listdir("out_ch20_exercise")))쉬움 모범 풀이의 IoU: 0.998
보통 모범 풀이의 IoU: 0.973
어려움 모범 풀이의 IoU: 0.144
저장한 파일: ['answer.csv', 'easy.png', 'easy_mask.png', 'hard.png', 'hard_mask.png', 'medium.png', 'medium_mask.png']코드 설명
LEVELS사전이 난이도의 손잡이입니다. 판을 하나 더하거나 노이즈를 바꾸고 싶으면 함수는 그대로 두고 사전의 숫자만 고칩니다.np.random.default_rng(seed)하나로 자리, 반지름, 노이즈를 모두 뽑습니다. 그래서 씨앗만 같으면 그림 전체가 칸마다 같습니다.np.linspace(-30, 30, 320)은 −30 부터 +30 까지를 320칸에 고르게 나눈 줄입니다. (240, 320) 그림에 더하면 모든 줄에 같은 줄이 더해져 왼쪽이 어둡고 오른쪽이 밝아집니다.- 정답 마스크는 그림을 그릴 때의 좌표로 같은 원을 칠해 만듭니다. 그림을 보고 다시 찾지 않으므로 정답에 실수가 없습니다.
- 같은 모범 풀이(Gaussian + Otsu)의 IoU 가 쉬움 0.998, 보통 0.973, 어려움 0.144 로 떨어졌습니다. 어려움 판은 조명 차 120 때문에 Otsu 의 기준값이 낮게 잡혀 오른쪽의 밝은 바탕까지 흰색이 됩니다. 8장의 Adaptive Threshold 같은 다른 방법이 있어야 풀리는 판이라는 것을 숫자로 확인한 셈입니다.