20장. 교육 콘텐츠 설계와 종합 실습
실습 3 — 자동 채점기 만들기
실습 2의 보통 판을 문제로 내고, 수강생이 낼 법한 답 네 가지(잘한 답, 잡티 많은 답, 반만 찾은 답, 넘치게 칠한 답)를 흉내 내 만듭니다. 정답 마스크와 견줘 점수를 매기고, 점수에 맞는 피드백 문구를 돌려주는 채점기를 만듭니다. 실습 2를 먼저 실행해야 합니다.
1단계. 문제, 정답, 흉내 낸 답 네 개 준비하기
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.1622단계. 채점 함수 만들기
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단계. 피드백 문구 붙여 채점표 만들기
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단계. 틀린 칸을 색으로 보이기
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)은 두 비율이 서로 반대쪽의 실수를 잡습니다.