18장. OpenCV 전처리와 YOLO 연계
실습 4 — IoU 와 정밀도·재현율 계산기
IoU 함수와, 찾은 상자를 정답과 짝지어 맞힌 것·헛것·놓친 것을 세는 함수를 만듭니다. 먼저 YOLO 없이 숫자 상자로 손계산과 맞는지 확인한 뒤, 학습한 모델의 검증용 사진 결과에 적용해 정밀도, 재현율, F1 을 냅니다. 3단계부터는 실습 2를 먼저 실행해야 합니다.
1단계. IoU 함수를 손계산과 견주기
def iou(a, b):
"""두 상자 (x1, y1, x2, y2) 의 IoU = 겹친 넓이 ÷ 합친 넓이."""
w = min(a[2], b[2]) - max(a[0], b[0]) # 겹친 너비: 작은 오른쪽 끝 - 큰 왼쪽 끝
h = min(a[3], b[3]) - max(a[1], b[1]) # 겹친 높이: 작은 아래 끝 - 큰 위 끝
inter = max(0, w) * max(0, h) # 안 겹치면 음수가 되므로 0 으로 막는다
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
return inter / union if union > 0 else 0.0
gt = (40, 40, 140, 120)
cases = {"본문 왼쪽 그림": (60, 50, 160, 130), # 손계산: 5600 ÷ 10400 = 0.538
"본문 오른쪽 그림": (100, 80, 200, 160), # 손계산: 1600 ÷ 14400 = 0.111
"똑같은 상자": (40, 40, 140, 120), # 손계산: 1
"떨어진 상자": (200, 150, 250, 200)} # 손계산: 0
for name, box in cases.items():
print(f"{name:10} IoU {round(iou(gt, box), 3)}")본문 왼쪽 그림 IoU 0.538
본문 오른쪽 그림 IoU 0.111
똑같은 상자 IoU 1.0
떨어진 상자 IoU 0.02단계. 짝짓기 함수를 작은 예로 확인하기
def match(preds, gts, thr=0.5):
"""찾은 상자를 신뢰도 높은 것부터 같은 클래스의 정답과 짝짓고 (맞힌 것, 헛것, 놓친 것) 개수를 돌려준다."""
used = set() # 이미 짝이 생긴 정답의 번호
tp = 0
for c, conf, box in sorted(preds, key=lambda x: -x[1]): # 신뢰도가 높은 것부터
best, best_j = 0.0, None
for j, (gc, gbox) in enumerate(gts):
if j in used or gc != c: # 짝이 있거나 클래스가 다르면 건너뛴다
continue
v = iou(box, gbox)
if v > best:
best, best_j = v, j
if best >= thr: # IoU 0.5 이상이면 맞힌 것
used.add(best_j)
tp += 1
return tp, len(preds) - tp, len(gts) - tp
# 정답 셋, 찾은 상자 넷(지어낸 예)
gts = [(1, (40, 40, 140, 120)), (2, (200, 30, 260, 90)), (0, (150, 150, 300, 200))]
preds = [(1, 0.9, (60, 50, 160, 130)), # 정답 0번과 IoU 0.538 → 맞힘
(1, 0.6, (45, 42, 138, 118)), # 정답 0번은 이미 짝이 있다 → 헛것
(2, 0.8, (205, 35, 262, 92)), # 정답 1번과 많이 겹친다 → 맞힘
(1, 0.4, (150, 150, 300, 200))] # 자리는 정답 2번인데 클래스가 다르다 → 헛것
tp, fp, fn = match(preds, gts)
precision, recall = tp / (tp + fp), tp / (tp + fn)
print("맞힌 것", tp, "· 헛것", fp, "· 놓친 것", fn)
print("정밀도", round(precision, 3), "· 재현율", round(recall, 3))맞힌 것 2 · 헛것 2 · 놓친 것 1
정밀도 0.5 · 재현율 0.6673단계. 학습한 모델의 결과에 적용하기
import os
from pathlib import Path
os.environ["YOLO_VERBOSE"] = "False"
from ultralytics import YOLO
import cv2
model = YOLO("out_ch18_runs/parts/weights/best.pt")
root = Path("out_ch17_yolo")
def read_labels(label_path, w, h):
"""YOLO 라벨을 [(클래스번호, (x1, y1, x2, y2)), ...] 픽셀 상자로 읽는다."""
boxes = []
for line in label_path.read_text(encoding="utf-8").split("\n"):
if line.strip():
c, xc, yc, bw, bh = line.split()
xc, yc, bw, bh = float(xc), float(yc), float(bw), float(bh)
boxes.append((int(c), ((xc - bw / 2) * w, (yc - bh / 2) * h, (xc + bw / 2) * w, (yc + bh / 2) * h)))
return boxes
def detect(img, conf=0.25):
"""추론 결과를 [(클래스번호, 신뢰도, (x1, y1, x2, y2)), ...] 로 돌려준다."""
r = model.predict(img, imgsz=320, conf=conf, device="cpu", verbose=False)[0]
return [(int(c), float(p), tuple(float(v) for v in b))
for b, p, c in zip(r.boxes.xyxy.cpu().numpy(), r.boxes.conf.cpu().numpy(),
r.boxes.cls.cpu().numpy().astype(int))]
per_image = []
for p in sorted((root / "images" / "val").glob("*.png")):
img = cv2.imread(str(p))
h, w = img.shape[:2]
gts = read_labels(root / "labels" / "val" / (p.stem + ".txt"), w, h)
preds = detect(img)
tp, fp, fn = match(preds, gts)
per_image.append({"그림": p.name, "정답": len(gts), "찾음": len(preds), "맞힘": tp, "헛것": fp, "놓침": fn,
"gts": gts, "preds": preds})
TP = sum(d["맞힘"] for d in per_image)
FP = sum(d["헛것"] for d in per_image)
FN = sum(d["놓침"] for d in per_image)
P = TP / (TP + FP) if TP + FP else 0.0
R = TP / (TP + FN)
F1 = 2 * P * R / (P + R) if P + R else 0.0
print(f"맞힌 것 {TP} · 헛것 {FP} · 놓친 것 {FN}")
print(f"정밀도 {round(P, 3)} · 재현율 {round(R, 3)} · F1 {round(F1, 3)}")맞힌 것 26 · 헛것 1 · 놓친 것 0
정밀도 0.963 · 재현율 1.0 · F1 0.9814단계. 그림별 표와 클래스별 재현율
import pandas as pd
table = pd.DataFrame(per_image)[["그림", "정답", "찾음", "맞힘", "헛것", "놓침"]]
print(table.to_string(index=False))
# 클래스별 재현율: 정답을 클래스 하나로 좁혀 다시 짝짓는다
for k, name in model.names.items():
hit = total = 0
for d in per_image:
g = [x for x in d["gts"] if x[0] == k]
q = [x for x in d["preds"] if x[0] == k]
hit += match(q, g)[0]
total += len(g)
print(f"{name:6} 정답 {total}개 가운데 {hit}개 찾음 · 재현율 {round(hit / total, 3)}") 그림 정답 찾음 맞힘 헛것 놓침
part_001.png 2 2 2 0 0
part_004.png 1 1 1 0 0
part_005.png 3 3 3 0 0
part_022.png 4 4 4 0 0
part_026.png 4 5 4 1 0
part_030.png 3 3 3 0 0
part_031.png 1 1 1 0 0
part_034.png 2 2 2 0 0
part_037.png 1 1 1 0 0
part_049.png 3 3 3 0 0
part_053.png 2 2 2 0 0
bolt 정답 7개 가운데 7개 찾음 · 재현율 1.0
nut 정답 10개 가운데 10개 찾음 · 재현율 1.0
washer 정답 9개 가운데 9개 찾음 · 재현율 1.0맞힌 것 26, 헛것 1, 놓친 것 0 으로 정밀도 0.963(26 ÷ 27), 재현율 1.0(26 ÷ 26), F1 0.981 입니다. 헛것 하나는 part_026.png 에서 나왔습니다. 실습 3에서 본 긴 볼트에 겹친 상자 둘 가운데 하나만 정답과 짝이 되고, 나머지 하나가 헛것이 되었습니다. 세 클래스 모두 재현율 1.0 이고, 어두운 그림 part_004.png 와 part_053.png 의 부품도 모두 찾았습니다. 17장 실습 1에서 평균 밝기 50 미만으로 고른 어두운 그림 9장 가운데 7장이 학습용에 들어가 있어, 어두운 모습도 배웠기 때문으로 봅니다.
코드 설명
iou는 겹친 너비와 높이를 "작은 끝 − 큰 끝"으로 구하고, 음수(안 겹침)는max(0, …)로 0 으로 막습니다. 1단계에서 본문의 손계산 0.538, 0.111 과 같은 값이 나오는지 먼저 확인했습니다.match는 신뢰도가 높은 상자부터 짝을 정합니다. 한 번 짝이 생긴 정답은used에 넣어 다시 쓰지 않으므로, 같은 물체에 상자가 둘 겹치면 하나는 헛것이 됩니다.- 놓친 것은 "정답 수 − 맞힌 것", 헛것은 "찾은 수 − 맞힌 것"입니다. 맞힌 것 하나가 정답 하나와 찾은 상자 하나를 함께 지웁니다.
- 클래스별 재현율은 정답과 찾은 상자를 한 클래스로 좁혀
match를 다시 부른 것입니다. 어느 부품을 자주 놓치는지 보입니다. - "맞혔는지 재기" 절의 주의할 점에 적은 경우가 실제로 나왔습니다. part_026.png 에서 정답 하나에 상자 둘이 겹쳐, 먼저 짝지은 하나만 맞힌 것이 되고 다른 하나는 헛것이 되었습니다.