YOLO 目标检测自学教程 07:YOLOv3——多尺度预测

0 阅读4分钟

系列导航:上一篇:06 YOLOv2——锚框与直接位置预测 | 下一篇:08 YOLOv4 / v5——工程集大成

本篇目标:读完本篇,你将能够——

  • 说出 YOLOv3 相对 YOLOv2 的改动:3 个尺度的预测、特征金字塔、9 个锚框、独立的 sigmoid 分类、Darknet-53;
  • 解释为什么一个尺度的特征图难以同时检测大物体和小物体,以及“自顶向下 + 拼接”的特征金字塔怎样解决这个问题;
  • 写出 YOLOv3 的标签分配规则和损失函数,推导二元交叉熵对 logit 的梯度 σ(t)−x^\sigma(t) - \hat{x};
  • 计算按物体大小分的 AP(APS\mathrm{AP}_S、APM\mathrm{AP}_M、APL\mathrm{AP}_L),并用 pycocotools 核对;
  • 把第 06 篇的 mini-YOLOv2 改造成 3 个尺度的 mini-YOLOv3,用实验检验每一项改动。

前置知识:第 01 篇(感受野、残差连接)、第 03 篇(mAP)、第 06 篇(锚框、k-means、直接位置预测、YOLOv2 的损失)。

2018 年,Redmon 和 Farhadi 发布了一份技术报告 YOLOv3: An Incremental Improvement。正如标题所说,它没有提出全新的思想,而是把当时别人的好想法(特征金字塔、残差网络、多标签分类)组合进 YOLO。它的语气相当随意,但影响很大:3 个尺度的预测从此成为 YOLO 的标准结构,此后的 v4、v5 直到 v8 都沿用了“骨干 + 特征金字塔 + 多尺度检测头”的框架。

本篇的重点是多尺度预测。为了让它的作用看得出来,本篇换了一个物体大小范围更宽的数据集:物体边长从 6 像素到 64 像素,相差 10 倍以上(第 05、06 篇是 18~48 像素)。本篇的数字全部来自实际训练:附录 A 的 example.py 训练 mini-YOLOv3 并做分析,附录 B 的 path_to_v3.py 从 mini-YOLOv2 出发逐项比较,附录 C 的 verify_with_pycocotools.py 用 pycocotools 核对按大小分的 AP。

一、YOLOv2 留下的问题

YOLOv2 在 VOC 上已经很强,但在 COCO 上暴露了一个弱点:小物体。YOLOv3 论文表 3 列出了 YOLOv2 在 COCO test-dev 上的成绩:AP 21.6,AP50 44.0,其中小物体的 APS\mathrm{AP}_S 只有 5.0,而大物体的 APL\mathrm{AP}_L 有 35.5。COCO 按面积把物体分成三类(第 03 篇):面积小于 32232^2 像素的是小物体,32232^2 到 96296^2 之间的是中物体,大于 96296^2 的是大物体。COCO 里小物体占了约 41%,检测不好它们,总的 AP 就上不去。

原因不难理解。YOLOv2 只在一个 13×13 的特征图上预测,总步长 32。一个 20×2020 \times 20 像素的物体,在特征图上还不到一个格子的 2/32/3;它的细节在 5 次下采样中几乎全部丢失。passthrough 层(第 06 篇第八节)把 26×26 的特征叠了进来,但只叠了一层,作用有限。

此外还有两个较小的问题:

  • 每个格子只有 5 个锚框,同一格子里中心靠得很近、形状又相似的多个小物体,会争同一个锚框(第 06 篇的 build_targets 把这种冲突的物体直接丢掉);
  • 类别用 softmax,假设每个框只属于一个类别。在类别有包含关系的数据集上(“人”和“女人”),这个假设不成立。

二、YOLOv3 的改动一览

部分YOLOv2YOLOv3
预测的尺度1 个(13×13,步长 32),加 passthrough3 个(13×13、26×26、52×52,步长 32、16、8)
特征融合passthrough:26×26 的特征重排后拼接特征金字塔:深层特征上采样后,与浅层特征拼接
锚框5 个 k-means 锚框9 个 k-means 锚框,每个尺度 3 个
框的参数化bx=σ(tx)+cxb_x = \sigma(t_x) + c_x,bw=pwetwb_w = p_w e^{t_w}相同
置信度的目标预测框与真实框的 IoU1(负责的锚框)
类别softmax,交叉熵每个类别一个独立的 sigmoid,二元交叉熵
骨干Darknet-19(19 个卷积层)Darknet-53(53 层,带残差连接)

输入 416×416、COCO 的 80 个类别时,3 个尺度的输出张量是

13×13×255,26×26×255,52×52×255,255=3×(4+1+80).13 \times 13 \times 255, \qquad 26 \times 26 \times 255, \qquad 52 \times 52 \times 255, \qquad 255 = 3 \times (4 + 1 + 80) .

每张图一共预测 (132+262+522)×3=10647(13^2 + 26^2 + 52^2) \times 3 = 10647 个框,是 YOLOv2(132×5=84513^2 \times 5 = 845)的 12 倍多。

论文表 3(COCO test-dev)中的部分结果:

方法骨干APAP50AP75APS\mathrm{AP}_SAPM\mathrm{AP}_MAPL\mathrm{AP}_L
YOLOv2Darknet-1921.644.019.25.022.435.5
SSD513ResNet-10131.250.433.310.234.549.8
RetinaNetResNet-101-FPN39.159.142.321.842.750.2
YOLOv3 608×608Darknet-5333.057.934.418.335.441.9

APS\mathrm{AP}_S 从 5.0 跳到了 18.3。速度方面,论文(在 M40 或 Titan X 上)测得:YOLOv3-320 每张 22 ms,AP 28.2;YOLOv3-416 每张 29 ms,AP 31.0;YOLOv3-608 每张 51 ms,AP 33.0,AP50 57.9。作为对比,RetinaNet-101-800 的 AP50 是 57.5,每张要 198 ms,慢 3.8 倍。

三、为什么要在多个尺度上预测

1. 一个尺度的两难

卷积网络越深,特征图的分辨率越低,但每个位置的感受野越大(第 01 篇第七节),看到的上下文越多,语义信息越丰富。这带来一个两难:

  • 检测大物体需要大的感受野。一个 60 像素宽的物体,如果某一层的感受野只有 30 像素,这一层的任何一个位置都看不全它。
  • 检测小物体需要高的分辨率。一个 8 像素宽的物体,在步长 32 的特征图上只占 1/41/4 个格子,它的形状细节早已在下采样中被平均掉了;而且一个格子里可能挤着好几个这样的小物体。

只用最后一层(YOLOv1、v2)能照顾大物体,却牺牲了小物体;只用浅层则反过来。

2. 三种多尺度的做法

fig1_architecture.png

图 1:本篇 mini-YOLOv3 的网络结构。左列是骨干,分辨率逐级减半;中列是特征金字塔,紫色箭头是自顶向下的路径;右列是 3 个尺度的检测头。

在多个尺度上检测,历史上有三种做法:

  1. 图像金字塔:把图像缩放成几种尺寸,分别送进网络。效果好,但计算量成倍增加。
  2. 特征层级(SSD):直接在骨干的几个不同深度的特征图上分别预测。浅层负责小物体,深层负责大物体,几乎不增加计算量。问题在于浅层特征的语义太弱:它们只经过了几层卷积,还“认不出”物体是什么。
  3. 特征金字塔(FPN,Lin 等,2017):在特征层级的基础上增加一条自顶向下的路径。把深层的特征上采样,与浅层的特征融合,于是每个尺度的特征都同时具备深层的语义和浅层的分辨率。

YOLOv3 用的是第 3 种,只是融合的方式与 FPN 原文略有不同:FPN 把上采样后的特征与浅层特征逐元素相加,YOLOv3 则沿通道方向拼接(concatenate)。

四、YOLOv3 的网络结构

论文对结构的描述很简短:在骨干之后加几个卷积层,最后一层输出第一个尺度的预测;然后取“往前两层”的特征图,上采样 2 倍,与网络较早的一层特征图拼接,再经过几个卷积层,输出第二个尺度的预测;同样的设计再做一次,得到第三个尺度。

结合 darknet 的配置文件 yolov3.cfg,输入 416×416 时具体是:

  1. Darknet-53 输出 13×13×1024 的特征,经过 5 个交替的 1×1、3×3 卷积,再经过一个 3×3 卷积和一个 1×1 卷积,输出 13×13×255,负责大物体;
  2. 从那 5 个卷积的输出引出一支,用 1×1 卷积把通道压到 256,上采样到 26×26,与骨干中 26×26×512 的特征拼接成 26×26×768;同样经过 5 + 2 个卷积,输出 26×26×255,负责中物体;
  3. 再引出一支,压到 128 个通道,上采样到 52×52,与骨干中 52×52×256 的特征拼接;输出 52×52×255,负责小物体。

上采样用最近邻插值,没有参数。本篇的 mini-YOLOv3(图 1)是它的缩小版:输入 128×128,三个尺度是 16×16(步长 8)、8×8(步长 16)、4×4(步长 32);每个尺度的特征只经过一个 3×3 卷积,检测头是一个 1×1 卷积,输出 3×(5+3)=243 \times (5 + 3) = 24 个通道。

五、9 个锚框,分给 3 个尺度

YOLOv3 仍然用 k-means 聚类锚框(第 06 篇第五节),只是聚类成 9 个,按面积从小到大排好,平均分给 3 个尺度。论文说这个选择“相当随意”。COCO 上的 9 个锚框(宽 × 高,像素,输入 416)是:

尺度网格锚框
步长 852×5210×13、16×30、33×23
步长 1626×2630×61、62×45、59×119
步长 3213×13116×90、156×198、373×326

换算成各自尺度的格子,步长 8 的三个锚框是 1.25×1.62、2.00×3.75、4.12×2.88 个格子,而步长 32 的最大一个是 11.66×10.19 个格子,几乎覆盖整张图(练习 5)。COCO 的物体大小分布很广,三个尺度的锚框以格子计也相差很大。

本篇的数据上(附录代码第 1 部分),训练集 3000 张图共有 13501 个框,边长(面积的平方根)从 6.0 到 64.0 像素。9 个 k-means 锚框是:

尺度网格锚框(宽×高,像素)以本尺度的格子为单位
步长 816×167.6×7.8、11.8×12.0、17.4×18.10.95×0.97、1.48×1.50、2.18×2.26
步长 168×822.6×28.8、30.0×24.5、32.1×41.21.41×1.80、1.88×1.53、2.01×2.57
步长 324×443.0×34.6、46.3×59.7、60.8×48.51.34×1.08、1.45×1.86、1.90×1.52

fig2_anchors.png

图 2:左:训练集所有框的宽高(对数坐标),按负责的尺度着色,★ 是 9 个锚框。右:物体边长的分布,按负责的尺度堆叠;两条虚线是本篇 APS\mathrm{AP}_S、APM\mathrm{AP}_M、APL\mathrm{AP}_L 的分界(第九节)。图例里的个数没有扣除第六节所说因冲突被丢掉的 2 个物体,所以步长 32 是 3985 个,比正文多 2 个。

表的最后一列值得留意:以各自的格子为单位,三个尺度的锚框都在 1~2.6 个格子之间。也就是说,在每个尺度看来,“物体比格子大多少”都差不多,三个检测头面对的几乎是同一个任务,只是图像被缩小了不同的倍数。这正是多尺度预测的出发点:与其让一个检测头同时应付 6 像素和 64 像素的物体,不如让每个尺度只负责相对自己大小合适的物体。

平均 IoU 随锚框个数增加而提高:k=3k = 3 时 0.663,k=5k = 5 时 0.737,k=9k = 9 时 0.789。

六、标签分配:每个物体只交给一个锚框

YOLOv3 的分配规则只比 YOLOv2 多了一步“选尺度”:

  1. 计算真实框与全部 9 个锚框的宽高 IoU(中心对齐),取 IoU 最大的那个锚框;
  2. 这个锚框属于哪个尺度,就由那个尺度负责;在该尺度的网格上,物体中心所在的格子、这个锚框负责预测它;
  3. 其余所有锚框都不负责这个物体。

所以一个物体只有一个正样本。论文强调了这一点,并与 Faster R-CNN 对比:Faster R-CNN 把 IoU 大于 0.7 的锚框都当作正样本、小于 0.3 的当作负样本、中间的忽略;论文说他们试过这种“双阈值”的做法,没能得到好结果。

不负责的锚框只计算置信度损失,目标是 0。但有一个例外,与 YOLOv2 相同:如果某个不负责的锚框的预测框与任意真实框的 IoU 超过阈值,就忽略它,不计置信度损失。论文写的阈值是 0.5,darknet 的配置文件 yolov3.cfg 里是 0.7(ignore_thresh = .7)。附录代码用论文的 0.5。

fig3_three_scales.png

图 3:同一张图像在三个尺度上的标签分配。小物体由 16×16 网格(步长 8)负责,大物体由 4×4 网格(步长 32)负责。白色点线是负责的锚框,画在格子中心:它的形状与物体相近,网络只需要预测一个小的修正。

附录代码第 2 部分统计了训练集上的分配结果:步长 8 负责 5647 个物体,步长 16 负责 3869 个,步长 32 负责 3983 个。从图 2 右边可以看出,边长约 20 像素以下的物体都归步长 8,约 20~44 像素的归步长 16,32 像素以上的归步长 32。分界处有重叠,因为分配看的是宽高 IoU,面积相同、形状不同的物体可能被分到不同的尺度。

多尺度也减少了冲突。如果两个物体的中心落在同一个格子,又选中了同一个锚框,只能保留一个。按第 06 篇的做法(只用步长 16、5 个锚框),训练集上有 13 个物体因冲突被丢掉;按 YOLOv3 的做法只丢掉 2 个,因为小物体被分到了 16×16 的细网格上,挤进同一个格子的机会小得多。本篇的数据里物体不算密集,在人群、鸟群这样的场景里,这个差别会大得多。

七、损失函数

1. 坐标:平方误差,还是二元交叉熵?

论文对坐标损失只有一句话:训练时用平方误差(sum of squared error),梯度是“目标值减预测值”t^∗−t∗\hat{t}_* - t_*。可 darknet 的实际做法有一个容易忽略的细节。

xx、yy 的输出要先经过 sigmoid,即 σ(tx)\sigma(t_x)。darknet 的 yolo_layer.c 计算的“梯度”是 x^−σ(tx)\hat{x} - \sigma(t_x),并且直接把它传给前一层,没有再乘 sigmoid 的导数 σ′(tx)\sigma'(t_x)。如果损失真的是平方误差 12(σ(tx)−x^)2\frac{1}{2}(\sigma(t_x) - \hat{x})^2,对 txt_x 的梯度应该是

∂∂tx12(σ(tx)−x^)2=(σ(tx)−x^)σ(tx)(1−σ(tx)),\frac{\partial}{\partial t_x} \tfrac{1}{2}\left(\sigma(t_x) - \hat{x}\right)^2 = \left(\sigma(t_x) - \hat{x}\right) \sigma(t_x)\left(1 - \sigma(t_x)\right) ,

多了一个因子 σ(1−σ)\sigma(1 - \sigma)。而二元交叉熵(binary cross-entropy,BCE)

BCE(σ(t),x^)=−[x^ln⁡σ(t)+(1−x^)ln⁡(1−σ(t))]\mathrm{BCE}(\sigma(t), \hat{x}) = -\left[ \hat{x} \ln \sigma(t) + (1 - \hat{x}) \ln\left(1 - \sigma(t)\right) \right]

对 tt 的梯度是(利用 σ′(t)=σ(t)(1−σ(t))\sigma'(t) = \sigma(t)(1 - \sigma(t)))

∂BCE∂t=−[x^σ(1−σ)σ−(1−x^)σ(1−σ)1−σ]=−[x^(1−σ)−(1−x^)σ]=  σ(t)−x^  .\frac{\partial \mathrm{BCE}}{\partial t} = -\left[ \hat{x} \frac{\sigma(1-\sigma)}{\sigma} - (1 - \hat{x}) \frac{\sigma(1-\sigma)}{1 - \sigma} \right] = -\left[\hat{x}(1 - \sigma) - (1 - \hat{x})\sigma\right] = \boxed{\;\sigma(t) - \hat{x}\;} .

正好是 darknet 传回去的那个量(差一个负号,因为 darknet 存的是负梯度)。所以 darknet 的 YOLOv3 对 xx、yy 实际上用的是二元交叉熵,对 ww、hh 用的是平方误差 12(tw−t^w)2\frac{1}{2}(t_w - \hat{t}_w)^2。注意 BCE 的目标 x^∈[0,1)\hat{x} \in [0, 1) 不一定是 0 或 1,这时 BCE 的最小值不是 0,而是 x^\hat{x} 的二元熵 −[x^ln⁡x^+(1−x^)ln⁡(1−x^)]-[\hat{x} \ln \hat{x} + (1 - \hat{x}) \ln(1 - \hat{x})];但它的梯度在 σ(t)=x^\sigma(t) = \hat{x} 处为 0,最优解不变。

两种损失的差别在 σ\sigma 饱和的时候最明显:

fig4_bce.png

图 4:左:同一组 logit,softmax 的三个概率之和必须为 1,sigmoid 让每个类别独立。右:目标 x^=0.9\hat{x} = 0.9 时,两种损失对 tt 的梯度。tt 很负时(预测 σ≈0\sigma \approx 0,错得很离谱),平方误差的梯度几乎为 0,BCE 的梯度却接近 −0.9-0.9。

平方误差的梯度带着因子 σ(1−σ)\sigma(1 - \sigma):预测越接近 0 或 1,梯度越小,哪怕预测是错的。这正是 sigmoid 饱和造成的梯度消失。BCE 的梯度 σ−x^\sigma - \hat{x} 只看“错了多少”,没有这个问题。

2. 置信度:目标是 1

YOLOv3 的置信度用 logistic 回归(sigmoid + BCE):负责的锚框目标是 1,不负责(且没有被忽略)的锚框目标是 0。这与 YOLOv1、v2 不同,它们的目标是预测框与真实框的 IoU(第 04 篇第三节)。

于是 YOLOv3 的置信度不再估计“框得有多准”,只回答“这里有没有物体”。这让训练更简单(目标是固定的 0 和 1,不随网络的预测而变化),代价是检测框的分数与定位质量无关。第 10 篇会看到,后来的 Quality Focal Loss、Varifocal Loss 又把 IoU 加了回来。

3. 类别:独立的 sigmoid

YOLOv3 不再用 softmax,而是给每个类别一个独立的 sigmoid,用二元交叉熵训练:

Lcls=−∑c=1C[ycln⁡pc+(1−yc)ln⁡(1−pc)],pc=σ(sc).\mathcal{L}_{\text{cls}} = -\sum_{c=1}^{C} \left[ y_c \ln p_c + (1 - y_c) \ln(1 - p_c) \right], \qquad p_c = \sigma(s_c) .

论文的理由是:在 Open Images 这样的数据集上,很多标签是重叠的(比如“女人”和“人”),softmax 假设每个框恰好属于一个类别,这在这些数据上不成立。用 sigmoid,一个框可以同时属于多个类别。论文还说,在 COCO 这种类别互斥的数据上,softmax 并不是取得好成绩所必需的。

图 4 左边是附录代码第 2 部分的例子:logit 为 (3,2.5,−2)(3, 2.5, -2) 时,softmax 给出 (0.620,0.376,0.004)(0.620, 0.376, 0.004),“人”和“女人”只能分享一份概率;sigmoid 给出 (0.953,0.924,0.119)(0.953, 0.924, 0.119),两者都接近 1。

4. 完整的损失

把三部分合起来(darknet 的做法,所有项的权重都是 1):

L=∑负责(2−wh)[BCE(σ(tx),x^)+BCE(σ(ty),y^)+12(tw−t^w)2+12(th−t^h)2]+∑负责BCE(σ(to),1)+∑不负责,IoU<0.5BCE(σ(to),0)+∑负责∑cBCE(σ(sc),yc),\begin{aligned} \mathcal{L} = & \sum_{\text{负责}} (2 - w h) \left[ \mathrm{BCE}(\sigma(t_x), \hat{x}) + \mathrm{BCE}(\sigma(t_y), \hat{y}) + \tfrac{1}{2}(t_w - \hat{t}_w)^2 + \tfrac{1}{2}(t_h - \hat{t}_h)^2 \right] \\ & + \sum_{\text{负责}} \mathrm{BCE}(\sigma(t_o), 1) + \sum_{\text{不负责,IoU} < 0.5} \mathrm{BCE}(\sigma(t_o), 0) + \sum_{\text{负责}} \sum_{c} \mathrm{BCE}(\sigma(s_c), y_c) , \end{aligned}

其中 t^w=ln⁡(w/pw)\hat{t}_w = \ln(w / p_w),(2−wh)(2 - wh) 与 YOLOv2 一样给小物体更大的权重(w,hw, h 是归一化的真实宽高),三个尺度的损失直接相加。和 YOLOv2 相比,λcoord\lambda_{\text{coord}}、λobj\lambda_{\text{obj}}、λnoobj\lambda_{\text{noobj}} 都没有了。

5. 两处实现上的选择

附录代码在 darknet 的做法之外,做了两处改动。它们不是 YOLOv3 论文的内容,但在本篇的设置下必不可少。

置信度偏置的初始化。mini-YOLOv3 每张图有 1008 个预测框,平均只有约 4.5 个负责物体。网络刚初始化时输出接近 0,所有置信度都是 σ(0)=0.5\sigma(0) = 0.5,“无物体”一项的损失约为 1008×ln⁡2≈6991008 \times \ln 2 \approx 699,而“有物体”一项只有约 4.5×ln⁡2≈3.14.5 \times \ln 2 \approx 3.1。训练初期几乎所有的梯度都在把置信度压低。RetinaNet(Lin 等,2017)的做法是把置信度那一路输出的偏置初始化为 b=ln⁡π1−πb = \ln\frac{\pi}{1 - \pi},取 π=0.01\pi = 0.01,让一开始所有框的置信度都约为 0.01。这时“无物体”一项的初始损失降到 1008×(−ln⁡0.99)≈10.11008 \times (-\ln 0.99) \approx 10.1。Ultralytics 的 YOLOv3、v5 也用了类似的初始化。

学习率。第 05、06 篇的最大学习率是 10−310^{-3};本篇的网络有 3 个尺度、预测框多了 15 倍,用 10−310^{-3} 训练 30 轮明显不够。附录代码用 3×10−33 \times 10^{-3},第十节有对比。

八、Darknet-53

YOLOv3 换了一个更大的骨干 Darknet-53。它保留了 Darknet-19 交替使用 3×3 和 1×1 卷积的风格,又借鉴了 ResNet 的残差连接(第 01 篇第十一节):每个残差块是一个把通道减半的 1×1 卷积和一个恢复通道的 3×3 卷积,输出与输入相加。下采样不再用最大池化,而是用步长为 2 的 3×3 卷积。五次下采样之后分别接 1、2、8、8、4 个残差块。论文说它有 53 个卷积层:开头的 1 个卷积、5 个下采样卷积和 23 个残差块里的 46 个卷积,共 52 个,再加上最后做分类的全连接层(darknet 里全连接层相当于 1×1 卷积),正好 53 层。

论文表 2(ImageNet,输入 256×256,Titan X):

骨干Top-1Top-5运算量(十亿次)每秒浮点运算(十亿次)FPS
Darknet-1974.191.87.291246171
ResNet-10177.193.719.7103953
ResNet-15277.693.829.4109037
Darknet-5377.293.818.7145778

Darknet-53 的准确率与 ResNet-152 相当,速度却快一倍。论文的解释是它的每秒浮点运算数最高,结构更适合 GPU:ResNet 的层太多,每层的计算量太小,GPU 的利用率不高。

YOLOv3 的骨干先在 ImageNet 上预训练,再训练检测;本系列从零训练,只有几千张图、30 轮。本篇的 mini-YOLOv3 默认沿用第 06 篇的“卷积 + 池化”骨干,同时保留了一个 Darknet 式的骨干选项(backbone="darknet":步长 2 的卷积下采样 + 残差块),第十节比较了两者。

九、动手:mini-YOLOv3

1. 数据与按大小分的 AP

第 05、06 篇的物体边长在 18~48 像素之间,相差不到 3 倍,一个尺度就能应付。本篇仍然调用 make_sample,只改两个参数:size_range=(6, 64)、n_range=(1, 8),于是物体边长从 6 到 64 像素,每张图 1~8 个物体(平均 4.5 个)。训练集仍是 3000 张,验证集 500 张。

COCO 的大小分界(32232^2、96296^2)是针对 640 像素左右的图像定的,直接用在 128×128 的图像上,几乎所有物体都会成为“小物体”。本篇按比例取了自己的分界:面积小于 16216^2 的是小物体(S),16216^2~32232^2 之间的是中物体(M),大于 32232^2 的是大物体(L)。训练集里三者分别占 29.1%、32.4%、38.5%,验证集里分别有 658、758、869 个。

计算 APS\mathrm{AP}_S 时,不能简单地把中、大物体从真实框里删掉,否则所有检测到中、大物体的框都会变成误检。COCO 的做法(附录代码的 match_class_area)是:

  1. 面积不在范围内的真实框标记为“忽略”;
  2. 每个检测框优先与不忽略的真实框匹配;如果只能匹配上被忽略的真实框,这个检测框也被忽略,既不算 TP 也不算 FP;
  3. 没有匹配上任何真实框的检测框,如果它自己的面积不在范围内,也忽略,否则算 FP。

附录 C 用 pycocotools 核对了这个实现:mAP@[.5:.95]、mAP@0.5、mAP@0.75、APS\mathrm{AP}_S、APM\mathrm{AP}_M、APL\mathrm{AP}_L 六个数与 COCOeval 的差都在 10−1610^{-16} 量级。

2. 改了什么

mini-YOLOv2(第 06 篇)mini-YOLOv3(本篇)
骨干“卷积 + 池化”,128 → 8×8同样的“卷积 + 池化”,多一级到 4×4,取出 3 个尺度的特征
特征融合无自顶向下:1×1 卷积、上采样、拼接,每级再接一个 3×3 卷积
检测头1 个:8×8×403 个:16×16×24、8×8×24、4×4×24
锚框5 个9 个,每个尺度 3 个
每张图的预测框8×8×5=3208 \times 8 \times 5 = 320(162+82+42)×3=1008(16^2 + 8^2 + 4^2) \times 3 = 1008
置信度的目标IoU1
损失平方误差;类别用 softmax 交叉熵xx、yy、置信度、类别用 BCE;ww、hh 用平方误差
参数量250,5521,044,952
每张图的乘加次数7350 万1.027 亿

骨干没有照搬 Darknet-53 的残差结构,而是沿用了第 06 篇的“卷积 + 池化”;第十节比较了两者。

3. 训练与结果

fig5_training.png

图 5:左:四项损失(对数坐标)。坐标一项一直停在 8.6 附近,因为 xx、yy 的 BCE 有一个不为 0 的下限。右:验证集 mAP。

第 5 轮时验证集 mAP@0.5 已达到 0.852,第 15 轮以后基本不再上升。坐标损失始终在 8.6 左右,这不是没学好:第七节说过,BCE 的目标 x^\hat{x} 不是 0 或 1 时,最小值是 x^\hat{x} 的二元熵。练习 9 算出,训练集上坐标损失的下限是每张图 8.571,训练结束时的 8.614 已经很接近了。

30 轮后的结果:

类别AP@0.5AP@0.75AP@[.5:.95]
圆0.9360.7860.648
矩形0.9260.7310.614
三角形0.9350.7620.636
mAP0.9320.7600.632

按大小分:APS=0.485\mathrm{AP}_S = 0.485,APM=0.679\mathrm{AP}_M = 0.679,APL=0.708\mathrm{AP}_L = 0.708。和 COCO 上一样,小物体最难。F1 最大的阈值是 0.48,此时精确率 0.968,召回率 0.908。

训练集前 500 张的 mAP@0.5 是 1.000,mAP@[.5:.95] 是 0.866,与验证集的差距(0.068 和 0.234)比第 06 篇大得多。图 5 中“有物体”和“类别”两项损失降到了 0.006,网络几乎把训练集背了下来。参数从 25 万增加到 104 万,而训练数据仍然只有 3000 张,过拟合是意料之中的事。第 14 篇的数据增强就是针对这个问题的。

fig6_predictions.png

图 6:验证集前 8 张图的检测结果,框的颜色表示它来自哪个尺度。小物体(橙色)由步长 8 检测,大物体(蓝色)由步长 32 检测。

图 6 里几乎所有框的分数都是 1.00。这是置信度目标改为 1 的直接后果:网络只学“有没有物体”,不再估计框得准不准。第 06 篇的 mini-YOLOv2 置信度的平均值(0.856)与平均 IoU(0.868)几乎相等,在这里已经不成立了。

4. 每个尺度检测了什么

附录代码第 6 部分做了一个简单的实验:检测时只保留一个尺度的输出,看各种大小的物体还能检测出多少。

只保留mAP@[.5:.95]APS\mathrm{AP}_SAPM\mathrm{AP}_MAPL\mathrm{AP}_L
步长 80.2790.4850.4270.000
步长 160.3450.0020.5260.450
步长 320.2520.0000.0590.607
3 个尺度一起0.6320.4850.6790.708

fig7_per_scale.png

图 7:左:只保留一个尺度的输出时的 APS\mathrm{AP}_S、APM\mathrm{AP}_M、APL\mathrm{AP}_L。右:验证集上分数 ≥ 0.48 的检测框的边长,按来自的尺度堆叠。

分工非常清楚:

  • 小物体只由步长 8 检测:只用步长 8 时 APS\mathrm{AP}_S 就是 0.485,与三个尺度一起时完全相同;去掉它,APS\mathrm{AP}_S 几乎为 0。
  • 大物体主要由步长 32 检测(0.607),步长 16 也能检测出一部分(0.450),两者合起来是 0.708。
  • 检测框的大小:来自步长 8 的 829 个框,边长中位数 13.6 像素(5.2~22.8);来自步长 16 的 705 个框,中位数 29.7 像素(19.7~45.6);来自步长 32 的 610 个框,中位数 50.7 像素(33.3~67.3)。相邻两个尺度在分界附近有重叠,与图 2 右边的训练标签一致。

十、从 mini-YOLOv2 到 mini-YOLOv3:逐项比较

附录 B 的 path_to_v3.py 在同样的数据和训练设置(30 轮,最大学习率 3×10−33 \times 10^{-3})下比较 6 个模型:

模型改了什么mAP@0.5mAP@0.75mAP@[.5:.95]APS\mathrm{AP}_SAPM\mathrm{AP}_MAPL\mathrm{AP}_L参数量乘加(百万)
0 mini-YOLOv2第 06 篇的网络和损失,单尺度0.8820.6400.5720.2600.5870.774250,55273.5
1 mini-YOLOv3本篇的主模型0.9320.7600.6320.4850.6790.7081,044,952102.7
2 去掉自顶向下路径同 1,fpn=False0.9380.7690.6390.4840.6930.725917,72087.9
3 只用一个尺度同 1,9 个锚框都放在步长 160.8800.6150.5600.2650.5970.7851,046,488102.7
4 残差骨干同 1,Darknet 式骨干0.9280.7980.6580.4660.7180.7531,556,856120.0
5 学习率 10−310^{-3}同 1,第 05、06 篇的学习率0.9170.5920.5420.3980.5790.6181,044,952102.7

(实验 3 的 P3 分支仍在计算,只是不输出,所以参数量和计算量与实验 1 几乎相同。附录 B 的输出里还有每张图的推理时间,但 6 个实验是同时运行的,互相抢 CPU,这些时间不能互相比较。实验 1 是最后单独运行的,测得 mini-YOLOv3 每张图 1.83 ms;附录 A 的 4.44 ms 也是在其他实验同时运行时测的。)

fig8_path_to_v3.png

图 8:6 个模型在验证集上的 mAP@[.5:.95] 与 APS\mathrm{AP}_S、APM\mathrm{AP}_M、APL\mathrm{AP}_L。

第 06 篇的经验是,同一配置只换线程数,结果就有约 0.01 的差别,所以下面只看明显大于 0.01 的差距。

  1. 0 → 1:小物体的 AP 几乎翻倍。APS\mathrm{AP}_S 从 0.260 升到 0.485,APM\mathrm{AP}_M 从 0.587 升到 0.679,mAP@[.5:.95] 从 0.572 升到 0.632。但 APL\mathrm{AP}_L 从 0.774 降到了 0.708,下面再讨论。
  2. 3:起作用的是“在多个尺度上预测”。实验 3 用的是 mini-YOLOv3 的骨干、损失和 9 个锚框,只是全部放在步长 16 一个尺度上,结果(APS\mathrm{AP}_S 0.265,mAP 0.560)几乎回到了 mini-YOLOv2。所以 0 → 1 的提升不是来自新的损失、更多的锚框或更大的网络,而是来自多尺度预测本身。
  3. 2:自顶向下的路径在这里没有作用。去掉它,各项指标都与实验 1 相差不到 0.02。原因在于合成数据太简单:圆、矩形、三角形的区别只在形状,浅层特征就足以分辨。练习 6 算出,P3 即使没有自顶向下的路径,感受野也有 54 像素,比所有小物体都大得多。在 COCO 上就不同了:要认出 20 像素大的一只鸟,需要深层的语义,FPN 论文报告了显著的提升。这又一次提醒我们:消融实验的结论只对做实验的数据成立。
  4. 4:残差骨干更好,也更贵。mAP@[.5:.95] 高 0.026,mAP@0.75 高 0.038,主要提升在中、大物体的定位精度上;代价是参数多 49%,计算量多 17%。本系列从零训练,YOLOv3 原文则先在 ImageNet 上预训练骨干,深的残差网络在那样的条件下优势更大。
  5. 5:学习率很重要。用第 05、06 篇的 10−310^{-3},mAP@0.75 从 0.760 降到 0.592,各项都明显变差。网络更大、预测框多了 3 倍(相对 mini-YOLOv2),30 轮训练里需要更大的步长才能收敛。

为什么大物体变差了? 只用步长 16 的模型(实验 0 和 3)APL\mathrm{AP}_L 是 0.774 和 0.785,mini-YOLOv3 只有 0.708。有意思的是,YOLOv3 论文也有类似的观察:与其他检测器相比,YOLOv3 的 APS\mathrm{AP}_S 相对较高,在中、大物体上却相对较差,论文说“需要更多的研究”。本篇的数据不足以下结论,但有几个可能的原因:

  • 信息只自顶向下流动。大物体由 4×4 的 P5 负责,而 P5 只来自骨干最深的 C5,没有任何高分辨率的信息流进来;要精确定位大物体的边界,粗糙的 4×4 特征不够用。只用步长 16 的模型,在 8×8 的特征上预测大物体,空间分辨率高一倍。
  • 边界效应。4×4 的特征图上,16 个位置有 12 个在边上,3×3 卷积看到的很大一部分是补的 0。
  • 每个尺度的训练样本变少了。大物体只训练步长 32 的那 3 个锚框,共 3983 个样本。

第一点正是 YOLOv4 采用 PANet 的理由:在自顶向下之后,再加一条自底向上的路径,把高分辨率的信息送回粗的尺度(第 08 篇)。练习 8 请你设计实验来检验这些猜测。

十一、论文里“没有用的尝试”

YOLOv3 论文有一节专门写“我们试过但没有用的东西”,这在学术论文里很少见:

  1. 锚框的 xx、yy 偏移:用 Faster R-CNN 的方式,以锚框宽高的倍数、线性输出预测中心偏移。模型不稳定,效果不好。第 06 篇的实验 3 验证了这一点:mAP@0.75 从 0.905 降到 0.700。
  2. 线性的 xx、yy:去掉 sigmoid,直接线性地预测偏移。mAP 降了几个点。
  3. Focal Loss:mAP 降了约 2 个点。论文的猜测是 YOLOv3 的置信度与条件类别概率是分开预测的,对大多数背景框,类别损失本来就是 0,Focal Loss 想解决的问题在这里不严重。第 10 篇会详细讲 Focal Loss。
  4. 双 IoU 阈值:像 Faster R-CNN 那样,IoU > 0.7 为正样本,0.3~0.7 忽略,< 0.3 为负样本。没能得到好结果。

论文还顺带提到,他们修复了 YOLOv2 中一个读数据的 bug,带来了约 2 个点的 mAP 提升。这类细节在复现时往往比模型结构的改动更要紧。

十二、YOLOv3 的成绩与局限

YOLOv3 最突出的是 AP50:608×608 输入达到 57.9,与 RetinaNet 的 57.5 相当,速度却快 3.8 倍。但在 COCO 的主指标 AP(IoU 0.5~0.95 的平均)上,它的 33.0 明显落后于 RetinaNet 的 39.1。论文的解读是,YOLOv3 擅长找到“大致对”的框,但在高 IoU 阈值下,框对得不够精确。本篇的 mini-YOLOv3 也是这样:mAP@0.5 是 0.932,mAP@0.75 只有 0.760。

论文接着质疑了 COCO 的主指标:COCO 的原始论文没有解释为什么要平均 0.5 到 0.95;而 Russakovsky 等人的研究发现,人很难分辨 IoU 为 0.3 和 0.5 的框,那么高 IoU 下的差别有多重要?这个问题至今仍有争论;第 09 篇的 IoU 损失、第 10 篇的 IoU 感知的置信度,都在设法提高高 IoU 阈值下的精度。

论文的最后一节谈到了计算机视觉研究的社会影响:检测技术可能被用于军事和侵犯隐私,研究者有责任考虑自己的工作可能带来的伤害。2020 年 2 月,Redmon 宣布出于这方面的担忧,停止了计算机视觉的研究。此后的 YOLOv4、YOLOv5 由其他团队发布(第 08 篇)。

十三、参考资料

  1. J. Redmon, A. Farhadi. YOLOv3: An Incremental Improvement. arXiv:1804.02767, 2018. 本篇的主要来源。
  2. T.-Y. Lin, P. Dollár, R. Girshick, K. He, B. Hariharan, S. Belongie. Feature Pyramid Networks for Object Detection. CVPR, 2017.(特征金字塔)
  3. T.-Y. Lin, P. Goyal, R. Girshick, K. He, P. Dollár. Focal Loss for Dense Object Detection. ICCV, 2017.(RetinaNet;置信度偏置的初始化;YOLOv3 论文表 3 的大部分数据来自这篇)
  4. W. Liu, D. Anguelov, D. Erhan, et al. SSD: Single Shot MultiBox Detector. ECCV, 2016.(在多个特征层上检测)
  5. K. He, X. Zhang, S. Ren, J. Sun. Deep Residual Learning for Image Recognition. CVPR, 2016.
  6. I. Krasin, T. Duerig, N. Alldrin, et al. OpenImages: A public dataset for large-scale multi-label and multi-class image classification, 2017.(多标签分类的动机)
  7. darknet 源码中的 src/yolo_layer.c 和 cfg/yolov3.cfg:YOLOv3 损失与网络结构的实际实现。

小结

  • YOLOv2 只在 13×13 的特征图上预测,小物体的 APS\mathrm{AP}_S 在 COCO 上只有 5.0。YOLOv3 在步长 32、16、8 三个尺度上预测,APS\mathrm{AP}_S 提高到 18.3。
  • 特征金字塔:深层特征经 1×1 卷积、上采样 2 倍,与浅层特征拼接,让高分辨率的特征也有深层的语义。YOLOv3 用拼接,FPN 原文用相加。
  • 9 个 k-means 锚框按面积平均分给 3 个尺度。每个物体只分给宽高 IoU 最大的那一个锚框,由它所在的尺度负责;不负责、但预测框与真实框 IoU 超过 0.5 的锚框被忽略。
  • 损失:置信度目标改为 1;类别用独立的 sigmoid 和 BCE,允许多标签;darknet 对 xx、yy 实际用的是 BCE,梯度是 σ(t)−x^\sigma(t) - \hat{x},不会像平方误差那样在 sigmoid 饱和时消失;所有权重都是 1。
  • Darknet-53:带残差连接、用步长 2 的卷积下采样,ImageNet 准确率与 ResNet-152 相当,速度快一倍。
  • 实验:在物体大小相差 10 倍的数据上,mini-YOLOv3 的 APS\mathrm{AP}_S 从 mini-YOLOv2 的 0.260 升到 0.485,mAP@[.5:.95] 从 0.572 升到 0.632;只用一个尺度时提升消失,说明起作用的是多尺度预测本身。自顶向下的路径在这个简单数据上没有作用;残差骨干再好 0.026;学习率用 10−310^{-3} 则明显变差。大物体的 AP 反而下降,这一点论文也观察到了,第 08 篇的 PANet 是一种解释和改进。

练习题

  1. 计算题:YOLOv3 在 COCO 上,输入为 416×416、608×608、320×320 时,三个尺度的输出张量形状分别是什么?每张图预测多少个框?与 416 输入的 YOLOv2 比较。
  2. 计算题:用 COCO 的 9 个锚框,对 416×416 图像中一个宽 40、高 70 像素、中心在 (200, 150) 的物体做 YOLOv3 的标签分配:它与 9 个锚框的宽高 IoU 各是多少?由哪个尺度、哪个格子、哪个锚框负责?xx、yy、ww、hh 的训练目标是多少?
  3. 推导题:(a) 推导 BCE(σ(t),x^)\mathrm{BCE}(\sigma(t), \hat{x}) 对 tt 的梯度;(b) 设 x^=0.9\hat{x} = 0.9,分别在 t=−4,0,2t = -4, 0, 2 处计算 BCE 与平方误差 (σ(t)−x^)2(\sigma(t) - \hat{x})^2 对 tt 的梯度;(c) x^=0.9\hat{x} = 0.9 时 BCE 的最小值是多少?
  4. 计算题:mini-YOLOv3 每张图 1008 个预测框,平均 4.5 个物体。(a) 置信度的偏置为 0 时,“无物体”一项的初始损失是多少?偏置初始化为 ln⁡(0.01/0.99)\ln(0.01/0.99) 时呢?(b) 对 416 输入的 YOLOv3(10647 个预测框)重复 (a)。(c) 偏置初始化之后,每个负责物体的“有物体”损失一开始是多少?
  5. 计算题:把 COCO 的 9 个锚框换算成各自尺度的格子。与本篇第五节的表比较,说明为什么本篇数据上三个尺度的锚框以格子计都在 1~2.6 之间,而 COCO 上不是。
  6. 计算题:本篇的“卷积 + 池化”骨干依次是:3×3 卷积,2×2 池化,3×3 卷积,2×2 池化,3×3 卷积(以上是 stem 和 C3 的第一层),2×2 池化,3×3 卷积(得到 C3);C4、C5 各是 2×2 池化 + 3×3 卷积;P5、P4、P3 各是一个 3×3 卷积。不考虑自顶向下的路径,求 C3、C4、C5 和 P3、P4、P5 的感受野。由此解释第十节实验 2 的结果。
  7. 思考题:YOLOv3 把置信度的目标从 IoU 改为 1。这对 NMS 保留哪个框、对 mAP@0.75 可能有什么影响?图 6 中几乎所有分数都是 1.00,说明了什么?
  8. 思考题:第十节列出了 mini-YOLOv3 大物体 AP 下降的三个可能原因。为每个原因设计一个实验来检验它。
  9. 计算题:训练结束时,坐标损失仍有每张图 8.614。设 xx、yy 两项 BCE 都已达到最小值、ww、hh 两项为 0,算出坐标损失的下限(提示:对训练集每个负责的锚框,计算 (2−wh)[H(x^)+H(y^)](2 - wh)[H(\hat{x}) + H(\hat{y})],HH 是二元熵)。
  10. 思考题:第十节实验 4 中,残差骨干比普通骨干好 0.026,但参数多 49%。如果你要在手机上部署检测器,会怎么选?如果可以用 ImageNet 预训练呢?

参考答案

  1. 416:52×52×255、26×26×255、13×13×255,共 (522+262+132)×3=10647(52^2 + 26^2 + 13^2) \times 3 = 10647 个框;608:76×76×255、38×38×255、19×19×255,共 22743 个;320:40×40×255、20×20×255、10×10×255,共 6300 个。416 输入的 YOLOv2 只有 13×13×5=84513 \times 13 \times 5 = 845 个框,YOLOv3 是它的 12.6 倍。
  2. 宽高 IoU 依次是 0.046、0.171、0.271、0.654、0.475、0.399、0.268、0.091、0.023,最大的是第 4 个锚框 30×61,属于步长 16、26×26 的网格。格子:列 ⌊200/16⌋=12\lfloor 200/16 \rfloor = 12,行 ⌊150/16⌋=9\lfloor 150/16 \rfloor = 9。目标:x^=200/16−12=0.5\hat{x} = 200/16 - 12 = 0.5,y^=150/16−9=0.375\hat{y} = 150/16 - 9 = 0.375,t^w=ln⁡(40/30)=0.288\hat{t}_w = \ln(40/30) = 0.288,t^h=ln⁡(70/61)=0.138\hat{t}_h = \ln(70/61) = 0.138。
  3. (a) 见第七节第 1 小节,结果是 σ(t)−x^\sigma(t) - \hat{x}。(b) t=−4t = -4:σ=0.0180\sigma = 0.0180,BCE 的梯度 −0.882-0.882,平方误差的梯度 2(σ−x^)σ(1−σ)=−0.03122(\sigma - \hat{x})\sigma(1 - \sigma) = -0.0312,只有前者的 1/28;t=0t = 0:−0.400-0.400 与 −0.200-0.200;t=2t = 2:σ=0.881\sigma = 0.881,−0.0192-0.0192 与 −0.0040-0.0040。预测错得越离谱,平方误差的梯度反而越小。(c) 最小值在 σ(t)=0.9\sigma(t) = 0.9 处取得,等于二元熵 −(0.9ln⁡0.9+0.1ln⁡0.1)=0.325-(0.9 \ln 0.9 + 0.1 \ln 0.1) = 0.325。
  4. (a) 偏置为 0:1008ln⁡2=698.71008 \ln 2 = 698.7;初始化之后:1008×(−ln⁡0.99)=10.11008 \times (-\ln 0.99) = 10.1。(b) 10647ln⁡2=7379.910647 \ln 2 = 7379.9;初始化之后 107.0。(c) −ln⁡0.01=4.61-\ln 0.01 = 4.61,每张图约 4.5×4.61=20.74.5 \times 4.61 = 20.7。附录 A 第 1 轮的平均“有物体”损失是 13.842,第 1 轮里置信度已经开始上升了。偏置 ln⁡(0.01/0.99)=−4.595\ln(0.01/0.99) = -4.595。
  5. 步长 8:1.25×1.62、2.00×3.75、4.12×2.88;步长 16:1.88×3.81、3.88×2.81、3.69×7.44;步长 32:3.62×2.81、4.88×6.19、11.66×10.19。本篇的物体边长在 6~64 像素之间均匀分布,按面积三等分后,每一段的最大与最小边长之比只有 2~3 倍,换算成格子后自然落在 1~2.6 之间。COCO 的物体大小是长尾分布:小物体很多,大物体可以大到占满整张图(373×326 几乎就是 416 的整幅图像),最大一段的范围非常宽,所以步长 32 的锚框以格子计也很大。
  6. 用第 01 篇第七节的递推 rout=rin+(k−1)jinr_{\text{out}} = r_{\text{in}} + (k - 1) j_{\text{in}}、jout=jinsj_{\text{out}} = j_{\text{in}} s:3×3 卷积 r=3r = 3;池化 r=4r = 4,j=2j = 2;卷积 r=8r = 8;池化 r=10r = 10,j=4j = 4;卷积 r=18r = 18;池化 r=22r = 22,j=8j = 8;卷积得到 C3,r=38r = 38。C4:池化 r=46r = 46,j=16j = 16,卷积 r=78r = 78。C5:池化 r=94r = 94,j=32j = 32,卷积 r=158r = 158。P3、P4、P5 各加一个 3×3 卷积:38+2×8=5438 + 2 \times 8 = 54,78+2×16=11078 + 2 \times 16 = 110,158+2×32=222158 + 2 \times 32 = 222。所有小物体(边长 < 16)、绝大多数中物体都远小于 P3 的 54 像素感受野,P3 已经能“看全”它们和周围的背景;而合成形状的类别只取决于轮廓,不需要更深的语义。所以自顶向下的路径带来的额外信息用处不大。
  7. 置信度不再反映定位质量,NMS 按分数排序时,保留下来的不一定是框得最准的那个:两个框重叠在同一个物体上、分数都接近 1 时,保留哪个几乎是随机的,被抑制的可能恰恰是 IoU 更高的框。这对 mAP@0.5 影响不大(两个框都能过 0.5),但会损害 mAP@0.75 这样的高阈值指标。图 6 中分数都是 1.00,说明网络对“有物体”非常确定,分数里几乎没有关于定位质量的信息,也说明在训练集上过拟合了(“有物体”损失只有 0.006)。第 10 篇的 IoU 感知的分类损失会把定位质量重新放进分数里。
  8. 示例:(1) 信息流向:加一条自底向上的路径(P3 → P4 → P5,用步长 2 的卷积下采样后拼接),看 APL\mathrm{AP}_L 是否回升;或者把大物体也交给 8×8 的 P4,对比只改分配、不改网络的效果。(2) 边界效应:把输入放大到 256×256(P5 变成 8×8),看 APL\mathrm{AP}_L 的下降是否减小;或者把 P5 的 3×3 卷积换成 1×1 卷积,去掉补零的影响。(3) 样本数:把每个物体分给多个锚框(IoU 超过阈值的都算正样本,第 08 篇 YOLOv5 的做法),增加大物体的正样本数。每个实验都应该用几个随机种子重复,因为单次运行的噪声约为 0.01。
  9. 训练集上逐个锚框计算,下限是每张图 8.571(每张图平均 4.50 个负责的锚框,每个约 1.905)。训练结束时的 8.614 只比下限高 0.043,说明 xx、yy 在训练集上已经几乎完全拟合。这也提醒我们:用 BCE 拟合软目标时,损失曲线的绝对值没有意义,要看它离下限还有多远。
  10. 手机上计算量和参数量往往是首要约束:普通骨干少 1/3 的参数、少 14% 的计算,精度只低 0.026,通常值得;也可以把省下的计算量用于更大的输入分辨率,这往往比换骨干更划算(第 06 篇第九节、第 20 篇)。如果能用 ImageNet 预训练,深的残差骨干学到的特征更通用,在复杂的真实数据上优势会更大,这正是 YOLOv3 用 Darknet-53 的原因;而且残差连接让很深的网络也能稳定训练(第 01 篇第十一节)。最终要在目标硬件上实测速度,在自己的数据上比较精度,再做决定。

附录 A:example.py

下面的代码完成本篇第五、六、九节的全部计算:数据、9 个 k-means 锚框与标签分配,训练 mini-YOLOv3,评估(包括 APS\mathrm{AP}_S、APM\mathrm{AP}_M、APL\mathrm{AP}_L)并分析每个尺度的作用。它需要 numpy 和 PyTorch,并从 ../common/ 导入 shapes.py、boxes.py、metrics.py。在本篇文件夹里运行 python example.py 即可。运行后会生成 mini_yolov3.pt 和 history.json,make_figures.py、path_to_v3.py、verify_with_pycocotools.py 要用到它们。

"""YOLO 目标检测自学教程 07:YOLOv3——多尺度预测 —— 附录代码

  1. 数据:物体大小范围更宽(6~64 像素)的合成形状数据集;9 个 k-means 锚框,按面积分给 3 个尺度;
  2. 标签分配:每个物体只交给 9 个锚框中形状最接近的一个,也就决定了由哪个尺度负责;sigmoid 与 softmax;
  3. mini-YOLOv3:骨干取出 3 个尺度的特征,自顶向下上采样 + 拼接成特征金字塔,3 个尺度各有检测头;
  4. YOLOv3 式的损失(darknet 的做法):x、y、置信度、类别都用二元交叉熵,w、h 用平方误差,训练并评估;
  5. 按物体大小分的 AP(AP_S、AP_M、AP_L,按 COCO 的规则计算);
  6. 分析:只用一个尺度的输出时各类大小物体的 AP、每个尺度检测出了多大的物体、计算量与速度。
训练好的权重和训练记录保存在本文件夹的 mini_yolov3.pt、history.json。
需要:numpy、torch,以及 ../common/ 下的 shapes.py、boxes.py、metrics.py。
运行:python example.py(CPU 上约需半小时到一小时)
"""
import json
import pathlib
import sys
import time

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "common"))
from shapes import CLASS_NAMES, make_sample
from boxes import box_iou, cxcywh_to_xyxy, nms_per_class
from metrics import IOU_THRS, ap_coco, evaluate, match_class, pr_curve, summarize

IMG, C = 128, 3
STRIDES = (8, 16, 32)                 # 3 个尺度:16×16、8×8、4×4 的网格
MASKS = ((0, 1, 2), (3, 4, 5), (6, 7, 8))   # 9 个锚框按面积从小到大,每个尺度 3 个
N_TRAIN, N_VAL = 3000, 500
EPOCHS, BATCH, LR = 30, 64, 3e-3   # 学习率是第 05、06 篇的 3 倍,原因见第七节
SIZE_RANGE, N_RANGE = (6.0, 64.0), (1, 8)
AREA_RNG = {"S": (0, 16 ** 2), "M": (16 ** 2, 32 ** 2), "L": (32 ** 2, 1e10)}   # 按面积分小、中、大物体


# ---------------- 1. 数据与锚框 ----------------
def make_data(n, seed):
    rng = np.random.default_rng(seed)
    imgs, gts = [], []
    for _ in range(n):
        img, gb, gc, _ = make_sample(rng, IMG, n_range=N_RANGE, size_range=SIZE_RANGE)
        imgs.append((img * 255).round().astype(np.uint8).transpose(2, 0, 1))
        gts.append((gb, gc))
    return np.stack(imgs), gts


def iou_wh(wh, anchors):
    """只看宽高的 IoU(中心对齐),与第 06 篇相同。wh: (N, 2),anchors: (K, 2),返回 (N, K)。"""
    inter = np.minimum(wh[:, None, 0], anchors[None, :, 0]) * np.minimum(wh[:, None, 1], anchors[None, :, 1])
    return inter / (wh[:, 0:1] * wh[:, 1:2] + (anchors[:, 0] * anchors[:, 1])[None] - inter)


def kmeans_anchors(wh, k, iters=300, seed=0):
    """d = 1 - IoU 的 k-means(第 06 篇),中心取中位数,按面积从小到大返回。"""
    rng = np.random.default_rng(seed)
    cent = wh[rng.choice(len(wh), k, replace=False)].astype(float)
    for _ in range(iters):
        assign = np.argmax(iou_wh(wh, cent), 1)
        new = np.array([np.median(wh[assign == j], 0) if (assign == j).any() else cent[j] for j in range(k)])
        if np.allclose(new, cent):
            break
        cent = new
    return cent[np.argsort(cent[:, 0] * cent[:, 1])]


# ---------------- 2. 标签分配 ----------------
def build_targets(gts, anchors, strides=STRIDES, masks=MASKS):
    """YOLOv3 的标签分配:每个物体只分给全部锚框中与它形状最接近(宽高 IoU 最大)的**一个**,
    这个锚框属于哪个尺度,就由那个尺度上、物体中心所在的格子负责。anchors 以像素为单位 (K, 2)。
    返回每个尺度的 (obj, box, cls)、每张图的真实框(归一化 cxcywh)和冲突数。
    box = (x 偏移, y 偏移, ln(w/p_w), ln(h/p_h)),偏移 ∈ [0,1),配合 σ(t) 使用(第 06 篇)。"""
    N = len(gts)
    level_of = {a: l for l, m in enumerate(masks) for a in m}
    used = [a for m in masks for a in m]
    tg = []
    for s, m in zip(strides, masks):
        S = IMG // s
        tg.append((np.zeros((N, S, S, len(m)), bool), np.zeros((N, S, S, len(m), 4), np.float32),
                   np.zeros((N, S, S, len(m)), np.int64)))
    norm, dropped = [], 0
    for n, (gb, gc) in enumerate(gts):
        cxcywh = np.stack([(gb[:, 0] + gb[:, 2]) / 2, (gb[:, 1] + gb[:, 3]) / 2, gb[:, 2] - gb[:, 0], gb[:, 3] - gb[:, 1]], 1)
        norm.append(cxcywh / IMG)
        best = np.array(used)[np.argmax(iou_wh(cxcywh[:, 2:], anchors[used]), 1)]
        for (xc, yc, w, h), a, c in zip(cxcywh, best, gc):
            l = level_of[a]
            s, k = strides[l], masks[l].index(a)
            S = IMG // s
            j, i = min(int(xc // s), S - 1), min(int(yc // s), S - 1)
            obj, box, cls = tg[l]
            if obj[n, i, j, k]:
                dropped += 1
                continue
            obj[n, i, j, k] = True
            box[n, i, j, k] = [xc / s - j, yc / s - i, np.log(w / anchors[a, 0]), np.log(h / anchors[a, 1])]
            cls[n, i, j, k] = c
    targets = [tuple(torch.tensor(t) for t in lv) for lv in tg]
    return targets, [torch.tensor(g, dtype=torch.float32) for g in norm], dropped


# ---------------- 3. 网络 ----------------
def conv(ci, co, k=3, s=1):
    return nn.Sequential(nn.Conv2d(ci, co, k, s, k // 2, bias=False), nn.BatchNorm2d(co), nn.LeakyReLU(0.1))


class Residual(nn.Module):
    """Darknet-53 的残差块:1×1 卷积把通道减半,3×3 卷积恢复,再与输入相加。"""

    def __init__(self, c):
        super().__init__()
        self.f = nn.Sequential(conv(c, c // 2, 1), conv(c // 2, c, 3))

    def forward(self, x):
        return x + self.f(x)


class MiniYOLOv3(nn.Module):
    """骨干 + 特征金字塔 + 3 个检测头。
      backbone="plain"  :第 06 篇的“3×3 卷积 + 最大池化”骨干,再加一级到步长 32;
      backbone="darknet":Darknet-53 的做法——下采样用步长 2 的 3×3 卷积,每次下采样后接残差块。
      两种骨干都取出步长 8、16、32 的特征 C3(64 通道)、C4(128)、C5(256)。
      fpn=True :自顶向下——C5 → P5;P5 经 1×1 卷积、上采样 2 倍,与 C4 拼接 → P4;P4 同样与 C3 拼接 → P3。
      fpn=False:每个尺度只用骨干自己的特征(像 SSD),没有自顶向下的路径。
      P5、P4、P3 各接一个 1×1 卷积输出 A×(5+C) 个通道。
      heads:哪些尺度输出预测(0=步长 8,1=16,2=32);n_anchors:每个尺度的锚框数。"""

    def __init__(self, fpn=True, heads=(0, 1, 2), n_anchors=3, backbone="plain"):
        super().__init__()
        self.fpn, self.heads, self.nA, self.no = fpn, heads, n_anchors, 5 + C
        if backbone == "plain":
            P = nn.MaxPool2d
            self.stem = nn.Sequential(conv(3, 16), P(2), conv(16, 32), P(2))             # → 32×32(步长 4)
            self.c3 = nn.Sequential(conv(32, 64), P(2), conv(64, 64))                     # → 16×16(步长 8)
            self.c4 = nn.Sequential(P(2), conv(64, 128))                                  # → 8×8(步长 16)
            self.c5 = nn.Sequential(P(2), conv(128, 256))                                 # → 4×4(步长 32)
        else:
            self.stem = nn.Sequential(conv(3, 16), conv(16, 32, s=2), conv(32, 32, s=2), Residual(32))
            self.c3 = nn.Sequential(conv(32, 64, s=2), Residual(64), Residual(64))
            self.c4 = nn.Sequential(conv(64, 128, s=2), Residual(128), Residual(128))
            self.c5 = nn.Sequential(conv(128, 256, s=2), Residual(256))
        self.p5 = conv(256, 128)
        self.p4 = conv(128 + (64 if fpn else 0), 128)
        self.p3 = conv(64 + (64 if fpn else 0), 64)
        if fpn:
            self.lat5, self.lat4 = conv(128, 64, 1), conv(128, 64, 1)     # 上采样之前先用 1×1 卷积减少通道
        widths = {0: 64, 1: 128, 2: 128}
        self.out = nn.ModuleDict({str(l): nn.Conv2d(widths[l], n_anchors * self.no, 1) for l in heads})
        # 置信度的偏置初始化为 ln(π/(1-π)),π = 0.01:一开始所有框的置信度都约为 0.01。
        # 否则上千个背景框的置信度都从 0.5 起步,训练初期的损失几乎全来自“无物体”这一项
        with torch.no_grad():
            for head in self.out.values():
                head.bias.view(n_anchors, self.no)[:, 4] = float(np.log(0.01 / 0.99))

    def forward(self, x):
        c3 = self.c3(self.stem(x))
        c4 = self.c4(c3)
        c5 = self.c5(c4)
        p5 = self.p5(c5)
        if self.fpn:
            p4 = self.p4(torch.cat([F.interpolate(self.lat5(p5), scale_factor=2, mode="nearest"), c4], 1))
            p3 = self.p3(torch.cat([F.interpolate(self.lat4(p4), scale_factor=2, mode="nearest"), c3], 1))
        else:
            p4, p3 = self.p4(c4), self.p3(c3)
        feats = {0: p3, 1: p4, 2: p5}
        outs = []
        for l in self.heads:
            o = self.out[str(l)](feats[l])
            N, _, S, _ = o.shape
            outs.append(o.view(N, self.nA, self.no, S, S).permute(0, 3, 4, 1, 2))    # (N, S, S, A, 5+C)
        return outs


def count_macs(model, size=IMG):
    """统计卷积层的乘加次数(每张图)。"""
    total = []

    def hook(m, inp, out):
        total.append(out[0].numel() * m.in_channels // m.groups * m.kernel_size[0] * m.kernel_size[1])
    hs = [m.register_forward_hook(hook) for m in model.modules() if isinstance(m, nn.Conv2d)]
    model.eval()
    with torch.no_grad():
        model(torch.zeros(1, 3, size, size))
    for h in hs:
        h.remove()
    return sum(total)


# ---------------- 4. 解码与损失 ----------------
def decode_boxes(out, anchors_cell):
    """网络输出 → 归一化 cxcywh (N,S,S,A,4):b_x = (σ(t_x) + c_x)/S,b_w = p_w·e^{t_w}/S,p_w 以本尺度的格子为单位。"""
    S = out.shape[1]
    jj, ii = torch.meshgrid(torch.arange(S), torch.arange(S), indexing="xy")
    cx = jj[None, :, :, None] + torch.sigmoid(out[..., 0])
    cy = ii[None, :, :, None] + torch.sigmoid(out[..., 1])
    w = anchors_cell[:, 0] * torch.exp(out[..., 2].clamp(max=8))
    h = anchors_cell[:, 1] * torch.exp(out[..., 3].clamp(max=8))
    return torch.stack([cx, cy, w, h], -1) / S


def iou_cxcywh(a, b):
    """逐元素 IoU,a、b 最后一维是 cxcywh。"""
    a1, a2 = a[..., :2] - a[..., 2:] / 2, a[..., :2] + a[..., 2:] / 2
    b1, b2 = b[..., :2] - b[..., 2:] / 2, b[..., :2] + b[..., 2:] / 2
    wh = (torch.minimum(a2, b2) - torch.maximum(a1, b1)).clamp(min=0)
    inter = wh[..., 0] * wh[..., 1]
    return inter / (a[..., 2] * a[..., 3] + b[..., 2] * b[..., 3] - inter + 1e-9)


def level_anchors(anchors, levels, strides=STRIDES, masks=MASKS):
    """每个输出尺度的锚框,换算成以该尺度的格子为单位。"""
    return [torch.tensor(anchors[list(masks[l])] / strides[l], dtype=torch.float32) for l in levels]


def yolo_v3_loss(outs, targets, gts, anc, ignore_thr=0.5):
    """darknet(yolo_layer.c)的 YOLOv3 损失。darknet 对 σ 之后的输出直接回传“目标 − 预测”,
    这恰好是二元交叉熵对 logit 的梯度,所以 x、y、置信度、类别都相当于用 BCE:
      坐标:负责的锚框,(2 - w·h)·[BCE(σ(t_x), x) + BCE(σ(t_y), y) + ½(t_w - ln(w/p_w))² + ½(t_h - ln(h/p_h))²];
      有物体:负责的锚框,BCE(σ(t_o), 1)——目标是 1,不再是 IoU;
      无物体:其余锚框,BCE(σ(t_o), 0);如果预测框与任意真实框的 IoU > 0.5,就不计这一项;
      类别:负责的锚框,每个类别各自一个 sigmoid,BCE(不用 softmax)。所有权重都是 1。"""
    N = outs[0].shape[0]
    G = max(len(g) for g in gts)
    gpad = torch.zeros(N, max(G, 1), 4)
    valid = torch.zeros(N, max(G, 1), dtype=torch.bool)
    for n, g in enumerate(gts):
        gpad[n, :len(g)] = g
        valid[n, :len(g)] = True
    bce = F.binary_cross_entropy_with_logits
    terms = {"坐标": 0.0, "有物体": 0.0, "无物体": 0.0, "类别": 0.0}
    for out, (obj, tbox, tcls), a in zip(outs, targets, anc):
        S = out.shape[1]
        with torch.no_grad():
            pbox = decode_boxes(out, a)
            ious = iou_cxcywh(pbox.reshape(N, -1, 1, 4), gpad[:, None])
            best = (ious * valid[:, None]).max(-1).values.reshape(obj.shape)
            scale = 2 - (a[:, 0] * tbox[..., 2].exp() / S) * (a[:, 1] * tbox[..., 3].exp() / S)
        o = obj.float()
        xy = bce(out[..., :2], tbox[..., :2], reduction="none").sum(-1)
        wh = 0.5 * ((out[..., 2:4] - tbox[..., 2:4]) ** 2).sum(-1)
        terms["坐标"] = terms["坐标"] + (o * scale * (xy + wh)).sum()
        terms["有物体"] = terms["有物体"] + (o * bce(out[..., 4], torch.ones_like(o), reduction="none")).sum()
        noobj = ((~obj) & (best < ignore_thr)).float()
        terms["无物体"] = terms["无物体"] + (noobj * bce(out[..., 4], torch.zeros_like(o), reduction="none")).sum()
        if obj.any():
            onehot = F.one_hot(tcls[obj], C).float()
            terms["类别"] = terms["类别"] + bce(out[obj][:, 5:], onehot, reduction="sum")
    terms = {k: v / N for k, v in terms.items()}
    return sum(terms.values()), terms


# ---------------- 5. 训练与推理 ----------------
def train(model, anchors, tr_x, tr_gt, va_x, va_gt, epochs=EPOCHS, seed=0, log=True):
    """与第 05、06 篇相同的训练循环(Adam + OneCycle,30 轮,批大小 64),只是最大学习率改为 3e-3。"""
    torch.manual_seed(seed)
    rng = np.random.default_rng(seed)
    strides, masks = model_layout(model)
    anc = level_anchors(anchors, range(len(model.heads)), strides, masks)
    steps = epochs * ((len(tr_x) + BATCH - 1) // BATCH)
    opt = torch.optim.Adam(model.parameters(), lr=LR)
    sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.1)
    history = []
    for ep in range(1, epochs + 1):
        model.train()
        t0 = time.time()
        sums, n = {}, 0
        perm = rng.permutation(len(tr_x))
        for i in range(0, len(perm), BATCH):
            idx = perm[i:i + BATCH]
            x = torch.tensor(tr_x[idx]).float() / 255
            targets, gts, _ = build_targets([tr_gt[k] for k in idx], anchors, strides, masks)
            loss, terms = yolo_v3_loss(model(x), targets, gts, anc)
            opt.zero_grad()
            loss.backward()
            opt.step()
            sched.step()
            for k, v in terms.items():
                sums[k] = sums.get(k, 0.0) + float(v) * len(idx)
            n += len(idx)
        rec = {"epoch": ep, "time": time.time() - t0, **{k: v / n for k, v in sums.items()}}
        rec["total"] = sum(v / n for v in sums.values())
        if ep % 5 == 0 or ep == epochs:
            rec["val_map50"], _, rec["val_map"] = summarize(evaluate(detect(model, anchors, va_x, va_gt)))
        history.append(rec)
        if log:
            msg = f"   第 {ep:2d} 轮  总损失 {rec['total']:.3f}(坐标 {rec['坐标']:.3f},有物体 {rec['有物体']:.3f}," \
                  f"无物体 {rec['无物体']:.3f},类别 {rec['类别']:.3f})"
            if "val_map" in rec:
                msg += f"  验证 mAP@0.5 {rec['val_map50']:.3f}  mAP@[.5:.95] {rec['val_map']:.3f}"
            print(msg, flush=True)
    return history


def model_layout(model):
    """输出尺度对应的步长和锚框编号。3 个尺度:每个尺度 3 个锚框;单尺度:9 个锚框都放在这个尺度上。"""
    if len(model.heads) == 1:
        return (STRIDES[model.heads[0]],), (tuple(range(9)),)
    return tuple(STRIDES[l] for l in model.heads), tuple(MASKS[l] for l in model.heads)


def detect(model, anchors, imgs, gts, levels=None, conf_thr=0.001, nms_thr=0.5, bs=250):
    """推理 → 类别分数 = σ(t_o)·σ(类别) → 过滤 → 按类别 NMS,框换算回像素坐标。
    levels 指定只用哪几个输出尺度(默认全部),用来分析每个尺度的作用。返回的字典里多一项 level:框来自哪个尺度。"""
    strides, masks = model_layout(model)
    anc = level_anchors(anchors, range(len(model.heads)), strides, masks)
    levels = range(len(model.heads)) if levels is None else levels
    model.eval()
    per_img = [[] for _ in range(len(imgs))]
    with torch.no_grad():
        for i in range(0, len(imgs), bs):
            outs = model(torch.tensor(imgs[i:i + bs]).float() / 255)
            for l in levels:
                out = outs[l]
                b = cxcywh_to_xyxy(decode_boxes(out, anc[l]).reshape(len(out), -1, 4).numpy()) * IMG
                sc = (torch.sigmoid(out[..., 4:5]) * torch.sigmoid(out[..., 5:])).reshape(len(out), -1, C).numpy()
                for k in range(len(out)):
                    per_img[i + k].append((b[k], sc[k], np.full(len(b[k]), l)))
    images = []
    for parts, (gb, gc) in zip(per_img, gts):
        b = np.concatenate([p[0] for p in parts])
        sc_all = np.concatenate([p[1] for p in parts])
        lv = np.concatenate([p[2] for p in parts])
        cls, sc = sc_all.argmax(1), sc_all.max(1)
        k = np.where(sc >= conf_thr)[0]
        if len(k):
            k = k[nms_per_class(b[k], sc[k], cls[k], nms_thr)]
        images.append(dict(gt_boxes=gb, gt_classes=gc, boxes=b[k], scores=sc[k], classes=cls[k], level=lv[k]))
    return images


# ---------------- 6. 按物体大小分的 AP ----------------
def match_class_area(images, c, iou_thr, lo, hi, max_dets=100):
    """COCO 的 AP_S / AP_M / AP_L:面积不在 [lo, hi] 内的真实框记为“忽略”。
    规则与 pycocotools 相同:检测框优先匹配不忽略的真实框;匹配到被忽略的真实框、
    或者没有匹配上而自己的面积不在范围内的检测框,都不参与统计(既不算 TP 也不算 FP)。"""
    all_scores, all_tp, all_ig, n_gt = [], [], [], 0
    for im in images:
        g = im["gt_boxes"][im["gt_classes"] == c]
        ga = (g[:, 2] - g[:, 0]) * (g[:, 3] - g[:, 1])
        gig = (ga < lo) | (ga > hi)
        order = np.argsort(gig, kind="mergesort")          # 不忽略的真实框排在前面
        g, gig = g[order], gig[order]
        n_gt += int((~gig).sum())
        keep = np.where(im["classes"] == c)[0]
        keep = keep[np.argsort(-im["scores"][keep], kind="mergesort")][:max_dets]
        d = im["boxes"][keep]
        da = (d[:, 2] - d[:, 0]) * (d[:, 3] - d[:, 1])
        ious = box_iou(d, g) if len(g) else np.zeros((len(keep), 0))
        used = np.zeros(len(g), dtype=bool)
        for k in range(len(keep)):
            m, best = -1, iou_thr
            for j in range(len(g)):
                if used[j]:
                    continue
                if m > -1 and not gig[m] and gig[j]:
                    break                                   # 已经匹配到不忽略的真实框,不再看被忽略的
                if ious[k, j] < best:
                    continue
                m, best = j, ious[k, j]
            if m > -1:
                used[m] = True
                all_tp.append(True)
                all_ig.append(gig[m])
            else:
                all_tp.append(False)
                all_ig.append(da[k] < lo or da[k] > hi)
            all_scores.append(im["scores"][keep[k]])
    scores, tp, ig = np.array(all_scores), np.array(all_tp, dtype=bool), np.array(all_ig, dtype=bool)
    order = np.argsort(-scores, kind="mergesort")
    scores, tp, ig = scores[order], tp[order], ig[order]
    return scores[~ig], tp[~ig], n_gt


def evaluate_area(images, lo, hi, num_classes=C, iou_thrs=IOU_THRS):
    """与 metrics.evaluate 相同,但只统计面积在 [lo, hi] 内的物体。返回 AP 矩阵 (阈值数, 类别数)。"""
    ap = np.full((len(iou_thrs), num_classes), np.nan)
    for c in range(num_classes):
        for t, thr in enumerate(iou_thrs):
            _, tp, n_gt = match_class_area(images, c, thr, lo, hi)
            if n_gt == 0:
                continue
            ap[t, c] = ap_coco(*pr_curve(tp, n_gt)) if len(tp) else 0.0
    return ap


def size_aps(images):
    """AP_S、AP_M、AP_L(都是 IoU 0.5:0.95 的平均)。"""
    return {k: float(np.nanmean(evaluate_area(images, lo, hi))) for k, (lo, hi) in AREA_RNG.items()}


def best_f1_threshold(images, confs=np.round(np.arange(0.02, 0.981, 0.02), 2)):
    """F1 最大的置信度阈值(第 03 篇第七节)。返回 (阈值, P, R, F1)。"""
    P = np.zeros((C, len(confs)))
    R = np.zeros_like(P)
    for c in range(C):
        scores, tp, n_gt = match_class(images, c, 0.5)
        for k, t in enumerate(confs):
            m = scores >= t
            P[c, k] = tp[m].sum() / m.sum() if m.any() else 1.0
            R[c, k] = tp[m].sum() / n_gt
    P, R = P.mean(0), R.mean(0)
    F1 = 2 * P * R / np.maximum(P + R, 1e-12)
    k = int(np.argmax(F1))
    return confs[k], P[k], R[k], F1[k]


def ms_per_image(model, imgs, reps=10):
    xb = torch.tensor(imgs[:64]).float() / 255
    model.eval()
    with torch.no_grad():
        model(xb)
        t0 = time.perf_counter()
        for _ in range(reps):
            model(xb)
    return (time.perf_counter() - t0) / reps / 64 * 1000


if __name__ == "__main__":
    torch.set_num_threads(4)
    print("1. 数据与锚框")
    tr_x, tr_gt = make_data(N_TRAIN, seed=0)
    va_x, va_gt = make_data(N_VAL, seed=1)
    wh = np.concatenate([np.stack([gb[:, 2] - gb[:, 0], gb[:, 3] - gb[:, 1]], 1) for gb, _ in tr_gt])
    area = wh[:, 0] * wh[:, 1]
    print(f"   训练集 {N_TRAIN} 张图,共 {len(wh)} 个框;边长(面积的平方根){np.sqrt(area).min():.1f}~{np.sqrt(area).max():.1f} 像素")
    va_area = np.concatenate([(gb[:, 2] - gb[:, 0]) * (gb[:, 3] - gb[:, 1]) for gb, _ in va_gt])
    for k, (lo, hi) in AREA_RNG.items():
        print(f"   {k}(面积 {lo:.0f}~{min(hi, IMG * IMG):.0f}):训练集 {np.mean((area >= lo) & (area < hi)):.1%},"
              f"验证集 {((va_area >= lo) & (va_area < hi)).sum()} 个")
    anchors = kmeans_anchors(wh, 9)
    print("   9 个 k-means 锚框(宽×高,像素):" + "  ".join(f"{w:.1f}×{h:.1f}" for w, h in anchors))
    for s, m in zip(STRIDES, MASKS):
        print(f"     步长 {s:2d}({IMG // s}×{IMG // s} 网格):锚框 {list(m)},以本尺度格子为单位 "
              + "  ".join(f"{anchors[a, 0] / s:.2f}×{anchors[a, 1] / s:.2f}" for a in m))
    for k in (3, 5, 9):
        print(f"   k = {k}:平均 IoU {iou_wh(wh, kmeans_anchors(wh, k)).max(1).mean():.3f}")

    print("\n2. 标签分配")
    targets, _, dropped = build_targets(tr_gt, anchors)
    for (s, m), (obj, _, _) in zip(zip(STRIDES, MASKS), targets):
        print(f"   步长 {s:2d} 负责 {int(obj.sum()):5d} 个物体")
    a5 = kmeans_anchors(wh, 5)
    _, _, dropped_v2 = build_targets(tr_gt, a5, strides=(16,), masks=(tuple(range(5)),))
    print(f"   因同一格子同一锚框冲突而丢掉的物体:3 个尺度 {dropped} 个;"
          f"只用步长 16 和 5 个锚框(第 06 篇的做法){dropped_v2} 个")
    z = torch.tensor([3.0, 2.5, -2.0])
    fmt = lambda v: "[" + ", ".join(f"{x:.3f}" for x in v.tolist()) + "]"
    print(f"   多标签:logit {z.tolist()} → softmax {fmt(torch.softmax(z, 0))},sigmoid {fmt(torch.sigmoid(z))}")

    print("\n3. 网络")
    torch.manual_seed(0)
    model = MiniYOLOv3()
    outs = model.eval()(torch.zeros(1, 3, IMG, IMG))
    print("   输出形状 " + ",".join(str(tuple(o.shape)) for o in outs)
          + f";每张图 {sum(o.shape[1] * o.shape[2] * o.shape[3] for o in outs)} 个预测框")
    print(f"   参数 {sum(p.numel() for p in model.parameters()):,},每张图 {count_macs(model) / 1e6:.1f} 百万次乘加")

    print("\n4. 训练 mini-YOLOv3")
    t0 = time.time()
    history = train(model, anchors, tr_x, tr_gt, va_x, va_gt)
    print(f"   训练用时 {(time.time() - t0) / 60:.1f} 分钟")
    torch.save({"model": model.state_dict(), "anchors": anchors}, HERE / "mini_yolov3.pt")
    (HERE / "history.json").write_text(json.dumps(history, ensure_ascii=False, indent=1), encoding="utf-8")

    print("\n5. 评估(置信度阈值 0.001,NMS 0.5)")
    images = detect(model, anchors, va_x, va_gt)
    ap = evaluate(images)
    for c, name in enumerate(CLASS_NAMES):
        print(f"   {name: <3}  AP@0.5 = {ap[0, c]:.3f}  AP@0.75 = {ap[5, c]:.3f}  AP@[.5:.95] = {np.mean(ap[:, c]):.3f}")
    m50, m75, m = summarize(ap)
    print(f"   mAP@0.5 = {m50:.3f},mAP@0.75 = {m75:.3f},mAP@[.5:.95] = {m:.3f}")
    sa = size_aps(images)
    print(f"   AP_S = {sa['S']:.3f},AP_M = {sa['M']:.3f},AP_L = {sa['L']:.3f}")
    tr_m = summarize(evaluate(detect(model, anchors, tr_x[:N_VAL], tr_gt[:N_VAL])))
    print(f"   训练集前 {N_VAL} 张:mAP@0.5 = {tr_m[0]:.3f},mAP@[.5:.95] = {tr_m[2]:.3f}")
    t_b, p_b, r_b, f_b = best_f1_threshold(images)
    print(f"   F1 最大的置信度阈值 {t_b:.2f}:精确率 {p_b:.3f},召回率 {r_b:.3f},F1 {f_b:.3f}")

    print("\n6. 分析")
    print("   只保留一个尺度的输出时(AP@[.5:.95]):")
    for l, s in enumerate(STRIDES):
        im_l = detect(model, anchors, va_x, va_gt, levels=[l])
        r, sa_l = summarize(evaluate(im_l)), size_aps(im_l)
        print(f"     步长 {s:2d}:mAP = {r[2]:.3f},AP_S = {sa_l['S']:.3f},AP_M = {sa_l['M']:.3f},AP_L = {sa_l['L']:.3f}")
    print(f"   置信度 ≥ {t_b:.2f} 的检测框来自哪个尺度,以及它们的边长中位数:")
    lv = np.concatenate([im["level"][im["scores"] >= t_b] for im in images])
    side = np.concatenate([np.sqrt((im["boxes"][:, 2] - im["boxes"][:, 0]) * (im["boxes"][:, 3] - im["boxes"][:, 1]))
                           [im["scores"] >= t_b] for im in images])
    for l, s in enumerate(STRIDES):
        print(f"     步长 {s:2d}:{(lv == l).sum():4d} 个框,边长中位数 {np.median(side[lv == l]):.1f} 像素,"
              f"范围 {side[lv == l].min():.1f}~{side[lv == l].max():.1f}")
    print(f"   推理速度:每张 {ms_per_image(model, va_x):.2f} ms(批大小 64)")

运行输出(用 4 个线程,同时还有附录 B 的 5 个实验在运行,所以用时偏长;训练用时与推理速度取决于机器,其余数字在同样的线程数下每次运行都相同。本篇实际运行了两次:第一次的输出中“多标签”一行打印格式不好,修改打印语句后重跑,两次的输出除这一行和用时外逐字相同):

1. 数据与锚框
   训练集 3000 张图,共 13501 个框;边长(面积的平方根)6.0~64.0 像素
   S(面积 0~256):训练集 29.1%,验证集 658 个
   M(面积 256~1024):训练集 32.4%,验证集 758 个
   L(面积 1024~16384):训练集 38.5%,验证集 869 个
   9 个 k-means 锚框(宽×高,像素):7.6×7.8  11.8×12.0  17.4×18.1  22.6×28.8  30.0×24.5  32.1×41.2  43.0×34.6  46.3×59.7  60.8×48.5
     步长  8(16×16 网格):锚框 [0, 1, 2],以本尺度格子为单位 0.95×0.97  1.48×1.50  2.18×2.26
     步长 16(8×8 网格):锚框 [3, 4, 5],以本尺度格子为单位 1.41×1.80  1.88×1.53  2.01×2.57
     步长 32(4×4 网格):锚框 [6, 7, 8],以本尺度格子为单位 1.34×1.08  1.45×1.86  1.90×1.52
   k = 3:平均 IoU 0.663
   k = 5:平均 IoU 0.737
   k = 9:平均 IoU 0.789

2. 标签分配
   步长  8 负责  5647 个物体
   步长 16 负责  3869 个物体
   步长 32 负责  3983 个物体
   因同一格子同一锚框冲突而丢掉的物体:3 个尺度 2 个;只用步长 16 和 5 个锚框(第 06 篇的做法)13 个
   多标签:logit [3.0, 2.5, -2.0] → softmax [0.620, 0.376, 0.004],sigmoid [0.953, 0.924, 0.119]

3. 网络
   输出形状 (1, 16, 16, 3, 8),(1, 8, 8, 3, 8),(1, 4, 4, 3, 8);每张图 1008 个预测框
   参数 1,044,952,每张图 102.7 百万次乘加

4. 训练 mini-YOLOv3
   第  1 轮  总损失 46.175(坐标 12.436,有物体 13.842,无物体 11.263,类别 8.634)
   第  2 轮  总损失 28.162(坐标 10.131,有物体 6.259,无物体 6.373,类别 5.399)
   第  3 轮  总损失 18.401(坐标 9.694,有物体 3.636,无物体 2.892,类别 2.178)
   第  4 轮  总损失 14.589(坐标 9.419,有物体 2.437,无物体 1.538,类别 1.195)
   第  5 轮  总损失 13.001(坐标 9.279,有物体 1.878,无物体 1.056,类别 0.788)  验证 mAP@0.5 0.852  mAP@[.5:.95] 0.343
   第  6 轮  总损失 12.100(坐标 9.194,有物体 1.532,无物体 0.794,类别 0.580)
   第  7 轮  总损失 11.319(坐标 9.101,有物体 1.200,无物体 0.578,类别 0.442)
   第  8 轮  总损失 10.788(坐标 9.034,有物体 0.976,无物体 0.472,类别 0.305)
   第  9 轮  总损失 10.349(坐标 8.986,有物体 0.772,无物体 0.382,类别 0.209)
   第 10 轮  总损失 9.990(坐标 8.948,有物体 0.584,无物体 0.307,类别 0.151)  验证 mAP@0.5 0.928  mAP@[.5:.95] 0.519
   第 11 轮  总损失 9.717(坐标 8.918,有物体 0.424,无物体 0.255,类别 0.120)
   第 12 轮  总损失 9.443(坐标 8.880,有物体 0.273,无物体 0.201,类别 0.089)
   第 13 轮  总损失 9.244(坐标 8.842,有物体 0.180,无物体 0.166,类别 0.056)
   第 14 轮  总损失 9.099(坐标 8.815,有物体 0.117,无物体 0.127,类别 0.040)
   第 15 轮  总损失 8.925(坐标 8.766,有物体 0.055,无物体 0.077,类别 0.026)  验证 mAP@0.5 0.934  mAP@[.5:.95] 0.586
   第 16 轮  总损失 8.826(坐标 8.723,有物体 0.031,无物体 0.055,类别 0.017)
   第 17 轮  总损失 8.765(坐标 8.695,有物体 0.018,无物体 0.040,类别 0.012)
   第 18 轮  总损失 8.725(坐标 8.673,有物体 0.012,无物体 0.030,类别 0.010)
   第 19 轮  总损失 8.705(坐标 8.658,有物体 0.011,无物体 0.027,类别 0.009)
   第 20 轮  总损失 8.692(坐标 8.650,有物体 0.009,无物体 0.025,类别 0.008)  验证 mAP@0.5 0.932  mAP@[.5:.95] 0.628
   第 21 轮  总损失 8.678(坐标 8.640,有物体 0.008,无物体 0.022,类别 0.007)
   第 22 轮  总损失 8.669(坐标 8.633,有物体 0.007,无物体 0.021,类别 0.007)
   第 23 轮  总损失 8.662(坐标 8.628,有物体 0.007,无物体 0.020,类别 0.006)
   第 24 轮  总损失 8.657(坐标 8.624,有物体 0.007,无物体 0.021,类别 0.006)
   第 25 轮  总损失 8.652(坐标 8.621,有物体 0.007,无物体 0.019,类别 0.006)  验证 mAP@0.5 0.932  mAP@[.5:.95] 0.629
   第 26 轮  总损失 8.652(坐标 8.619,有物体 0.006,无物体 0.021,类别 0.006)
   第 27 轮  总损失 8.648(坐标 8.617,有物体 0.007,无物体 0.019,类别 0.006)
   第 28 轮  总损失 8.647(坐标 8.616,有物体 0.006,无物体 0.019,类别 0.006)
   第 29 轮  总损失 8.645(坐标 8.615,有物体 0.006,无物体 0.018,类别 0.006)
   第 30 轮  总损失 8.644(坐标 8.614,有物体 0.006,无物体 0.018,类别 0.006)  验证 mAP@0.5 0.932  mAP@[.5:.95] 0.632
   训练用时 46.0 分钟

5. 评估(置信度阈值 0.001,NMS 0.5)
   圆    AP@0.5 = 0.936  AP@0.75 = 0.786  AP@[.5:.95] = 0.648
   矩形   AP@0.5 = 0.926  AP@0.75 = 0.731  AP@[.5:.95] = 0.614
   三角形  AP@0.5 = 0.935  AP@0.75 = 0.762  AP@[.5:.95] = 0.636
   mAP@0.5 = 0.932,mAP@0.75 = 0.760,mAP@[.5:.95] = 0.632
   AP_S = 0.485,AP_M = 0.679,AP_L = 0.708
   训练集前 500 张:mAP@0.5 = 1.000,mAP@[.5:.95] = 0.866
   F1 最大的置信度阈值 0.48:精确率 0.968,召回率 0.908,F1 0.937

6. 分析
   只保留一个尺度的输出时(AP@[.5:.95]):
     步长  8:mAP = 0.279,AP_S = 0.485,AP_M = 0.427,AP_L = 0.000
     步长 16:mAP = 0.345,AP_S = 0.002,AP_M = 0.526,AP_L = 0.450
     步长 32:mAP = 0.252,AP_S = 0.000,AP_M = 0.059,AP_L = 0.607
   置信度 ≥ 0.48 的检测框来自哪个尺度,以及它们的边长中位数:
     步长  8: 829 个框,边长中位数 13.6 像素,范围 5.2~22.8
     步长 16: 705 个框,边长中位数 29.7 像素,范围 19.7~45.6
     步长 32: 610 个框,边长中位数 50.7 像素,范围 33.3~67.3
   推理速度:每张 4.44 ms(批大小 64)

附录 B:path_to_v3.py

第十节的实验代码。它导入本篇和第 06 篇的 example.py,训练 6 个模型中的 5 个(实验 1 直接读取附录 A 保存的 mini_yolov3.pt),结果写入 path_to_v3.json。

"""第 07 篇:从 mini-YOLOv2 到 mini-YOLOv3,逐项比较。
所有实验用同样的数据(训练 3000 张、验证 500 张,物体边长 6~64 像素)、同样的随机种子和训练设置(30 轮,最大学习率 3e-3)。

  0 mini-YOLOv2            :第 06 篇的网络和损失原样搬来,单尺度(步长 16),5 个 k-means 锚框
  1 mini-YOLOv3            :example.py 的主模型(3 个尺度 + 特征金字塔 + BCE),直接读取 mini_yolov3.pt,不重新训练
  2 去掉自顶向下路径        :同 1,但每个尺度只用骨干自己的特征(fpn=False)
  3 只用一个尺度            :同 1,但只在步长 16 上预测,9 个锚框都放在这个尺度
  4 残差骨干               :同 1,骨干换成 Darknet-53 式的“步长 2 卷积 + 残差块”
  5 学习率 1e-3            :同 1,最大学习率用第 05、06 篇的 1e-3
每个实验在 CPU 上训练约 30~60 分钟(取决于机器和同时运行的进程数)。结果写入 path_to_v3.json。
运行:python path_to_v3.py              (全部;需要先运行 example.py)
      python path_to_v3.py 0 3          (只跑第 0、3 个,结果写入 path_to_v3_0_3.json)
      python path_to_v3.py 2 -t4        (用 4 个线程,默认 4 个,与 example.py 相同)
"""
import importlib.util
import json
import pathlib
import sys
import time

import numpy as np
import torch

HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent / "common"))
import example as ex
from metrics import evaluate, summarize

# 第 06 篇的 example.py 与本篇的同名,用 importlib 按路径导入,起名 v2
_spec = importlib.util.spec_from_file_location("v2", HERE.parent / "06_YOLOv2锚框" / "example.py")
v2 = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(v2)


def v2_ms_per_image(model, imgs, reps=10):
    xb = torch.tensor(imgs[:64]).float() / 255
    model.eval()
    with torch.no_grad():
        model(xb)
        t0 = time.perf_counter()
        for _ in range(reps):
            model(xb)
    return (time.perf_counter() - t0) / reps / 64 * 1000


if __name__ == "__main__":
    torch.set_num_threads(int(sys.argv[-1][2:]) if sys.argv[-1].startswith("-t") else 4)
    picks = [int(a) for a in sys.argv[1:] if a.isdigit()] or list(range(6))
    tr_x, tr_gt = ex.make_data(ex.N_TRAIN, seed=0)
    va_x, va_gt = ex.make_data(ex.N_VAL, seed=1)
    wh = np.concatenate([np.stack([gb[:, 2] - gb[:, 0], gb[:, 3] - gb[:, 1]], 1) for gb, _ in tr_gt])
    anchors = ex.kmeans_anchors(wh, 9)
    variants = [
        ("mini-YOLOv2", None),
        ("mini-YOLOv3", None),
        ("去掉自顶向下路径", dict(fpn=False)),
        ("只用一个尺度", dict(heads=(1,), n_anchors=9)),
        ("残差骨干", dict(backbone="darknet")),
        ("学习率 1e-3", dict(lr=1e-3)),
    ]
    results = {}
    for k in picks:
        name, kw = variants[k]
        t0 = time.time()
        torch.manual_seed(0)
        if k == 0:
            a5 = v2.kmeans_anchors(wh / v2.STRIDE, 5)
            v2.LR = ex.LR
            model = v2.MiniYOLOv2()
            v2.train(model, a5, tr_x, tr_gt, va_x, va_gt, log=False)
            images = v2.detect(model, a5, va_x, va_gt)
            ms = v2_ms_per_image(model, va_x)
            macs = ex.count_macs(model)
        else:
            kw = dict(kw or {})
            lr = kw.pop("lr", ex.LR)
            model = ex.MiniYOLOv3(**kw)
            if k == 1:
                ck = torch.load(HERE / "mini_yolov3.pt", weights_only=False)
                model.load_state_dict(ck["model"])
                hist = json.loads((HERE / "history.json").read_text(encoding="utf-8"))
                t0 -= sum(h["time"] for h in hist)
            else:
                ex.LR = lr
                ex.train(model, anchors, tr_x, tr_gt, va_x, va_gt, log=False)
                ex.LR = 3e-3
            images = ex.detect(model, anchors, va_x, va_gt)
            ms = ex.ms_per_image(model, va_x)
            macs = ex.count_macs(model)
        m50, m75, m = summarize(evaluate(images))
        sa = ex.size_aps(images)
        results[name] = dict(map50=float(m50), map75=float(m75), map=float(m), ap_s=sa["S"], ap_m=sa["M"], ap_l=sa["L"],
                             params=sum(p.numel() for p in model.parameters()), macs=macs, ms=ms,
                             minutes=(time.time() - t0) / 60)
        r = results[name]
        print(f"{name:<10}:mAP@0.5 = {m50:.3f},mAP@0.75 = {m75:.3f},mAP@[.5:.95] = {m:.3f};"
              f"AP_S = {sa['S']:.3f},AP_M = {sa['M']:.3f},AP_L = {sa['L']:.3f};"
              f"参数 {r['params']:,},{macs / 1e6:.1f} 百万次乘加,每张 {ms:.2f} ms,用时 {r['minutes']:.1f} 分钟", flush=True)
    suffix = "" if picks == list(range(6)) else "_" + "_".join(map(str, picks))
    (HERE / f"path_to_v3{suffix}.json").write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8")

运行输出(本篇把实验 0、2、3、4、5 分成 5 个进程同时运行,最后单独运行实验 1,再把 6 个 json 合并为 path_to_v3.json;同时运行的进程互相抢 CPU,用时和推理速度不能互相比较):

mini-YOLOv2:mAP@0.5 = 0.882,mAP@0.75 = 0.640,mAP@[.5:.95] = 0.572;AP_S = 0.260,AP_M = 0.587,AP_L = 0.774;参数 250,552,73.5 百万次乘加,每张 4.88 ms,用时 36.8 分钟
mini-YOLOv3:mAP@0.5 = 0.932,mAP@0.75 = 0.760,mAP@[.5:.95] = 0.632;AP_S = 0.485,AP_M = 0.679,AP_L = 0.708;参数 1,044,952,102.7 百万次乘加,每张 1.83 ms,用时 53.5 分钟
去掉自顶向下路径  :mAP@0.5 = 0.938,mAP@0.75 = 0.769,mAP@[.5:.95] = 0.639;AP_S = 0.484,AP_M = 0.693,AP_L = 0.725;参数 917,720,87.9 百万次乘加,每张 4.00 ms,用时 52.4 分钟
只用一个尺度    :mAP@0.5 = 0.880,mAP@0.75 = 0.615,mAP@[.5:.95] = 0.560;AP_S = 0.265,AP_M = 0.597,AP_L = 0.785;参数 1,046,488,102.7 百万次乘加,每张 6.33 ms,用时 49.3 分钟
残差骨干      :mAP@0.5 = 0.928,mAP@0.75 = 0.798,mAP@[.5:.95] = 0.658;AP_S = 0.466,AP_M = 0.718,AP_L = 0.753;参数 1,556,856,120.0 百万次乘加,每张 2.24 ms,用时 57.3 分钟
学习率 1e-3  :mAP@0.5 = 0.917,mAP@0.75 = 0.592,mAP@[.5:.95] = 0.542;AP_S = 0.398,AP_M = 0.579,AP_L = 0.618;参数 1,044,952,102.7 百万次乘加,每张 2.50 ms,用时 54.5 分钟

附录 C:verify_with_pycocotools.py

用 pycocotools 核对附录 A 手写的 mAP 和 APS\mathrm{AP}_S、APM\mathrm{AP}_M、APL\mathrm{AP}_L。需要先 pip install pycocotools 并运行 example.py。

"""用 pycocotools 核对 example.py 里手写的 AP、AP_S、AP_M、AP_L(需要先运行 example.py,并 pip install pycocotools)。
把 mini-YOLOv3 在验证集上的检测结果交给 COCOeval,面积分段与 example.py 相同:S < 16²,16² ≤ M ≤ 32²,L > 32²。"""
import contextlib
import io
import pathlib
import sys

import numpy as np
import torch
from pycocotools.coco import COCO
from pycocotools.cocoeval import COCOeval

HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent / "common"))
import example as ex
from metrics import evaluate, summarize

torch.set_num_threads(4)
ck = torch.load(HERE / "mini_yolov3.pt", weights_only=False)
model = ex.MiniYOLOv3()
model.load_state_dict(ck["model"])
va_x, va_gt = ex.make_data(ex.N_VAL, seed=1)
images = ex.detect(model, ck["anchors"], va_x, va_gt)

gt = {"images": [], "annotations": [], "categories": [{"id": c + 1} for c in range(ex.C)]}
dets = []
for i, im in enumerate(images):
    gt["images"].append({"id": i + 1, "width": ex.IMG, "height": ex.IMG})
    for b, c in zip(im["gt_boxes"], im["gt_classes"]):
        w, h = b[2] - b[0], b[3] - b[1]
        gt["annotations"].append({"id": len(gt["annotations"]) + 1, "image_id": i + 1, "category_id": int(c) + 1,
                                  "bbox": [float(b[0]), float(b[1]), float(w), float(h)], "area": float(w * h), "iscrowd": 0})
    for b, s, c in zip(im["boxes"], im["scores"], im["classes"]):
        dets.append({"image_id": i + 1, "category_id": int(c) + 1, "score": float(s),
                     "bbox": [float(b[0]), float(b[1]), float(b[2] - b[0]), float(b[3] - b[1])]})

with contextlib.redirect_stdout(io.StringIO()):
    coco = COCO()
    coco.dataset = gt
    coco.createIndex()
    E = COCOeval(coco, coco.loadRes(dets), "bbox")
    E.params.areaRng = [[0, 1e10], [0, 16 ** 2], [16 ** 2, 32 ** 2], [32 ** 2, 1e10]]
    E.evaluate()
    E.accumulate()
    E.summarize()
ours = list(summarize(evaluate(images)))
sa = ex.size_aps(images)
ours += [sa["S"], sa["M"], sa["L"]]
names = ["mAP@[.5:.95]", "mAP@0.5", "mAP@0.75", "AP_S", "AP_M", "AP_L"]
theirs = E.stats[:6]
ours = [ours[2], ours[0], ours[1], ours[3], ours[4], ours[5]]
for n, a, b in zip(names, ours, theirs):
    print(f"{n:<13} 手写 {a:.6f}   pycocotools {b:.6f}   差 {abs(a - b):.1e}")

运行输出:

mAP@[.5:.95]  手写 0.632302   pycocotools 0.632302   差 1.1e-16
mAP@0.5       手写 0.932268   pycocotools 0.932268   差 0.0e+00
mAP@0.75      手写 0.759692   pycocotools 0.759692   差 0.0e+00
AP_S          手写 0.485237   pycocotools 0.485237   差 1.1e-16
AP_M          手写 0.679419   pycocotools 0.679419   差 1.1e-16
AP_L          手写 0.707609   pycocotools 0.707609   差 1.1e-16