개념
밀집 예측(dense prediction)으로서의 검출
분류기(classifier)는 이미지당 C개의 숫자를 출력합니다. YOLO 방식의 검출기(YOLO-style detector)는 이미지당 (S x S x (5 + C))개의 숫자를 출력합니다. 여기서 S는 공간 격자(spatial grid)의 크기입니다. 앵커(anchor)를 명시하면 출력은 (S x S x B x (5 + C))로 볼 수 있습니다.
flowchart LR
IMG["Input 416x416 RGB"] --> BB["Backbone<br/>(ResNet, DarkNet, ...)"]
BB --> FM["Feature map<br/>(C_feat, 13, 13)"]
FM --> HEAD["Detection head<br/>(1x1 convs)"]
HEAD --> OUT["Output tensor<br/>(13, 13, B * (5 + C))"]
OUT --> DEC["Decode<br/>(grid + sigmoid + exp)"]
DEC --> NMS["Non-max suppression"]
NMS --> RESULT["Final boxes"]
S * S개의 각 격자 셀(grid cell)은 B개의 상자를 예측합니다. 상자마다 다음 값이 있습니다.
- 기하(geometry)를 나타내는 숫자 4개:
tx, ty, tw, th
- 객체성(objectness) 점수 1개: “이 셀 중심에 객체가 있는가?”
- 클래스 확률(class probabilities)
C개
셀마다 총 B * (5 + C)개의 숫자가 필요합니다. VOC에서 S=13, B=2, C=20이면 셀마다 2 * (5 + 20) = 50개의 숫자가 필요합니다.
격자(grid)와 앵커(anchor)가 필요한 이유
모든 객체의 절대 좌표(absolute coordinate) (x, y, w, h)를 바로 회귀(regression)하는 것은 합성곱 네트워크(conv network)에 어렵습니다. 이미지를 평행 이동한다고 해서 모든 예측이 같은 양만큼 평행 이동하면 안 됩니다. 각 객체는 공간적으로 고정되어 있어야 합니다. 격자는 실제 정답 상자(ground-truth box)의 중심(center)이 들어간 격자 셀에 책임(responsibility)을 할당해 이 문제를 해결합니다. 해당 객체는 그 셀만 책임집니다.
앵커는 두 번째 문제를 해결합니다. 3x3 합성곱(conv)은 16픽셀 수용장(receptive field)을 가진 특징 셀(feature cell)에서 500픽셀 너비의 상자를 처음부터 회귀하기 어렵습니다. 대신 셀마다 미리 정의한 B개의 사전 상자 모양(prior box shapes), 즉 앵커를 두고, 모델은 아무것도 없는 상태에서 회귀하는 대신 적절한 앵커를 고른 뒤 그 앵커를 조금 움직이는 델타(delta)를 예측합니다.
416x416 입력에 대한 앵커 상자 사전값(anchor box priors) 예시:
small: (30, 60)
medium: (75, 170)
large: (200, 380)
각 격자 셀에서 모든 앵커는 (tx, ty, tw, th, obj, c_1, ..., c_C)를 방출합니다.
현대 검출기는 종종 특징 피라미드 네트워크(Feature Pyramid Network, FPN)를 사용해 해상도(resolution)마다 다른 앵커 집합(anchor set)을 둡니다. 얕은 고해상도 맵(shallow high-resolution map)에는 작은 앵커를, 깊은 저해상도 맵(deep low-resolution map)에는 큰 앵커를 둡니다. 아이디어는 같고, 스케일(scale)만 더 많아진 것입니다.
예측 디코딩(decoding predictions)
원시 tx, ty, tw, th는 상자 좌표(box coordinates)가 아닙니다. 그림으로 표시하기 전에 다음처럼 변환해야 하는 회귀 대상(regression targets)입니다.
centre x = (sigmoid(tx) + cell_x) * stride
centre y = (sigmoid(ty) + cell_y) * stride
width = anchor_w * exp(tw)
height = anchor_h * exp(th)
sigmoid는 중심 오프셋(center offset)을 셀 안에 유지합니다. exp는 폭(width)이 부호 반전 없이 앵커로부터 자유롭게 스케일될 수 있게 합니다. stride는 격자 좌표(grid coordinates)를 다시 픽셀(pixel)로 확장합니다. 이 디코딩 단계는 YOLO v2 이후 모든 YOLO 버전에서 같은 구조로 쓰입니다.
IoU
IoU(Intersection-over-Union)는 두 상자 사이의 범용 유사도 지표(universal similarity metric)입니다.
IoU(A, B) = area(A intersect B) / area(A union B)
IoU=1은 두 상자가 동일하다는 뜻이고, IoU=0은 겹침(overlap)이 없다는 뜻입니다. 예측 상자와 정답 상자(ground-truth box)의 IoU는 어떤 예측이 참 양성(true positive)으로 계산되는지 결정합니다. 보통 IoU >= 0.5를 기준으로 삼습니다. 예측끼리의 IoU는 NMS가 중복을 제거할 때 사용합니다.
비최대 억제(Non-maximum suppression)
인접한 앵커에서 학습된 합성곱 네트워크는 같은 객체에 대해 겹치는 상자를 여러 개 예측하는 경우가 많습니다. NMS는 신뢰도(confidence)가 가장 높은 예측을 남기고, 그 예측과 IoU가 임계값(threshold)보다 큰 다른 예측을 삭제합니다.
NMS(boxes, scores, iou_threshold):
score 내림차순으로 상자를 정렬한다.
keep = []
boxes가 비어 있지 않은 동안:
가장 높은 점수의 상자를 고르고 keep에 추가한다.
고른 상자와 IoU > iou_threshold인 모든 상자를 제거한다.
keep을 반환한다.
객체 검출의 일반적인 임계값은 0.45입니다. 최근 검출기는 표준 NMS를 soft-NMS, DIoU-NMS로 바꾸거나 RT-DETR처럼 억제(suppression)를 직접 학습하기도 하지만, 구조적 목적은 같습니다.
손실(loss)
YOLO 손실은 가중치를 둔 세 손실을 더한 것입니다.
L = lambda_coord * L_box(pred, target, where obj=1)
+ lambda_obj * L_obj(pred, 1, where obj=1)
+ lambda_noobj * L_obj(pred, 0, where obj=0)
+ lambda_cls * L_cls(pred, target, where obj=1)
객체가 있는 셀만 상자 회귀 손실(box-regression loss)과 분류 손실(classification loss)에 기여합니다. 객체가 없는 셀은 객체성 손실(objectness loss)에만 기여해 모델이 조용히 있어야 함을 배웁니다. 빈 셀(empty cell)이 압도적으로 많고 그렇지 않으면 전체 손실을 지배하기 때문에 lambda_noobj는 보통 작게 둡니다. 대략 0.5가 흔합니다.
현대 변형은 MSE 상자 손실 대신 IoU를 직접 최적화하는 CIoU/DIoU를 쓰고, 클래스 불균형(class imbalance)에는 초점 손실(focal loss)을 쓰며, 객체성은 품질 초점 손실(quality focal loss)로 균형을 맞추기도 합니다. 그래도 세 구성 요소 구조는 변하지 않습니다.
검출 지표(detection metrics)
정확도(accuracy)는 검출에 그대로 옮겨 쓸 수 없습니다. 대신 다음 네 숫자를 봅니다.
- Precision@IoU=0.5: 양성(positive)으로 계산된 예측 중 실제로 맞은 것이 몇 개인지 나타냅니다.
- Recall@IoU=0.5: 실제 객체 중 몇 개를 찾았는지 나타냅니다.
- AP@0.5: IoU 임계값 0.5에서의 정밀도-재현율 곡선(precision-recall curve) 아래 면적입니다. 클래스마다 하나의 숫자가 나옵니다.
- mAP@0.5:0.95: IoU 임계값 0.5, 0.55, ..., 0.95에 걸친 AP 평균입니다. COCO 지표이며 가장 엄격하고 정보량이 많습니다.
네 가지를 모두 보고해야 합니다. mAP@0.5는 높고 mAP@0.5:0.95가 낮은 검출기는 객체를 대략 찾지만 상자를 촘촘하게(tightly) 위치시키지 못하는 것입니다. 더 나은 상자 회귀 손실로 고칩니다. 정밀도(precision)가 높고 재현율(recall)이 낮은 검출기는 너무 보수적입니다. 신뢰도 임계값(confidence threshold)을 낮추거나 객체성 가중치(objectness weight)를 높입니다.
만들어 보기
Step 1: IoU
전체 강의에서 가장 자주 쓰이는 기본 일꾼(workhorse)입니다. (x1, y1, x2, y2) 형식의 상자 배열 두 개에서 동작합니다.
import numpy as np
def box_iou(boxes_a, boxes_b):
ax1, ay1, ax2, ay2 = boxes_a[:, 0], boxes_a[:, 1], boxes_a[:, 2], boxes_a[:, 3]
bx1, by1, bx2, by2 = boxes_b[:, 0], boxes_b[:, 1], boxes_b[:, 2], boxes_b[:, 3]
inter_x1 = np.maximum(ax1[:, None], bx1[None, :])
inter_y1 = np.maximum(ay1[:, None], by1[None, :])
inter_x2 = np.minimum(ax2[:, None], bx2[None, :])
inter_y2 = np.minimum(ay2[:, None], by2[None, :])
inter_w = np.clip(inter_x2 - inter_x1, 0, None)
inter_h = np.clip(inter_y2 - inter_y1, 0, None)
inter = inter_w * inter_h
area_a = (ax2 - ax1) * (ay2 - ay1)
area_b = (bx2 - bx1) * (by2 - by1)
union = area_a[:, None] + area_b[None, :] - inter
return inter / np.clip(union, 1e-8, None)
(N_a, N_b) 모양의 쌍별 IoU 행렬(pairwise IoU matrix)을 반환합니다. 정답 상자 하나와 비교하려면 배열 한쪽의 모양을 (1, 4)로 만들면 됩니다.
Step 2: NMS
def nms(boxes, scores, iou_threshold=0.45):
order = np.argsort(-scores)
keep = []
while len(order) > 0:
i = order[0]
keep.append(i)
if len(order) == 1:
break
rest = order[1:]
ious = box_iou(boxes[[i]], boxes[rest])[0]
order = rest[ious <= iou_threshold]
return np.array(keep, dtype=np.int64)
정렬(sort) 때문에 결정적(deterministic)이며 O(N log N)입니다. 같은 입력에서는 torchvision.ops.nms의 동작과 맞습니다.
Step 3: 상자 인코딩(box encoding)과 디코딩(decoding)
픽셀 좌표(pixel coordinates)와 네트워크가 실제로 회귀하는 (tx, ty, tw, th) 대상(target)을 서로 변환합니다.
def encode(box_xyxy, cell_x, cell_y, stride, anchor_wh):
x1, y1, x2, y2 = box_xyxy
cx = 0.5 * (x1 + x2)
cy = 0.5 * (y1 + y2)
w = x2 - x1
h = y2 - y1
tx = cx / stride - cell_x
ty = cy / stride - cell_y
tw = np.log(w / anchor_wh[0] + 1e-8)
th = np.log(h / anchor_wh[1] + 1e-8)
return np.array([tx, ty, tw, th])
def decode(tx_ty_tw_th, cell_x, cell_y, stride, anchor_wh):
tx, ty, tw, th = tx_ty_tw_th
cx = (sigmoid(tx) + cell_x) * stride
cy = (sigmoid(ty) + cell_y) * stride
w = anchor_wh[0] * np.exp(tw)
h = anchor_wh[1] * np.exp(th)
return np.array([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2])
def sigmoid(x):
return 1.0 / (1.0 + np.exp(-x))
테스트 방법은 간단합니다. 상자를 인코딩한 뒤 디코딩해 보세요. tx가 사후 시그모이드 범위(post-sigmoid range)에 있지 않을 때 시그모이드 역함수(sigmoid inverse)가 완전히 가역적이지는 않기 때문에 아주 작은 차이는 있을 수 있지만, 원래 상자에 매우 가까워야 합니다.
Step 4: 최소 YOLO 헤드
특징 맵(feature map)에 1x1 합성곱 하나를 적용하고 (B, S, S, num_anchors, 5 + C) 형태로 변형(reshape)합니다.
import torch
import torch.nn as nn
class YOLOHead(nn.Module):
def __init__(self, in_c, num_anchors, num_classes):
super().__init__()
self.num_anchors = num_anchors
self.num_classes = num_classes
self.conv = nn.Conv2d(in_c, num_anchors * (5 + num_classes), kernel_size=1)
def forward(self, x):
n, _, h, w = x.shape
y = self.conv(x)
y = y.view(n, self.num_anchors, 5 + self.num_classes, h, w)
y = y.permute(0, 3, 4, 1, 2).contiguous()
return y
출력 모양(output shape)은 (N, H, W, num_anchors, 5 + C)입니다. 마지막 차원에는 [tx, ty, tw, th, obj, cls_0, ..., cls_{C-1}]가 들어갑니다.
Step 5: 정답 상자 할당(ground-truth assignment)
모든 정답 상자에 대해 어느 (cell, anchor)가 책임질지 결정합니다.
def assign_targets(boxes_xyxy, classes, anchors, stride, grid_size, num_classes):
num_anchors = len(anchors)
target = np.zeros((grid_size, grid_size, num_anchors, 5 + num_classes), dtype=np.float32)
has_obj = np.zeros((grid_size, grid_size, num_anchors), dtype=bool)
for box, cls in zip(boxes_xyxy, classes):
x1, y1, x2, y2 = box
cx, cy = 0.5 * (x1 + x2), 0.5 * (y1 + y2)
gx, gy = int(cx / stride), int(cy / stride)
bw, bh = x2 - x1, y2 - y1
ious = np.array([
(min(bw, aw) * min(bh, ah)) / (bw * bh + aw * ah - min(bw, aw) * min(bh, ah))
for aw, ah in anchors
])
best = int(np.argmax(ious))
aw, ah = anchors[best]
target[gy, gx, best, 0] = cx / stride - gx
target[gy, gx, best, 1] = cy / stride - gy
target[gy, gx, best, 2] = np.log(bw / aw + 1e-8)
target[gy, gx, best, 3] = np.log(bh / ah + 1e-8)
target[gy, gx, best, 4] = 1.0
target[gy, gx, best, 5 + cls] = 1.0
has_obj[gy, gx, best] = True
return target, has_obj
앵커 선택은 “정답 상자와 모양 IoU(shape IoU)가 가장 좋은 앵커”를 고르는 방식입니다. YOLOv2/v3의 할당(assignment)에 맞는 저렴한 근사(cheap proxy)입니다. v5 이후의 task-aligned matching, dynamic k도 같은 아이디어를 더 정교하게 만든 것입니다.
Step 6: 세 가지 손실(loss)
def yolo_loss(pred, target, has_obj, lambda_coord=5.0, lambda_obj=1.0, lambda_noobj=0.5, lambda_cls=1.0):
has_obj_t = torch.from_numpy(has_obj).bool()
target_t = torch.from_numpy(target).float()
box_pred = pred[..., :4][has_obj_t]
box_true = target_t[..., :4][has_obj_t]
loss_box = torch.nn.functional.mse_loss(box_pred, box_true, reduction="sum")
obj_pred = pred[..., 4]
obj_true = target_t[..., 4]
loss_obj_pos = torch.nn.functional.binary_cross_entropy_with_logits(
obj_pred[has_obj_t], obj_true[has_obj_t], reduction="sum")
loss_obj_neg = torch.nn.functional.binary_cross_entropy_with_logits(
obj_pred[~has_obj_t], obj_true[~has_obj_t], reduction="sum")
cls_pred = pred[..., 5:][has_obj_t]
cls_true = target_t[..., 5:][has_obj_t]
loss_cls = torch.nn.functional.binary_cross_entropy_with_logits(
cls_pred, cls_true, reduction="sum")
total = (lambda_coord * loss_box
+ lambda_obj * loss_obj_pos
+ lambda_noobj * loss_obj_neg
+ lambda_cls * loss_cls)
return total, {"box": loss_box.item(), "obj_pos": loss_obj_pos.item(),
"obj_neg": loss_obj_neg.item(), "cls": loss_cls.item()}
모든 YOLO 튜토리얼이 하드코딩하거나 탐색(sweep)하는 하이퍼파라미터(hyper-parameters)가 다섯 개 있습니다. 비율이 중요합니다. lambda_coord=5, lambda_noobj=0.5는 원 YOLOv1 논문의 비율을 반영하며 여전히 합리적인 기본값으로 동작합니다.
Step 7: 추론 파이프라인(inference pipeline)
원시 헤드 출력(raw head output)을 디코딩하고, sigmoid/exp를 적용하고, 객체성을 기준으로 임계값(threshold)을 적용한 뒤 NMS를 수행합니다.
def postprocess(pred_tensor, anchors, stride, img_size, conf_threshold=0.25, iou_threshold=0.45):
pred = pred_tensor.detach().cpu().numpy()
grid_h, grid_w = pred.shape[1], pred.shape[2]
num_anchors = len(anchors)
boxes, scores, classes = [], [], []
for gy in range(grid_h):
for gx in range(grid_w):
for a in range(num_anchors):
tx, ty, tw, th, obj, *cls = pred[0, gy, gx, a]
score = sigmoid(obj) * sigmoid(np.array(cls)).max()
if score < conf_threshold:
continue
cls_idx = int(np.argmax(cls))
cx = (sigmoid(tx) + gx) * stride
cy = (sigmoid(ty) + gy) * stride
w = anchors[a][0] * np.exp(tw)
h = anchors[a][1] * np.exp(th)
boxes.append([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2])
scores.append(float(score))
classes.append(cls_idx)
if not boxes:
return np.zeros((0, 4)), np.zeros((0,)), np.zeros((0,), dtype=int)
boxes = np.array(boxes)
scores = np.array(scores)
classes = np.array(classes)
keep = nms(boxes, scores, iou_threshold)
return boxes[keep], scores[keep], classes[keep]
이것이 완전한 평가 경로입니다. head -> decode -> threshold -> NMS 순서로 최종 상자, 점수, 레이블을 얻습니다.
사용하기
torchvision.models.detection은 같은 개념 구조(conceptual structure)를 가진 프로덕션(production) 검출기를 제공합니다. 사전 학습 모델(pretrained model)은 세 줄로 불러올 수 있습니다.
import torch
from torchvision.models.detection import fasterrcnn_resnet50_fpn_v2
model = fasterrcnn_resnet50_fpn_v2(weights="DEFAULT")
model.eval()
with torch.no_grad():
predictions = model([torch.randn(3, 400, 600)])
print(predictions[0].keys())
print(f"boxes: {predictions[0]['boxes'].shape}")
print(f"scores: {predictions[0]['scores'].shape}")
print(f"labels: {predictions[0]['labels'].shape}")
실시간 추론(real-time inference) 파이프라인에서는 ultralytics(YOLOv8/v9)가 표준에 가깝습니다. from ultralytics import YOLO; model = YOLO('yolov8n.pt'); model(img)처럼 사용합니다. 모델은 디코딩과 NMS를 내부에서 처리하고, 위에서 직접 만든 것과 같은 boxes / scores / labels 세 쌍(triple) 구조를 반환합니다.
산출물 만들기
이 강의의 최종 산출물은 다음 두 가지입니다.
outputs/prompt-detection-metric-reader.md: 정밀도(precision), 재현율(recall), AP, mAP@0.5:0.95 행을 한 줄 진단(diagnosis)과 가장 유용한 다음 실험으로 바꾸는 프롬프트(prompt)
outputs/skill-anchor-designer.md: 정답 상자(ground-truth box) 데이터셋(dataset)이 주어지면 (w, h)에 k-평균(k-means)을 수행해 FPN 레벨별 앵커 집합(anchor set)과 적절한 앵커 개수를 고르는 데 필요한 커버리지 통계(coverage statistics)를 반환하는 스킬(skill)
연습문제
- 쉬움:
box_iou를 구현하고 1,000개 무작위 상자 쌍(random box pairs)에서 torchvision.ops.box_iou와 비교합니다. 최대 절대 차이(max absolute difference)가 1e-6보다 작은지 확인합니다.
- 중간:
yolo_loss를 MSE 대신 CIoU 상자 손실(box loss)을 쓰는 버전으로 바꿉니다. 100장짜리 합성 데이터셋(synthetic dataset)에서 같은 에폭(epoch) 수로 CIoU가 MSE보다 더 나은 최종 mAP@0.5:0.95에 수렴하는지 보입니다.
- 어려움: 다중 스케일 추론(multi-scale inference)을 구현합니다. 같은 이미지를 세 해상도로 모델에 넣고, 상자 예측을 합친 뒤 마지막에 단일 NMS를 실행합니다. 보류 세트(held-out set)에서 단일 스케일 추론(single-scale inference) 대비 mAP 상승폭(lift)을 측정합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 앵커(Anchor) | 상자 사전값(box prior) | 각 격자 셀에 미리 정의한 상자 모양입니다. 네트워크는 절대 좌표가 아니라 이 모양에서의 델타를 예측합니다. |
| IoU | 겹침(overlap) | 두 상자의 교집합-합집합 비율(intersection-over-union)입니다. 검출에서 쓰는 범용 유사도 척도입니다. |
| NMS | 중복 제거(deduplicate) | 점수가 가장 높은 예측을 남기고 임계값보다 많이 겹치는 예측을 제거하는 탐욕 알고리즘(greedy algorithm)입니다. |
| 객체성(Objectness) | 여기에 무언가 있는가(is there something here) | 셀/앵커마다 객체 중심이 해당 셀에 있는지 예측하는 스칼라(scalar)입니다. |
| 격자 보폭(Grid stride) | 다운샘플 계수(downsample factor) | 격자 셀 하나가 몇 픽셀에 해당하는지 나타냅니다. 416픽셀 입력에 13x13 격자 헤드(13-grid head)를 쓰면 보폭(stride)은 32입니다. |
| mAP | 평균 정밀도(mean average precision) | 정밀도-재현율 곡선 아래 면적을 클래스와 COCO의 경우 IoU 임계값에 걸쳐 평균낸 지표입니다. |
| AP@0.5 | PASCAL VOC AP | IoU 임계값 0.5에서의 평균 정밀도입니다. 더 관대한 버전의 지표입니다. |
| mAP@0.5:0.95 | COCO AP | IoU 임계값 0.5부터 0.95까지 0.05 간격으로 평균낸 값입니다. 더 엄격한 버전이며 현재 커뮤니티 표준입니다. |
더 읽을거리