OpenCV 비전 실무20장 · 교육 콘텐츠 설계와 종합 실습15 / 21← 이전목차다음 →MetaContents

20장. 교육 콘텐츠 설계와 종합 실습

실습 2 — 난이도 손잡이가 있는 연습 그림 생성기

함수 하나로 쉬움·보통·어려움 세 판의 연습 그림, 정답 마스크, 정답 개수를 만듭니다. 같은 씨앗이면 같은 그림이 나오는지 확인하고, 모범 풀이를 세 판에 돌려 난이도가 실제로 올라가는지 잰 뒤 out_ch20_exercise/ 에 저장합니다.

1단계. 손잡이 표와 생성 함수 만들기

Python
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    120

2단계. 세 판 만들고 같은 씨앗 확인하기

Python
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: False

3단계. 세 판을 한눈에 보기

Python
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()
쉬움, 보통, 어려움 세 판의 연습 그림과 정답 마스크를 2행 3열로 놓은 결과

4단계. 모범 풀이로 난이도 재고 저장하기

Python
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 같은 다른 방법이 있어야 풀리는 판이라는 것을 숫자로 확인한 셈입니다.
← 이전 절20장 · 실습 1 — 수업 계획표 만들기다음 절 →20장 · 실습 3 — 자동 채점기 만들기