OpenCV 비전 실무18장 · OpenCV 전처리와 YOLO 연계19 / 21← 이전목차다음 →MetaContents

18장. OpenCV 전처리와 YOLO 연계

프로젝트 1 — 전처리 전후 YOLO 성능 비교 보고서

부품 탐지 수업을 준비하는 강사라고 해 봅시다. 수강생들이 "공장 조명이 어두우면 YOLO 가 못 찾는데, OpenCV 로 밝게 하면 되지 않나요?" 하고 묻습니다. 말로 답하지 않고 실험으로 답하려고 합니다. 검증용 사진을 네 가지 조건으로 만들고, 세 가지 처리를 걸어 학습한 모델에 넣은 뒤, 12가지 조합의 정밀도·재현율·F1 을 한 표로 보여 주는 보고서를 만듭니다. 조건마다 가장 좋은 처리를 고르고, 대표 그림 격자를 저장합니다.

준비물은 실습 2에서 학습한 모델과 17장의 검증용 사진입니다. 프로젝트는 처음부터 다시 실행되므로 함수도 처음부터 다시 정의합니다.

파일내용
out_ch18_runs/parts/weights/best.pt실습 2에서 학습한 부품 탐지 모델
out_ch17_yolo/images/val/*.png검증용 그림 11장 (320×240, 17장의 가상 그림)
out_ch17_yolo/labels/val/*.txt검증용 정답 라벨(물체 26개)

요구사항

  1. IoU, 짝짓기, 채점 함수를 만듭니다. 채점은 IoU 0.5 이상이고 클래스가 같으면 맞힌 것으로 치고, 신뢰도 기준값은 0.25 로 둡니다.
  2. 조건 4가지를 만듭니다. 원본, 어둡게(밝기 0.3배), 잡음(표준편차 20), 어둡게+잡음입니다. 잡음은 씨앗 1802 로 만들고, 잡음 조건 둘이 같은 잡음을 쓰게 합니다.
  3. 처리 3가지를 만듭니다. 그대로, 밝기 보정(감마 0.5), 잡음 제거+밝기 보정(Gaussian 5×5 뒤 감마 0.5)입니다.
  4. 조건 4 × 처리 3 = 12가지마다 정밀도, 재현율, F1 을 재서 표로 찍습니다.
  5. 조건마다 F1 이 가장 높은 처리를 고르고, F1 을 조건 × 처리의 표로 다시 찍고 묶음 막대그래프로 그립니다.
  6. 대표 그림 한 장에 12가지 조합의 결과 상자를 그려 4줄 3칸의 격자로 붙여 out_ch18_report.png 로 저장하고 띄웁니다.
  7. 결론을 실제 숫자로 적습니다.

힌트

  1. 실습 5의 darken, add_noise, score 를 그대로 가져다 쓰면 됩니다. 조건을 만들 때마다 np.random.default_rng(1802) 를 새로 만들면, "잡음"과 "어둡게+잡음"이 같은 잡음을 씁니다.
  2. 어둡게+잡음은 어둡게 한 뒤 잡음을 더합니다. 어두운 곳에서 찍은 사진에 센서 잡음이 끼는 모습을 흉내 낸 것입니다.
  3. 잡음 제거+밝기 보정은 블러를 먼저, 감마를 나중에 겁니다. 감마를 먼저 걸면 잡음까지 커집니다("전처리가 결과에 주는 영향" 절).
  4. 조건 × 처리의 표는 pivot(index="조건", columns="처리", values="F1") 로 만듭니다. 조건마다 가장 좋은 처리는 idxmax(axis=1) 로 고릅니다.
  5. 격자는 그림 12장에 상자와 글자를 그린 뒤 2장에서 본 cv2.hconcat, cv2.vconcat 으로 붙입니다.

채점 기준

항목기준점수
채점 함수IoU 0.5·같은 클래스로 짝짓고 정밀도·재현율·F1 을 낸다20
조건 만들기네 조건을 만들고 잡음의 씨앗을 고정한다15
처리 만들기세 처리를 만들고 잡음 제거를 밝기 보정보다 먼저 건다15
12칸 표모든 조합의 점수를 표로 찍는다15
가장 좋은 처리조건마다 고르고 F1 표와 막대그래프를 낸다15
보고 격자12가지 결과를 격자로 저장한다10
결론실제 숫자로 적는다10

코드의 흐름

채점 함수, 조건 4개, 처리 3개, 12칸 표, 보고 격자의 다섯 단계를 이은 흐름도

그림의 다섯 단계를 예시 풀이의 단계와 같은 순서로 짰습니다. 첫째 단계의 채점 함수는 실습 4, 5와 같고, 둘째와 셋째 단계에서 조건과 처리를 함수로 만들어 두면 넷째 단계는 두 목록을 엇갈려 돌리기만 하면 됩니다. 다섯째 단계의 격자는 숫자 표가 말하는 차이를 그림으로 확인하는 것입니다.

실제 데이터로 해 보려면

실제 사진으로 해 보려면 AI Hub(aihub.or.kr)에서 제조 부품이나 물류 물품처럼 상자 주석이 함께 있는 이미지 자료를 찾아 내려받습니다. 내려받기 전에 이용 약관(상업적 이용 가능 여부, 재배포 금지 등)을 확인하고, 사람이 찍힌 사진은 얼굴과 개인정보가 드러나지 않게 다룹니다. 17장의 방법으로 YOLO 형식으로 바꿔 학습한 뒤, 이 보고서의 조건을 만들지 말고 실제로 어둡게 찍힌 사진과 밝게 찍힌 사진을 나눠 견주면 더 현실에 가깝습니다. 실제 사진은 어둡게 찍힐 때 색도 함께 바뀌므로, 가상으로 만든 조건보다 점수 차이가 크게 날 수 있습니다.

결과 보기 (예시 풀이)

1단계. 채점 도구 준비하기

Python
import os
from pathlib import Path
os.environ["YOLO_VERBOSE"] = "False"
from ultralytics import YOLO
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

weights = Path("out_ch18_runs/parts/weights/best.pt")
if not weights.exists():
    raise SystemExit("best.pt 가 없습니다. 18차시 실습 2를 먼저 실행하세요")
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 iou(a, b):
    """겹친 넓이 ÷ 합친 넓이."""
    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)
    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

def match(preds, gts, thr=0.5):
    """신뢰도 높은 것부터 같은 클래스의 정답과 짝짓고 (맞힌 것, 헛것, 놓친 것) 을 돌려준다."""
    used, tp = set(), 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 not in used and gc == c and iou(box, gbox) > best:
                best, best_j = iou(box, gbox), j
        if best >= thr:
            used.add(best_j)
            tp += 1
    return tp, len(preds) - tp, len(gts) - tp

def detect(img):
    """추론 결과를 [(클래스번호, 신뢰도, (x1, y1, x2, y2)), ...] 로 돌려준다."""
    r = model.predict(img, imgsz=320, conf=0.25, 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))]

val = []
for p in sorted((root / "images" / "val").glob("*.png")):
    img = cv2.imread(str(p))
    val.append((p.name, img, read_labels(root / "labels" / "val" / (p.stem + ".txt"), img.shape[1], img.shape[0])))
print("검증용 사진", len(val), "장 · 정답 물체", sum(len(g) for _, _, g in val), "개")
print("모델의 클래스:", model.names)
출력
검증용 사진 11 장 · 정답 물체 26 개
모델의 클래스: {0: 'bolt', 1: 'nut', 2: 'washer'}

2단계. 조건과 처리 만들기

Python
def darken(img):
    return np.clip(img.astype(np.float64) * 0.3, 0, 255).astype(np.uint8)

def add_noise(img, rng, sigma=20):
    return np.clip(img.astype(np.float64) + rng.normal(0, sigma, img.shape), 0, 255).astype(np.uint8)

def make_condition(name):
    """조건 이름에 맞게 검증용 사진 11장을 바꾼 목록을 돌려준다."""
    rng = np.random.default_rng(1802)                 # 조건마다 새로 만든다 → 잡음 조건 둘이 같은 잡음
    out = []
    for _, img, _ in val:
        x = img
        if "어둡게" in name:
            x = darken(x)                             # 어둡게를 먼저
        if "잡음" in name:
            x = add_noise(x, rng)                     # 그다음 잡음
        out.append(x)
    return out

lut = np.array([((i / 255) ** 0.5) * 255 for i in range(256)]).round().astype(np.uint8)
TREATMENTS = {
    "그대로": lambda im: im,
    "밝기 보정": lambda im: cv2.LUT(im, lut),                                   # 감마 0.5
    "잡음 제거+밝기 보정": lambda im: cv2.LUT(cv2.GaussianBlur(im, (5, 5), 0), lut),   # 블러 먼저, 감마 나중
}
CONDITIONS = ["원본", "어둡게", "잡음", "어둡게+잡음"]
sets = {c: make_condition(c) for c in CONDITIONS}

def mean_gray(images):
    return round(float(np.mean([cv2.cvtColor(im, cv2.COLOR_BGR2GRAY).mean() for im in images])), 1)

for c in CONDITIONS:
    shown = ", ".join(f"{t} {mean_gray([f(im) for im in sets[c]])}" for t, f in TREATMENTS.items())
    print(f"{c:7} 평균 밝기 → {shown}")
출력
원본      평균 밝기 → 그대로 63.8, 밝기 보정 125.7, 잡음 제거+밝기 보정 125.7
어둡게     평균 밝기 → 그대로 18.4, 밝기 보정 68.1, 잡음 제거+밝기 보정 68.1
잡음      평균 밝기 → 그대로 63.3, 밝기 보정 122.8, 잡음 제거+밝기 보정 125.3
어둡게+잡음  평균 밝기 → 그대로 20.4, 밝기 보정 60.8, 잡음 제거+밝기 보정 71.1

3단계. 12가지 조합 채점하기

Python
rows = []
for c in CONDITIONS:
    for t, f in TREATMENTS.items():
        TP = FP = FN = 0
        for img, (_, _, gts) in zip(sets[c], val):
            tp, fp, fn = match(detect(f(img)), gts)
            TP, FP, FN = TP + tp, FP + fp, FN + fn
        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
        rows.append({"조건": c, "처리": t, "맞힘": TP, "헛것": FP, "놓침": FN,
                     "정밀도": round(P, 3), "재현율": round(R, 3), "F1": round(F1, 3)})
report = pd.DataFrame(rows)
print(report.to_string(index=False))
출력
    조건          처리  맞힘  헛것  놓침   정밀도   재현율    F1
    원본         그대로  26   1   0 0.963 1.000 0.981
    원본       밝기 보정  26   0   0 1.000 1.000 1.000
    원본 잡음 제거+밝기 보정  26   0   0 1.000 1.000 1.000
   어둡게         그대로  26   0   0 1.000 1.000 1.000
   어둡게       밝기 보정  26   0   0 1.000 1.000 1.000
   어둡게 잡음 제거+밝기 보정  26   0   0 1.000 1.000 1.000
    잡음         그대로  24   5   2 0.828 0.923 0.873
    잡음       밝기 보정  21  10   5 0.677 0.808 0.737
    잡음 잡음 제거+밝기 보정  23   1   3 0.958 0.885 0.920
어둡게+잡음         그대로   0   1  26 0.000 0.000 0.000
어둡게+잡음       밝기 보정   0   0  26 0.000 0.000 0.000
어둡게+잡음 잡음 제거+밝기 보정   5   1  21 0.833 0.192 0.312

4단계. 조건마다 가장 좋은 처리 고르기

Python
# pivot: 조건을 줄, 처리를 칸으로 놓은 F1 표. 칸의 차례는 TREATMENTS 의 차례로 맞춘다
f1 = report.pivot(index="조건", columns="처리", values="F1").loc[CONDITIONS, list(TREATMENTS)]
print(f1.to_string())
print()
best = f1.idxmax(axis=1)                              # 줄마다 가장 큰 값이 있는 칸 이름
for c in CONDITIONS:
    print(f"{c:7} 가장 좋은 처리: {best[c]} (F1 {float(f1.loc[c, best[c]]):.3f}, 그대로 {float(f1.loc[c, '그대로']):.3f})")

fig, ax = plt.subplots(figsize=(10, 4), layout="constrained")
x = np.arange(len(CONDITIONS))
for k, t in enumerate(TREATMENTS):
    ax.bar(x + (k - 1) * 0.27, f1[t], width=0.27, label=t)
ax.set_xticks(x, labels=CONDITIONS)
ax.set_ylim(0, 1.15)
ax.set_ylabel("F1")
ax.set_title("조건과 처리에 따른 F1 (검증용 11장, IoU 0.5, conf 0.25)")
ax.legend(loc="upper center", ncol=3)
plt.show()
출력
처리        그대로  밝기 보정  잡음 제거+밝기 보정
조건
원본      0.981  1.000        1.000
어둡게     1.000  1.000        1.000
잡음      0.873  0.737        0.920
어둡게+잡음  0.000  0.000        0.312

원본      가장 좋은 처리: 밝기 보정 (F1 1.000, 그대로 0.981)
어둡게     가장 좋은 처리: 그대로 (F1 1.000, 그대로 1.000)
잡음      가장 좋은 처리: 잡음 제거+밝기 보정 (F1 0.920, 그대로 0.873)
어둡게+잡음  가장 좋은 처리: 잡음 제거+밝기 보정 (F1 0.312, 그대로 0.000)
원본, 어둡게, 잡음, 어둡게와 잡음 네 조건마다 그대로, 밝기 보정, 잡음 제거와 밝기 보정 세 처리의 F1 을 견준 묶음 막대그래프

5단계. 대표 그림 격자 저장하기

Python
pick = [k for k, (name, _, _) in enumerate(val) if name == "part_022.png"][0]   # 부품 넷이 놓인 그림
_, _, gts = val[pick]
tiles = []
for c in CONDITIONS:
    row = []
    for t, f in TREATMENTS.items():
        tile = f(sets[c][pick]).copy()
        preds = detect(tile)
        tp, fp, fn = match(preds, gts)
        for gc, (x1, y1, x2, y2) in gts:                                      # 정답: 초록 얇은 선
            cv2.rectangle(tile, (round(x1), round(y1)), (round(x2), round(y2)), (0, 255, 0), 1)
        for pc, p, (x1, y1, x2, y2) in preds:                                 # 찾은 상자: 빨강 굵은 선
            cv2.rectangle(tile, (int(x1), int(y1)), (int(x2), int(y2)), (0, 0, 255), 2)
        # 위에 검은 띠를 깔고 조건, 처리, 맞힘 수를 영어로 적는다(putText 는 한글을 못 쓴다)
        cv2.rectangle(tile, (0, 0), (319, 18), (0, 0, 0), -1)
        tag = {"원본": "orig", "어둡게": "dark", "잡음": "noise", "어둡게+잡음": "dark+noise"}[c]
        fix = {"그대로": "as is", "밝기 보정": "gamma", "잡음 제거+밝기 보정": "blur+gamma"}[t]
        cv2.putText(tile, f"{tag} / {fix}  hit {tp}/{len(gts)} fp {fp}", (4, 13),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.42, (255, 255, 255), 1)
        row.append(tile)
    tiles.append(cv2.hconcat(row))                    # hconcat: 가로로 잇기
sheet = cv2.vconcat(tiles)                            # vconcat: 세로로 잇기
print("격자 크기:", sheet.shape, "· 저장:", cv2.imwrite("out_ch18_report.png", sheet))

plt.figure(figsize=(10, 10))
plt.imshow(cv2.cvtColor(sheet, cv2.COLOR_BGR2RGB))
plt.title("part_022.png 의 조건(줄) × 처리(칸) 결과 (out_ch18_report.png)")
plt.axis("off")
plt.show()
출력
격자 크기: (960, 960, 3) · 저장: True
part_022.png 를 네 조건으로 만들고 세 처리를 걸어 학습한 모델이 찾은 상자와 정답 상자를 그린 4줄 3칸 격자

조건마다 가장 좋은 처리는 원본이 밝기 보정(F1 1.000, 그대로 0.981), 어둡게가 그대로(세 처리 모두 1.000), 잡음이 잡음 제거+밝기 보정(0.920, 그대로 0.873), 어둡게+잡음이 잡음 제거+밝기 보정(0.312, 그대로 0.000)입니다. 결과를 세 가지로 읽을 수 있습니다.

첫째, 어둡게 한 것만으로는 점수가 떨어지지 않았습니다. 학습용 사진에 어두운 그림이 섞여 있었기 때문입니다. 원본에 밝기 보정을 걸어 평균 밝기를 63.8 에서 125.7 로 올려도 점수는 떨어지지 않았고, 헛것 하나가 사라졌습니다. 둘째, 잡음 낀 사진에 밝기만 올리면 헛것이 5개에서 10개로 늘어 F1 이 0.873 에서 0.737 로 오히려 떨어졌습니다. 잡음을 먼저 줄이고 밝기를 올리면 헛것이 1개로 줄어 0.920 이 되었습니다. 다만 놓친 것은 2개에서 3개로 늘어 재현율은 0.923 에서 0.885 로 조금 낮아졌습니다. 셋째, 어둡게+잡음이 가장 어려웠습니다. 그대로와 밝기 보정은 26개 가운데 하나도 맞히지 못했고, 잡음 제거+밝기 보정이 5개를 맞혀 재현율 0.192, F1 0.312 였습니다. 격자의 맨 아랫줄에서도 part_022.png 의 부품 넷 가운데 하나만 찾았습니다. 어둡게 하면 부품과 바탕의 밝기 차이가 줄어 잡음에 묻히고, 그 뒤의 손질로는 잃은 차이를 되살리지 못합니다.

정리하면 "어두우니 밝게 하자"는 손질은 이 모델에게 필요하지 않았고, 진짜 문제는 학습 때 보지 못한 잡음이었습니다. 무엇이 문제인지 먼저 점수로 재고, 잡음이 있으면 밝기보다 잡음 제거를 먼저 거는 것이 알맞습니다. 어둡고 잡음까지 낀 사진은 손질만으로는 한계가 있으므로, 그런 사진을 증강으로 학습용에 넣어 다시 학습하는 쪽을 생각해야 합니다. 잡음 씨앗이 실습 5(1801)와 달라(1802) 잡음 조건의 "그대로" F1 이 0.815 와 0.873 으로 다릅니다. 물체 26개뿐인 작은 검증용이라 이 정도 차이는 씨앗에 따라 달라질 수 있습니다.

← 이전 절18장 · 실습 5 — 나쁜 조건 만들고 전처리로 되살리기다음 절 →18장 · 정리