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

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

실습 3 — 자동 채점기 만들기

실습 2의 보통 판을 문제로 내고, 수강생이 낼 법한 답 네 가지(잘한 답, 잡티 많은 답, 반만 찾은 답, 넘치게 칠한 답)를 흉내 내 만듭니다. 정답 마스크와 견줘 점수를 매기고, 점수에 맞는 피드백 문구를 돌려주는 채점기를 만듭니다. 실습 2를 먼저 실행해야 합니다.

1단계. 문제, 정답, 흉내 낸 답 네 개 준비하기

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

# 실습 2가 저장한 보통 판의 문제와 정답 마스크
img = cv2.imread("out_ch20_exercise/medium.png", cv2.IMREAD_GRAYSCALE)
answer = cv2.imread("out_ch20_exercise/medium_mask.png", cv2.IMREAD_GRAYSCALE)
if img is None or answer is None:
    raise SystemExit("out_ch20_exercise 가 없습니다. 실습 2를 먼저 실행하세요")
answer_count = int(pd.read_csv("out_ch20_exercise/answer.csv", encoding="utf-8-sig")
                   .set_index("난이도").loc["보통", "정답 개수"])

# 답 1: 잘한 답(흐리게 한 뒤 Otsu)
blur = cv2.GaussianBlur(img, (5, 5), 0)
_, good = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# 답 2: 잡티 많은 답(흐리게 하지 않고 기준값 127 로 바로 이진화)
_, noisy = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)
# 답 3: 반만 찾은 답(오른쪽 절반을 지운 답)
half = good.copy()
half[:, 160:] = 0
# 답 4: 넘치게 칠한 답(팽창을 두 번 걸어 물체보다 크게)
fat = cv2.dilate(good, np.ones((7, 7), np.uint8), iterations=2)

answers = {"잘한 답": good, "잡티 많은 답": noisy, "반만 찾은 답": half, "넘치게 칠한 답": fat}
print("정답 개수:", answer_count, "/ 정답 칸 비율:", round(float((answer > 0).mean()), 3))
for name, a in answers.items():
    print(f"{name:8} 흰 칸 비율 {float((a > 0).mean()):.3f}")
출력
정답 개수: 7 / 정답 칸 비율: 0.081
잘한 답     흰 칸 비율 0.081
잡티 많은 답  흰 칸 비율 0.081
반만 찾은 답  흰 칸 비율 0.053
넘치게 칠한 답 흰 칸 비율 0.162

2단계. 채점 함수 만들기

Python
def grade(student, answer, answer_count, min_area=30):
    """학생 답 마스크를 정답과 견줘 점수 사전을 돌려준다."""
    s, a = student > 0, answer > 0
    inter, union = int((s & a).sum()), int((s | a).sum())
    # 12차시처럼 바깥 윤곽선만 찾고, 넓이 min_area 이상은 물체, 그보다 작으면 잡티로 센다
    contours, _ = cv2.findContours(student, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    areas = [cv2.contourArea(c) for c in contours]
    return {
        "IoU": round(inter / union, 3) if union else 0.0,
        "정확도": round(float((s == a).mean()), 3),
        "찾은 비율": round(inter / int(a.sum()), 3),
        "맞은 비율": round(inter / int(s.sum()), 3) if s.any() else 0.0,
        "물체 수": sum(1 for x in areas if x >= min_area),
        "잡티 수": sum(1 for x in areas if x < min_area),
        "정답 수": answer_count,
    }

# 먼저 정답을 정답과 견줘 채점기가 만점을 주는지 시험한다
print("정답 자신:", grade(answer, answer, answer_count))
출력
정답 자신: {'IoU': 1.0, '정확도': 1.0, '찾은 비율': 1.0, '맞은 비율': 1.0, '물체 수': 7, '잡티 수': 0, '정답 수': 7}

3단계. 피드백 문구 붙여 채점표 만들기

Python
def feedback(score):
    """점수를 보고 다음에 할 일을 한 문장으로 돌려준다. 위에서부터 먼저 맞는 규칙을 쓴다."""
    if score["잡티 수"] > 5:
        return f"작은 잡티가 {score['잡티 수']}개 있습니다. 이진화 전에 흐림 필터(6차시)나 뒤에 열기(9차시)를 걸어 보세요."
    if score["IoU"] >= 0.9:
        return "잘했습니다. 어려움 판에도 도전해 보세요."
    if score["찾은 비율"] < 0.8:
        return f"놓친 물체가 있습니다({score['물체 수']}/{score['정답 수']}). 그림 전체에 같은 처리를 했는지 보세요."
    if score["맞은 비율"] < 0.8:
        return "물체보다 넓게 칠했습니다. 팽창을 줄이거나 침식(9차시)을 걸어 보세요."
    return "조금만 더 다듬어 보세요. 기준값을 바꿔 IoU 를 견줘 보세요."

rows = []
for name, a in answers.items():
    score = grade(a, answer, answer_count)
    rows.append({"답": name, **score, "피드백": feedback(score)})
table = pd.DataFrame(rows)
print(table.drop(columns=["피드백", "정답 수"]).to_string(index=False))
for r in rows:
    print(f"- {r['답']}: {r['피드백']}")
table.to_csv("out_ch20_grades.csv", index=False, encoding="utf-8-sig")
출력
       답   IoU   정확도  찾은 비율  맞은 비율  물체 수  잡티 수
    잘한 답 0.973 0.998  0.985  0.987     7     0
 잡티 많은 답 0.993 0.999  0.997  0.996     7    25
 반만 찾은 답 0.654 0.972  0.654  1.000     5     0
넘치게 칠한 답 0.500 0.919  1.000  0.500     5     0
- 잘한 답: 잘했습니다. 어려움 판에도 도전해 보세요.
- 잡티 많은 답: 작은 잡티가 25개 있습니다. 이진화 전에 흐림 필터(6차시)나 뒤에 열기(9차시)를 걸어 보세요.
- 반만 찾은 답: 놓친 물체가 있습니다(5/7). 그림 전체에 같은 처리를 했는지 보세요.
- 넘치게 칠한 답: 물체보다 넓게 칠했습니다. 팽창을 줄이거나 침식(9차시)을 걸어 보세요.

4단계. 틀린 칸을 색으로 보이기

Python
def error_view(student, answer):
    """맞힌 물체는 흰색, 빠뜨린 칸은 빨강, 넘친 칸은 파랑으로 칠한 RGB 그림."""
    s, a = student > 0, answer > 0
    view = np.zeros(answer.shape + (3,), np.uint8)
    view[s & a] = (255, 255, 255)     # 맞힌 칸
    view[a & ~s] = (255, 0, 0)        # 정답인데 칠하지 않은 칸(빠뜨림). ~ 는 not(아님)
    view[s & ~a] = (0, 0, 255)        # 정답이 아닌데 칠한 칸(넘침)
    return view

fig, axes = plt.subplots(1, 4, figsize=(13, 3.2))
for ax, r in zip(axes, rows):
    ax.imshow(error_view(answers[r["답"]], answer))   # RGB 순서로 만들었으므로 바꾸지 않는다
    ax.set_title(f"{r['답']}\nIoU {r['IoU']}, 잡티 {r['잡티 수']}")
    ax.axis("off")
plt.tight_layout()
plt.show()
네 가지 답의 맞힌 칸을 흰색, 빠뜨린 칸을 빨강, 넘친 칸을 파랑으로 칠한 비교 그림

코드 설명

  • grade 는 IoU, 정확도, 찾은 비율, 맞은 비율, 물체 수, 잡티 수를 한 번에 돌려줍니다. 점수 하나만 보면 놓치는 답이 있어서 여럿을 함께 봅니다.
  • 채점기를 만들면 먼저 정답을 정답과 견줘 봅니다. 만점(IoU 1.0)이 나오지 않으면 채점기가 틀린 것입니다.
  • feedback 은 위에서부터 차례로 규칙을 봅니다. 규칙의 차례가 곧 "무엇을 먼저 고칠지"의 차례입니다.
  • error_view 의 빨강은 빠뜨린 칸, 파랑은 넘친 칸입니다. 그림을 RGB 순서로 직접 만들었으므로 plt.imshow 에 그대로 넣습니다.
  • 잡티 많은 답의 IoU(0.993)는 잘한 답(0.973)보다 높지만, 넓이 30 미만의 잡티가 25개 있습니다. 잡티 하나하나가 몇 칸뿐이라 칸 수로는 거의 티가 나지 않기 때문입니다. 그래서 feedback 은 잡티 규칙을 IoU 규칙보다 먼저 봅니다. 반만 찾은 답(찾은 비율 0.654, 맞은 비율 1.000)과 넘치게 칠한 답(찾은 비율 1.000, 맞은 비율 0.500)은 두 비율이 서로 반대쪽의 실수를 잡습니다.
← 이전 절20장 · 실습 2 — 난이도 손잡이가 있는 연습 그림 생성기다음 절 →20장 · 실습 4 — 전후 비교 격자와 한 장 요약 만들기