🧪 第14章 实作物件侦测 API
📄 一句话概括

前处理三件事挤在一行里: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. 把 [:, :, ::-1] 删掉再跑一次第四节,看框的位置有没有变。
  2. 把 CONF_THRESHOLD 改成 0.6,再跑第六节,看 NMS 之后剩几个。
  3. 想一想:为什么还原坐标时宽和高要用各自的比例?

常见误区

  1. 忘了转 CHW:形状对不上,直接报错(这条还算好)。
  2. 忘了归一化:分数全是 0.02,看起来像模型不行。
  3. 硬拉不按比例:目标变形,认不出。
  4. 忘了还原坐标:框全挤在左上角。
  5. 忘了 NMS:一个目标十几个框。
自测三问(点开对答案) 1. 一行里挤了哪三件事? —— BGR 转 RGB、HWC 转 CHW、除以 255 归一化。 2. 为什么宽和高要用各自的缩放比? —— 长宽比不同,两个方向的比例本来就不一样,用统一比例会把框拉歪。 3. NMS 的规则是什么? —— 按分数从高到低,留最高的,删掉跟它 IoU 超阈值的,再看下一个。

两头都通了,下一篇把它们和模型接起来,做成一个真的 API。

相关笔记