前处理三件事挤在一行里:BGR 翻 RGB、HWC 变 CHW、除以 255。写反不报错。 后处理第一步是还原坐标:乘 (原宽/416, 原高/416),漏了框全挤左上角。 NMS 按分数从高到低,留下最高的,删掉跟它 IoU 超阈值的。
这一篇对应教材 14.4 到 14.4.3:编写资料准备/前处理/后处理的程式码。 上一篇把壳立好了,这一篇填模型两头那两段——它们比模型本身更容易出错。
一、读图:中文路径那个坑
def load_image(path):
return cv2.imdecode(np.fromfile(path, dtype=np.uint8),
cv2.IMREAD_COLOR)
实测 cv2.imread(中文路径) 返回 None,load_image 返回形状 (720, 1280, 3)。
顺序是高、宽、通道。OpenCV 是 HWC,PyTorch 要 CHW。
这个转换在后面,别记混——记混了不会报错,只会出垃圾结果。
第 8 章讲过这个坑,这里再出现一次因为它真的会再踩一次:用户上传的文件名里带中文是常态,
而 secure_filename 洗完可能只剩扩展名。
二、缩放:按比例还是硬拉
| 做法 | 本课 | 真实项目 |
|---|---|---|
| 直接拉伸 | 用这个 | 会把 16:9 压成 1:1,猫被压扁 |
| letterbox | 没用 | 按比例缩放再补灰边,坐标还原要减偏移 |
实测 1280x720 缩到 416x416,两个方向的比例是 3.077 和 1.731——差得很远。 比例不同意味着「横向压缩得比纵向多」。模型看惯了的形状变了,瘦长的目标就会认不出。 本课用拉伸,是为了坐标换算只有一次乘法。教材和大多数 YOLO 实现用 letterbox。
letterbox 的代价是还原坐标时还要减掉补边的偏移量,多两个参数。 真项目里这点麻烦值得付——准确率比代码简洁重要。
三、那一行挤了三件事
chw = resized[:, :, ::-1].transpose(2, 0, 1).astype(np.float32) / 255.0
| 这一段 | 做什么 | 写错的后果 |
|---|---|---|
[:, :, ::-1] |
BGR 翻成 RGB | 分数偏低,但不报错 |
transpose(2, 0, 1) |
HWC 变 CHW | 直接报形状错 |
/ 255.0 |
归一化到 0~1 | 分数接近 0 |
astype(np.float32) |
整数转浮点 | 除法结果被截成 0 |
实测结果:形状 (3, 416, 416)、dtype 是 float32、取值范围 0.20~0.94。
取值范围不是 0 到 1,因为测试图里只有两种灰度:背景 50、亮块 240。
归一化只是换刻度,不会把数据撑满。
四行里只有 transpose 写错会报错(形状对不上),另外三行都是静默出错。
这就是为什么调模型时要一路打印形状和范围。
四、后处理第一步:还原坐标
| 阶段 | 框 |
|---|---|
| 模型输出(416 坐标系) | [98, 70, 71, 162] |
| 乘上缩放比(1280x720) | [302, 121, 218, 280] |
| 原图上那块亮矩形的真值 | (300, 120) 到 (520, 400) |
差两三个像素,是缩放和取整造成的。对上了,说明这条链路是通的。
漏了这一步,框全挤在左上角;乘错方向(用 416/原宽),框飞到画面外。
两种情况都不报错,你只会觉得「这个模型真差」。
公式很简单:x * 原宽/416、y * 原高/416、宽高同样处理。
但要注意宽和高用的是各自的比,不是同一个数——用统一的比例会把框拉歪。
五、后处理第二步:筛分数
实测四个框分数 0.91、0.55、0.28、0.12,阈值 0.30,留下两个。 阈值调低会多出很多「疑似」,调高会漏掉真目标。没有正确答案,只有取舍。 怎么定这个数?拿一批标注好的图,画一条「阈值—准确率/召回率」的曲线,看业务能接受哪种错。 这是另一个话题,但要知道 0.30 这个数不是天上掉的。
六、后处理第三步:NMS
| 框对 | IoU |
|---|---|
| #0 与 #1 | 0.932 |
| #0 与 #2 | 0.888 |
| #1 与 #2 | 0.914 |
四个框,三个高度重叠(IoU 都在 0.88 以上),一个在别处。NMS 之后剩 2 个。 规则:按分数从高到低排,留下最高的那个,把跟它 IoU 超过阈值的全删掉,再看下一个。 所以最后留的是分数最高那个(0.91)和那个孤立的(0.66)。 重叠的那两个被当成「同一个东西的重复检测」。
为什么必须做:模型自己不知道「这三个框说的是同一个亮块」。不做 NMS, 一张图上有几个目标它就给你几倍的框。 IoU 是「交集面积除以并集面积」。 0.93 意味着两个框几乎完全重合,0.45 的阈值意味着「重合超过四成五就当同一个」。
七、两头加起来,五条坑四条不报错
| 哪边 | 典型错法 | 症状 |
|---|---|---|
| 前处理 | 忘了归一化 | 分数全是 0.02 |
| 前处理 | RGB 与 BGR 弄反 | 分数偏低,但不报错 |
| 前处理 | 直接拉伸不按比例 | 瘦长目标认不出 |
| 后处理 | 忘了还原坐标 | 框全挤在左上角 |
| 后处理 | 忘了 NMS | 一个目标十几个框 |
五条里有四条不报错。这就是「模型是黑盒」的具体含义:喂错了它照样给你结果,只是结果是垃圾。 所以调试的办法只有一个:拿一张你知道答案的图,从读图开始,每一步都把形状和取值范围打出来。 这一篇的七个小节就是照这个顺序排的。 这也是为什么第 11 章那些测试值钱—— 把「这张图应该出两个框」写成断言,以后改前处理就不会悄悄坏掉。
八、动手 Demo:前处理与后处理:模型两头的活
这个 Demo 拿一张 1280x720 的图走完整条链路,每一步都打印形状和范围。 第四节最值得看:416 坐标系里的框乘回来,正好落在原图那块矩形上。
def scale_back(boxes, src_wh, inp_wh):
sx = src_wh[0] / inp_wh[0]
sy = src_wh[1] / inp_wh[1]
out = []
for b in boxes:
x, y, w, h = b["box"]
out.append(dict(b, box=[round(x * sx), round(y * sy),
round(w * sx), round(h * sy)]))
return out
实跑输出:
--------------------------------------------------------------------
前处理与后处理:模型两头的活
--------------------------------------------------------------------
① 读图:中文路径那个坑
--------------------------------------------------------------------
cv2.imread(中文路径) -> None
detector.load_image(同一个路径) -> 形状 (720, 1280, 3)
! 形状是 (720, 1280, 3):高、宽、通道。OpenCV 的顺序是 HWC,而 PyTorch 要的是 CHW。
这个转换在后面,别记混。
② 缩放:为什么要按比例
--------------------------------------------------------------------
原图 -> 1280x720
模型输入 -> 416x416
缩放比 -> x 3.077 / y 1.731
! 两个比例不一样(3.077 与 1.731),因为 1280x720 和 416x416 长宽比不同。
! 这里用的是直接拉伸。
教材和大多数 YOLO 实现用的是 letterbox——按比例缩到能放进去,剩下的空白补灰边。
! 差别很实在:直接拉伸会把 16:9 压成 1:1,猫会被压扁,模型认不出。
这一课用拉伸是为了坐标换算简单;真项目要用 letterbox,那时候还原坐标还要减掉补边的偏移量。
③ 形状与数值范围
--------------------------------------------------------------------
缩放后(HWC)-> (416, 416, 3)
转成 CHW -> (3, 416, 416)
dtype -> float32
取值范围 -> 0.20 ~ 0.94
! 原图是 uint8 的 0~255,现在是 float32 的 0~1。这一步叫归一化,几乎所有视觉模型都要。
! 取值范围不是从 0 到 1 而是 0.20 到 0.94,因为这张测试图里只有两种灰度:背景 50 和亮块 240。
归一化只是换了刻度,不会把数据撑满。
! 顺序上有三件事挤在一行里:[:, :, ::-1] 把 BGR 翻成 RGB、transpose(2,0,1) 把 HWC 变 CHW、
除以 255。写反一个,模型就给垃圾结果,而且不报错。
! 第 8 章那个替身不看这些(它只看灰度阈值),所以这一行在替身身上没用。
但换成真模型,它就是「能不能用」的分界线。
④ 后处理第一步:把坐标还原回去
--------------------------------------------------------------------
模型给的原话(在 416x416 坐标系里)->
spot score=0.415 box=[98, 70, 71, 162]
spot score=0.336 box=[20, 23, 45, 81]
乘上缩放比之后(回到 1280x720)->
spot score=0.415 box=[302, 121, 218, 280]
spot score=0.336 box=[62, 40, 138, 140]
! 这一步是所有「框位置不对」的根源。模型只在 416x416 里知道「框在这儿」,它不知道原图多大。
乘 (原宽/416, 原高/416) 才是原图坐标。
! 漏了这一步,框会全挤在左上角;乘错方向,框会飞到画面外。
而且两种情况都不报错,你只会觉得「这个模型真差」。
! 对照一下:原图那块亮矩形是从 (300, 120) 到 (520, 400),
乘回来之后位置基本对得上——差几个像素是缩放取整造成的。
⑤ 后处理第二步:筛掉低分的
--------------------------------------------------------------------
四个框,分数 0.91/0.55/0.28/0.12,阈值 0.30
留下 2 个:[0.91, 0.55]
! 阈值调低会多出很多「疑似」,调高会漏掉真目标。没有正确答案,只有取舍。
⑥ 后处理第三步:NMS 去掉重叠的
--------------------------------------------------------------------
四个框:三个高度重叠,一个在别处
两两 IoU(前三个之间)
#0 与 #1 -> 0.932
#0 与 #2 -> 0.888
#1 与 #2 -> 0.914
原始 -> 4 个
NMS 之后 -> 2 个:[ [100, 100, 200, 200], [500, 300, 120, 120]]
! NMS 的规则:按分数从高到低,留下最高的那个,把跟它 IoU 超过阈值的全删掉,再看下一个。
! 所以最后留的是分数最高那个(0.91)和那个孤立的(0.66)。
重叠的那两个被当成「同一个东西的重复检测」。
! 这一步为什么必须:模型自己不知道「这三个框说的是同一个亮块」。
不做 NMS,一张图上有几个目标它就给你几倍的框。
⑦ 前处理与后处理,谁更容易出错
--------------------------------------------------------------------
| 哪边 | 典型错法 | 症状 |
|---|---|---|
| 前处理 | 忘了归一化 | 分数全是 0.02 |
| 前处理 | RGB 与 BGR 弄反 | 分数偏低,但不报错 |
| 前处理 | 直接拉伸不按比例 | 瘦长目标认不出 |
| 后处理 | 忘了还原坐标 | 框全挤在左上角 |
| 后处理 | 忘了 NMS | 一个目标十几个框 |
! 五条里有四条不报错。这就是「模型是黑盒」的具体含义:喂错了它照样给你结果,只是结果是垃圾。
! 所以调试的办法只有一个:拿一张你知道答案的图,从读图开始,每一步都把形状和取值范围打出来。
这一节的七个步骤就是照着这个思路排的。
第二节 3.077 与 1.731 两个比例不一样,因为长宽比不同。 第四节 302 与真值 300、218 与真值 220:差两三个像素,链路是通的。 第六节三个 IoU 都在 0.88 以上,所以三个框只能留一个。
30 秒实验
- 把
[:, :, ::-1]删掉再跑一次第四节,看框的位置有没有变。 - 把
CONF_THRESHOLD改成 0.6,再跑第六节,看 NMS 之后剩几个。 - 想一想:为什么还原坐标时宽和高要用各自的比例?
常见误区
- 忘了转 CHW:形状对不上,直接报错(这条还算好)。
- 忘了归一化:分数全是 0.02,看起来像模型不行。
- 硬拉不按比例:目标变形,认不出。
- 忘了还原坐标:框全挤在左上角。
- 忘了 NMS:一个目标十几个框。
自测三问(点开对答案)
1. 一行里挤了哪三件事? —— BGR 转 RGB、HWC 转 CHW、除以 255 归一化。 2. 为什么宽和高要用各自的缩放比? —— 长宽比不同,两个方向的比例本来就不一样,用统一比例会把框拉歪。 3. NMS 的规则是什么? —— 按分数从高到低,留最高的,删掉跟它 IoU 超阈值的,再看下一个。两头都通了,下一篇把它们和模型接起来,做成一个真的 API。
- ☐ (本篇完,下一篇 14.3 模型执行与路由)