s12 目标检测 — exercise.py 练习指南
练习目标
通过手写目标检测的核心算法,深入理解 IoU、NMS、YOLO 坐标转换和 mAP 评估:
- 实现 IoU 计算 —— 理解交并比的数学原理和代码表达
- 实现 NMS 算法 —— 掌握去除冗余检测框的经典算法
- 掌握 YOLO 坐标转换 —— 归一化坐标
像素坐标 - 理解 mAP 计算流程 —— Precision、Recall 和 F1 在检测中的定义
预备知识
- IoU:
,衡量两个框的重叠程度 - NMS:贪心算法,保留置信度最高的框,移除与它 IoU 过高的框
- 边界框格式:
[x1, y1, x2, y2]:左上角 + 右下角(像素坐标)[cx, cy, w, h]:中心 + 宽高(归一化坐标)
- Precision:
—— 预测的框中,有多少是对的 - Recall:
—— 真实框中,有多少被找到了 - F1:
—— P 和 R 的调和平均
任务清单
练习 1:实现 IoU(交并比)计算
任务:在 compute_iou(box_a, box_b) 中实现两个边界框的 IoU 计算。
公式:
步骤提示:
1. 交集矩形的左上角 = (max(x1_a, x1_b), max(y1_a, y1_b))
2. 交集矩形的右下角 = (min(x2_a, x2_b), min(y2_a, y2_b))
3. 交集面积 = max(0, x2_inter - x1_inter) * max(0, y2_inter - y1_inter)
4. area_a = (x2_a - x1_a) * (y2_a - y1_a)
5. area_b = (x2_b - x1_b) * (y2_b - y1_b)
6. 并集面积 = area_a + area_b - 交集面积
7. IoU = 交集面积 / 并集面积(如果并集为0则返回0)代码框架:
python
def compute_iou(box_a, box_b):
x1_inter = max(box_a[0], box_b[0])
y1_inter = max(box_a[1], box_b[1])
x2_inter = min(box_a[2], box_b[2])
y2_inter = min(box_a[3], box_b[3])
inter_w = max(0, x2_inter - x1_inter)
inter_h = max(0, y2_inter - y1_inter)
inter_area = inter_w * inter_h
area_a = (box_a[2] - box_a[0]) * (box_a[3] - box_a[1])
area_b = (box_b[2] - box_b[0]) * (box_b[3] - box_b[1])
union_area = area_a + area_b - inter_area
if union_area <= 0:
return 0.0
return inter_area / union_area测试用例:
| box_a | box_b | 预期 IoU | 场景 |
|---|---|---|---|
| [10,10,50,50] | [10,10,50,50] | 1.0 | 完全重叠 |
| [10,10,50,50] | [60,60,100,100] | 0.0 | 完全不重叠 |
| [10,10,50,50] | [30,30,70,70] | ~0.143 | 部分重叠 |
几何直觉:
- 交集面积 =
(红色和绿色重叠的中间区域) - box1 面积 =
- box2 面积 =
- 并集面积 =
- IoU =
练习 2:实现 NMS(非极大值抑制)
任务:实现 NMS 算法。
为什么这个算法叫"非极大值抑制"? 在一个重叠框的集合中,我们只保留置信度"极大"的那个,抑制(丢弃)所有非极大的。这与图像处理中的非极大值抑制(如 Canny 边缘检测中只保留梯度方向上的局部最大值)理念一致。
算法伪代码:
Input: B = {b1,...,bN}, S = {s1,...,sN}, Nt (IoU阈值)
Output: D (保留的框)
D ← {}
while B ≠ {}:
m ← argmax(S) # 选置信度最高的框
M ← bm; D ← D ∪ {M}
B ← B \ {M} # 从待处理列表中移除
for bi in B:
if IoU(M, bi) ≥ Nt: # 如果与 M 重叠太多
B ← B \ {bi} # 移除
return D代码框架:
python
def nms(boxes, scores, iou_threshold=0.5):
if len(boxes) == 0:
return np.array([], dtype=np.int64)
order = scores.argsort()[::-1] # 按置信度降序
keep = []
while len(order) > 0:
idx = order[0]
keep.append(idx)
if len(order) == 1:
break
# 计算当前框与剩余框的 IoU
current_box = boxes[idx]
remaining_boxes = boxes[order[1:]]
# 计算所有剩余框与当前框的 IoU
ious = []
for i in range(len(remaining_boxes)):
ious.append(compute_iou(current_box, remaining_boxes[i]))
ious = np.array(ious)
# 保留 IoU ≤ threshold 的框
remaining_indices = np.where(ious <= iou_threshold)[0]
order = order[remaining_indices + 1]
return np.array(keep, dtype=np.int64)测试用例:
输入: 5 个框 + 置信度 [0.95, 0.82, 0.76, 0.88, 0.61]
- 框0 (置信度0.95): [100,100,200,200]
- 框1 (置信度0.82): [110,110,210,210] # 与框0高度重叠
- 框2 (置信度0.76): [105,105,195,195] # 与框0高度重叠
- 框3 (置信度0.88): [300,100,400,200] # 位置独立
- 框4 (置信度0.61): [115,115,205,205] # 与框0高度重叠
期望输出: [0, 3](保留框0和框3)NMS 的执行过程(逐步演示):
- 排序后:
order = [0, 3, 1, 2, 4] - 取框0 (0.95),保留。移除与框0 IoU>0.5 的框1,2,4。剩余:
order = [3] - 取框3 (0.88),保留。只剩一个框,结束。
- 输出:
[0, 3]
练习 3:YOLO 输出格式转换为像素坐标
任务:将 YOLO 的归一化输出 [cx, cy, w, h] 转换为像素坐标 [x1, y1, x2, y2]。
YOLO 坐标的含义:
cx, cy:中心点坐标,归一化到(相对于图像宽高) w, h:框的宽高,归一化到(相对于图像宽高)
转换步骤:
1. cx_pixel = cx * img_w # 中心 x 转为像素
2. cy_pixel = cy * img_h # 中心 y 转为像素
3. w_pixel = w * img_w # 宽度转为像素
4. h_pixel = h * img_h # 高度转为像素
5. x1 = cx_pixel - w_pixel/2 # 左上角
6. y1 = cy_pixel - h_pixel/2 # 左上角
7. x2 = cx_pixel + w_pixel/2 # 右下角
8. y2 = cy_pixel + h_pixel/2 # 右下角代码框架:
python
def yolo_to_pixel(boxes, img_w, img_h):
cx = boxes[:, 0]
cy = boxes[:, 1]
w = boxes[:, 2]
h = boxes[:, 3]
cx_pixel = cx * img_w
cy_pixel = cy * img_h
w_pixel = w * img_w
h_pixel = h * img_h
x1 = cx_pixel - w_pixel / 2
y1 = cy_pixel - h_pixel / 2
x2 = cx_pixel + w_pixel / 2
y2 = cy_pixel + h_pixel / 2
return np.stack([x1, y1, x2, y2], axis=1)测试用例:
python
# 输入: 两个 YOLO 归一化框
boxes = [[0.5, 0.5, 0.3, 0.4], # 中心在图像正中
[0.25, 0.75, 0.15, 0.2]] # 左下方小物体
img_w, img_h = 640, 480
# 期望输出(第一个框):
# cx_pixel=0.5*640=320, cy_pixel=0.5*480=240
# w_pixel=0.3*640=192, h_pixel=0.4*480=192
# x1=320-96=224, y1=240-96=144, x2=320+96=416, y2=240+96=336
# 结果: [224, 144, 416, 336]练习 4:评估检测器的 Precision 和 Recall
任务:理解并实现单类别的 Precision/Recall 计算。
在目标检测中:
| 符号 | 定义 | 判定条件 |
|---|---|---|
| TP(True Positive) | 正确检测 | 预测框与 GT 框 IoU > threshold 且类别正确 |
| FP(False Positive) | 误检 | 预测框没有匹配的 GT(IoU 不足或类别错误) |
| FN(False Negative) | 漏检 | GT 框没有被任何预测匹配到 |
关键规则:一个 GT 框只能被匹配一次("一个真值不能被重复算对")。
代码框架:
python
def compute_precision_recall(pred_boxes, pred_scores, pred_classes,
gt_boxes, gt_classes, iou_threshold=0.5):
TP = FP = 0
matched_gt = set() # 已被匹配的 GT 框索引
for i, (pred_box, pred_cls) in enumerate(zip(pred_boxes, pred_classes)):
best_iou = 0
best_gt_idx = -1
# 找最佳匹配的 GT 框
for j, (gt_box, gt_cls) in enumerate(zip(gt_boxes, gt_classes)):
if j in matched_gt:
continue # 已被匹配,跳过
if pred_cls != gt_cls:
continue # 类别不同,跳过
iou = compute_iou(pred_box, gt_box)
if iou > best_iou:
best_iou = iou
best_gt_idx = j
# 判断是否匹配
if best_iou >= iou_threshold and best_gt_idx >= 0:
TP += 1
matched_gt.add(best_gt_idx)
else:
FP += 1
FN = len(gt_boxes) - len(matched_gt) # 未被匹配的 GT 框
precision = TP / (TP + FP) if (TP + FP) > 0 else 0
recall = TP / (TP + FN) if (TP + FN) > 0 else 0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
return precision, recall, f1注意:本练习是简化的单类别单阈值版本。真正的 mAP 需要对每个类别、多个 IoU 阈值和多个置信度阈值分别计算,再取平均。COCO 标准的 mAP@0.5:0.95 需要在 10 个 IoU 阈值下各算一次 AP 再取平均。
完整代码
py
# -*- coding: utf-8 -*-
"""
s12 目标检测 练习
==================
完成以下 TODO 练习来加深对目标检测核心算法的理解:
IoU 计算、NMS(非极大值抑制)、YOLO 输出格式转换。
"""
import numpy as np
from typing import List, Tuple
# ============================================================
# 练习 1:实现 IoU(交并比)计算
# ============================================================
def compute_iou(box_a: np.ndarray, box_b: np.ndarray) -> float:
"""
TODO: 计算两个边界框的 IoU
边界框格式: [x1, y1, x2, y2] —— 左上角 + 右下角坐标
参数:
box_a: 第一个边界框,形状 (4,) → [x1, y1, x2, y2]
box_b: 第二个边界框,形状 (4,) → [x1, y1, x2, y2]
返回:
iou: IoU 值,范围 [0, 1]
公式:
IoU = area(A ∩ B) / area(A ∪ B)
提示:
1. 交集矩形的左上角 = (max(x1_a, x1_b), max(y1_a, y1_b))
2. 交集矩形的右下角 = (min(x2_a, x2_b), min(y2_a, y2_b))
3. 交集面积 = max(0, x2_inter - x1_inter) * max(0, y2_inter - y1_inter)
4. 并集面积 = area_a + area_b - 交集面积
5. 如果并集面积为 0,返回 0.0
"""
# TODO: 计算交集区域
# x1_inter = max(???, ???) # 交集左上角 x
# y1_inter = max(???, ???) # 交集左上角 y
# x2_inter = min(???, ???) # 交集右下角 x
# y2_inter = min(???, ???) # 交集右下角 y
# inter_area = ???
# TODO: 计算各自的面积
# area_a = ???
# area_b = ???
# TODO: 计算并集面积和 IoU
# union_area = ???
# iou = ??? if union_area > 0 else 0.0
return 0.0 # 替换为你的实现
# ============================================================
# 练习 2:实现 NMS(非极大值抑制)
# ============================================================
def nms(boxes: np.ndarray, scores: np.ndarray,
iou_threshold: float = 0.5) -> np.ndarray:
"""
TODO: 实现非极大值抑制(NMS)算法
参数:
boxes: 边界框数组,形状 (N, 4),格式 [x1, y1, x2, y2]
scores: 置信度得分数组,形状 (N,)
iou_threshold: IoU 阈值,高于此值的框被抑制
返回:
keep: 保留的框的索引数组
算法伪代码:
Input: B = {b1,...,bN}, S = {s1,...,sN}, Nt (IoU threshold)
Output: D (selected boxes)
D ← {}
while B ≠ {}:
m ← argmax(S) # 选置信度最高的框
M ← b_m # 该框
D ← D ∪ {M}; B ← B - {M} # 保存并从 B 中移除
for b_i in B:
if IoU(M, b_i) >= Nt: # 如果与 M 重叠太多
B ← B - {b_i} # 移除该框
return D
提示:
1. 用 argsort()[::-1] 按置信度降序排列
2. 用循环实现上述伪代码
3. 使用练习 1 中的 compute_iou 计算重叠
4. 注意处理边界情况(空输入)
"""
if len(boxes) == 0:
return np.array([], dtype=np.int64)
# TODO: 按置信度降序排序
# order = ???
# TODO: 逐步选出最高分框并移除重叠框
keep = [] # 存放保留的索引
# while len(order) > 0:
# idx = order[0] # 当前最高分框
# keep.append(idx)
# if len(order) == 1:
# break
# # 计算当前框与剩余框的 IoU
# # 保留 IoU <= threshold 的框
# order = ???
return np.array(keep, dtype=np.int64)
# ============================================================
# 练习 3:YOLO 输出格式 → 像素坐标转换
# ============================================================
def yolo_to_pixel(boxes: np.ndarray, img_w: int, img_h: int) -> np.ndarray:
"""
TODO: 将 YOLO 的归一化输出转换为像素坐标
YOLO 输出格式(归一化):
- (x, y, w, h),其中:
- x, y: 边界框中心坐标,归一化到 [0, 1](相对于图像宽高)
- w, h: 边界框宽高,归一化到 [0, 1](相对于图像宽高)
转换为像素格式:
- [x1, y1, x2, y2],其中:
- (x1, y1): 左上角,像素坐标
- (x2, y2): 右下角,像素坐标
参数:
boxes: YOLO 归一化框,形状 (N, 4),格式 [cx, cy, w, h](归一化)
img_w: 图像宽度(像素)
img_h: 图像高度(像素)
返回:
boxes_pixel: 像素坐标框,形状 (N, 4),格式 [x1, y1, x2, y2]
转换步骤:
1. cx_pixel = cx * img_w # 中心 x 转为像素
2. cy_pixel = cy * img_h # 中心 y 转为像素
3. w_pixel = w * img_w # 宽度转为像素
4. h_pixel = h * img_h # 高度转为像素
5. x1 = cx_pixel - w_pixel/2 # 左上角 x
6. y1 = cy_pixel - h_pixel/2 # 左上角 y
7. x2 = cx_pixel + w_pixel/2 # 右下角 x
8. y2 = cy_pixel + h_pixel/2 # 右下角 y
"""
# TODO: 从输入中提取 cx, cy, w, h
# cx = boxes[:, 0]
# cy = boxes[:, 1]
# w = boxes[:, 2]
# h = boxes[:, 3]
# TODO: 转换为像素坐标
# cx_pixel = ???
# cy_pixel = ???
# w_pixel = ???
# h_pixel = ???
# TODO: 计算 x1, y1, x2, y2
# x1 = ???
# y1 = ???
# x2 = ???
# y2 = ???
# TODO: 堆叠为 (N, 4) 格式
# boxes_pixel = np.stack([x1, y1, x2, y2], axis=1)
return None # 替换为你的实现
# ============================================================
# 练习 4:评估检测器的 mAP(概念实现)
# ============================================================
def compute_precision_recall(pred_boxes: np.ndarray,
pred_scores: np.ndarray,
pred_classes: np.ndarray,
gt_boxes: np.ndarray,
gt_classes: np.ndarray,
iou_threshold: float = 0.5) -> Tuple[float, float, float]:
"""
TODO: 计算目标检测的 Precision 和 Recall(简化版)
这是理解 mAP 计算的基础。对于单个类别,给定一组预测和 ground truth:
True Positive (TP): 预测框与某个 GT 框的 IoU > threshold,且类别正确
False Positive (FP): 预测框没有匹配的 GT 框(IoU 不足或类别错误)
False Negative (FN): GT 框没有被任何预测框匹配到
Precision = TP / (TP + FP) —— 预测的框中,有多少是对的
Recall = TP / (TP + FN) —— GT 框中,有多少被找到了
F1 = 2 * P * R / (P + R) —— 两者的调和平均
参数:
pred_boxes: 预测框,形状 (N_pred, 4), [x1, y1, x2, y2]
pred_scores: 预测置信度,形状 (N_pred,)
pred_classes: 预测类别,形状 (N_pred,)
gt_boxes: 真实框,形状 (N_gt, 4), [x1, y1, x2, y2]
gt_classes: 真实类别,形状 (N_gt,)
iou_threshold: 匹配的 IoU 阈值
返回:
(precision, recall, f1_score)
提示:
1. 对每个预测框,检查它是否与某个 GT 框匹配(IoU > threshold 且类别相同)
2. 一个 GT 框只能被匹配一次(需要标记已匹配的 GT 框)
3. 按上述定义统计 TP, FP, FN
"""
# TODO: 实现 Precision/Recall 计算
# TP = 0 # 正确检测
# FP = 0 # 误检
# FN = 0 # 漏检
# matched_gt = set() # 已被匹配的 GT 框索引
# for each prediction:
# 找到最佳匹配的 GT 框
# 如果 IoU > threshold 且类别正确且该 GT 未被匹配:
# TP += 1, 标记该 GT 为已匹配
# 否则:
# FP += 1
# FN = 未被匹配的 GT 框数量
# precision = TP / (TP + FP) if TP + FP > 0 else 0
# recall = TP / (TP + FN) if TP + FN > 0 else 0
return 0.0, 0.0, 0.0 # 替换为你的实现
# ============================================================
# 测试代码
# ============================================================
if __name__ == "__main__":
print("=" * 50)
print("s12 目标检测 — 练习测试")
print("=" * 50)
# ---- 测试练习 1:IoU 计算 ----
print("\n[练习 1] IoU 计算测试:")
# 测试用例
test_cases = [
([10, 10, 50, 50], [10, 10, 50, 50], 1.0, "完全重叠"),
([10, 10, 50, 50], [60, 60, 100, 100], 0.0, "完全不重叠"),
([10, 10, 50, 50], [30, 30, 70, 70], 0.1428, "部分重叠"),
]
for box_a, box_b, expected, desc in test_cases:
iou = compute_iou(np.array(box_a), np.array(box_b))
status = "✓" if abs(iou - expected) < 0.01 else "✗"
print(f" {status} {desc}: IoU = {iou:.4f} (期望 {expected})")
# ---- 测试练习 2:NMS ----
print("\n[练习 2] NMS 测试:")
boxes = np.array([
[100, 100, 200, 200],
[110, 110, 210, 210],
[105, 105, 195, 195],
[300, 100, 400, 200],
[115, 115, 205, 205],
], dtype=np.float32)
scores = np.array([0.95, 0.82, 0.76, 0.88, 0.61], dtype=np.float32)
keep = nms(boxes, scores, iou_threshold=0.5)
if keep is not None and len(keep) > 0:
print(f" 保留的框索引: {keep}")
print(f" 保留的置信度: {scores[keep]}")
# 期望: [0, 3](框0最高分 + 框3位置不同)
if set(keep) == {0, 3}:
print(f" ✓ 结果正确!保留框0和框3")
else:
print(f" 期望保留索引 [0, 3],但得到 {keep}")
else:
print(" 请完成 NMS 实现")
# ---- 测试练习 3:YOLO 格式转换 ----
print("\n[练习 3] YOLO 格式转换测试:")
# YOLO 归一化输出: [cx, cy, w, h]
yolo_boxes = np.array([
[0.5, 0.5, 0.3, 0.4], # 中心在图像正中
[0.25, 0.75, 0.15, 0.2], # 左下方小物体
])
img_w, img_h = 640, 480
result = yolo_to_pixel(yolo_boxes, img_w, img_h)
if result is not None:
print(f" 输入 (归一化):\n{yolo_boxes}")
print(f" 输出 (像素):\n{result}")
# 验证第一个框
# cx=0.5*640=320, cy=0.5*480=240, w=0.3*640=192, h=0.4*480=192
# x1=320-96=224, y1=240-96=144, x2=320+96=416, y2=240+96=336
expected_first = [224, 144, 416, 336]
if np.allclose(result[0], expected_first):
print(f" ✓ 第一个框转换正确: {result[0]}")
else:
print(f" 期望: {expected_first}")
print(f" 实际: {result[0]}")
else:
print(" 请完成 yolo_to_pixel 实现")
print("\n" + "=" * 50)
print("完成所有练习后,运行 demo.py 查看完整的检测演示。")
print("=" * 50)