18장. OpenCV 전처리와 YOLO 연계
실습 5 — 나쁜 조건 만들고 전처리로 되살리기
검증용 사진 11장을 어둡게 하거나 잡음을 넣은 판을 만들고, 학습한 모델의 점수가 얼마나 떨어지는지 잽니다. 그다음 어두운 판에는 감마와 CLAHE 를, 잡음 낀 판에는 Gaussian 블러와 Median 블러, 일부러 지나치게 센 블러를 걸어 넣고 점수가 얼마나 돌아오는지 표와 막대그래프로 견줍니다. 실습 2를 먼저 실행해야 합니다.
1단계. 나쁜 조건의 판 만들기
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.32단계. 조건마다 점수 재기
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단계. 전처리로 고쳐 넣기
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.04단계. 표와 막대그래프로 견주기
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
어둡게 한 판은 손질하지 않아도 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 근처로 돌아옵니다. 어느 조건이 실제로 문제인지 먼저 재고 손질을 고르는 것이 순서입니다.