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

18장. OpenCV 전처리와 YOLO 연계

실습 5 — 나쁜 조건 만들고 전처리로 되살리기

검증용 사진 11장을 어둡게 하거나 잡음을 넣은 판을 만들고, 학습한 모델의 점수가 얼마나 떨어지는지 잽니다. 그다음 어두운 판에는 감마와 CLAHE 를, 잡음 낀 판에는 Gaussian 블러와 Median 블러, 일부러 지나치게 센 블러를 걸어 넣고 점수가 얼마나 돌아오는지 표와 막대그래프로 견줍니다. 실습 2를 먼저 실행해야 합니다.

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

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

# 검증용 사진과 정답을 한 번에 읽어 둔다: [(이름, 그림, 정답), ...]
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])))

def darken(img):
    """밝기를 0.3 배로 낮춘다(노출이 모자란 사진)."""
    return np.clip(img.astype(np.float64) * 0.3, 0, 255).astype(np.uint8)

def add_noise(img, rng, sigma=20):
    """6차시의 꼴로 표준편차 sigma 의 잡음을 더한다."""
    return np.clip(img.astype(np.float64) + rng.normal(0, sigma, img.shape), 0, 255).astype(np.uint8)

rng = np.random.default_rng(1801)                    # 씨앗을 고정해 잡음이 늘 같게
sets = {"원본": [img for _, img, _ in val],
        "어둡게": [darken(img) for _, img, _ in val],
        "잡음": [add_noise(img, rng) for _, img, _ in val]}

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

for name, images in sets.items():
    print(f"{name:4} 사진 {len(images)}장 · 평균 밝기 {mean_gray(images)}")
출력
원본   사진 11장 · 평균 밝기 63.8
어둡게  사진 11장 · 평균 밝기 18.4
잡음   사진 11장 · 평균 밝기 63.3

2단계. 조건마다 점수 재기

Python
model = YOLO("out_ch18_runs/parts/weights/best.pt")

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):
    """실습 4와 같다: (맞힌 것, 헛것, 놓친 것)."""
    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 score(images):
    """그림 목록을 모델에 넣고 정답과 견줘 정밀도, 재현율, F1 을 돌려준다."""
    TP = FP = FN = 0
    for img, (_, _, gts) in zip(images, val):
        r = model.predict(img, imgsz=320, conf=0.25, device="cpu", verbose=False)[0]
        preds = [(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))]
        tp, fp, fn = match(preds, 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
    return {"정밀도": round(P, 3), "재현율": round(R, 3), "F1": round(F1, 3), "맞힘": TP, "헛것": FP}

for name, images in sets.items():
    print(f"{name:4}", score(images))
출력
원본   {'정밀도': 0.963, '재현율': 1.0, 'F1': 0.981, '맞힘': 26, '헛것': 1}
어둡게  {'정밀도': 1.0, '재현율': 1.0, 'F1': 1.0, '맞힘': 26, '헛것': 0}
잡음   {'정밀도': 0.786, '재현율': 0.846, 'F1': 0.815, '맞힘': 22, '헛것': 6}

3단계. 전처리로 고쳐 넣기

Python
lut = np.array([((i / 255) ** 0.5) * 255 for i in range(256)]).round().astype(np.uint8)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))

def fix_gamma(img):
    """감마 0.5 로 어두운 쪽을 올린다(5차시)."""
    return cv2.LUT(img, lut)

def fix_clahe(img):
    """밝기 채널(Y)에만 CLAHE 를 건다(5차시)."""
    ycc = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb)
    ycc[:, :, 0] = clahe.apply(ycc[:, :, 0])
    return cv2.cvtColor(ycc, cv2.COLOR_YCrCb2BGR)

# (나쁜 조건, 손질 이름, 손질 함수) 를 차례로 시험한다
trials = [("어둡게", "감마 0.5", fix_gamma),
          ("어둡게", "CLAHE", fix_clahe),
          ("잡음", "Gaussian 5x5", lambda im: cv2.GaussianBlur(im, (5, 5), 0)),
          ("잡음", "Median 5", lambda im: cv2.medianBlur(im, 5)),
          ("잡음", "Gaussian 15x15", lambda im: cv2.GaussianBlur(im, (15, 15), 0))]   # 일부러 지나치게 센 블러

rows = [{"조건": name, "손질": "그대로", **score(images)} for name, images in sets.items()]
for cond, fix_name, fix in trials:
    fixed = [fix(im) for im in sets[cond]]
    rows.append({"조건": cond, "손질": fix_name, **score(fixed)})
    print(f"{cond:4} + {fix_name:15} 평균 밝기 {mean_gray(fixed):5}  →", rows[-1]["재현율"])
출력
어둡게  + 감마 0.5          평균 밝기  68.1  → 1.0
어둡게  + CLAHE           평균 밝기  22.9  → 1.0
잡음   + Gaussian 5x5    평균 밝기  63.3  → 1.0
잡음   + Median 5        평균 밝기  63.2  → 1.0
잡음   + Gaussian 15x15  평균 밝기  63.3  → 1.0

4단계. 표와 막대그래프로 견주기

Python
result = pd.DataFrame(rows)
print(result.to_string(index=False))

labels = [f"{r['조건']}\n{r['손질']}" for r in rows]
fig, ax = plt.subplots(figsize=(11, 4.2), layout="constrained")
x = np.arange(len(rows))
ax.bar(x - 0.2, result["재현율"], width=0.4, label="재현율")
ax.bar(x + 0.2, result["정밀도"], width=0.4, label="정밀도")
ax.set_xticks(x, labels=labels, fontsize=9)
ax.set_ylim(0, 1.15)
ax.set_title("조건과 손질에 따른 학습한 모델의 점수 (검증용 11장, IoU 0.5, conf 0.25)")
ax.legend(loc="upper center", ncol=2)
plt.show()
출력
 조건             손질   정밀도   재현율    F1  맞힘  헛것
 원본            그대로 0.963 1.000 0.981  26   1
어둡게            그대로 1.000 1.000 1.000  26   0
 잡음            그대로 0.786 0.846 0.815  22   6
어둡게         감마 0.5 1.000 1.000 1.000  26   0
어둡게          CLAHE 1.000 1.000 1.000  26   0
 잡음   Gaussian 5x5 0.963 1.000 0.981  26   1
 잡음       Median 5 1.000 1.000 1.000  26   0
 잡음 Gaussian 15x15 1.000 1.000 1.000  26   0
원본, 어둡게, 잡음 조건을 그대로 넣었을 때와 감마, CLAHE, Gaussian, Median, 지나친 블러로 손질해 넣었을 때의 재현율과 정밀도 막대그래프

어둡게 한 판은 손질하지 않아도 F1 1.0 으로, 원본(0.981)보다 오히려 헛것이 하나 적었습니다. 평균 밝기를 63.8 에서 18.4 로 낮췄는데도 점수가 떨어지지 않은 것은, 학습용 사진에 어두운 그림 7장이 섞여 있어 모델이 어두운 모습을 이미 배웠기 때문으로 봅니다. 16장에서 본 증강의 생각, 곧 여러 조건을 학습 때 보여 주면 쓸 때 강해진다는 것을 이 결과가 보여 줍니다. 반면 학습 때 한 번도 보지 못한 잡음은 F1 을 0.815 로 떨어뜨렸습니다(맞힘 22, 헛것 6, 재현율 0.846). 손질한 뒤에는 다섯 가지 모두 재현율이 1.0 이었습니다. 감마와 CLAHE 는 원래 떨어지지 않은 어두운 판을 그대로 지켰을 뿐이고, CLAHE 는 평균 밝기를 22.9 로 조금만 올렸는데도 같은 점수였습니다. 잡음 판에서는 Gaussian 5×5 의 정밀도가 0.963(헛것 1), Median 5 와 일부러 넣은 Gaussian 15×15 는 1.0 이었습니다. 지나친 블러도 이 데이터에서는 점수를 떨어뜨리지 않은 것입니다. 검증용 물체가 26개뿐이라 헛것 하나의 차이(0.963 과 1.0)는 우연일 수 있습니다.

코드 설명

  • 잡음은 np.random.default_rng(1801) 하나로 사진 11장에 차례로 만듭니다. 씨앗과 사진의 차례가 같으면 다시 돌려도 똑같은 잡음이 나옵니다.
  • score 는 그림 목록을 받아 같은 모델, 같은 기준(IoU 0.5, conf 0.25)으로 채점합니다. 조건이나 손질만 바꾸고 채점은 그대로 두는 대조 실험입니다.
  • 손질한 그림은 파일로 저장하지 않고 model.predict 에 배열 그대로 넣습니다. 손질 함수만 바꿔 끼우면 여러 손질을 같은 틀에서 견줄 수 있습니다.
  • 잡음 판의 손질에는 일부러 지나치게 센 GaussianBlur(im, (15, 15), 0) 을 넣었습니다. "지나친 전처리의 함정" 절의 내용을 점수로 확인하려는 것이었는데, 이 데이터에서는 점수가 떨어지지 않았습니다.
  • 막대그래프에서 어둡게 판은 손질 없이도 막대가 1.0 이고, 잡음 판만 "그대로"에서 낮아졌다가 손질 뒤 1.0 근처로 돌아옵니다. 어느 조건이 실제로 문제인지 먼저 재고 손질을 고르는 것이 순서입니다.
← 이전 절18장 · 실습 4 — IoU 와 정밀도·재현율 계산기다음 절 →18장 · 프로젝트 1 — 전처리 전후 YOLO 성능 비교 보고서