PyTorch 版 YOLOv3 源码逐段详解:从配置文件到训练、评估、检测(定制自己的数据集)

引言:这是一个什么样的项目,读完你能学到什么

这是一个基于 eriklindernoren/PyTorch-YOLOv3 二次改造的目标检测项目。它在保持 YOLOv3 经典结构不动的前提下,把「通用 80 类(COCO)」改造成了「自己的数据集」------在这个副本里,数据集是 dog / cat 共 2 类。你可以把它理解成一份「能直接上手训练自己数据」的 YOLOv3 教学代码:结构清晰、注释多、代码量不大(核心逻辑全部集中在 4 个文件里),非常适合拿来逐行啃。

读完这份博客,你可以搞清楚下面这些事:

  • YOLOv3 到底是怎么把一张图「切成网格、在每个格子上猜框」的;先验框(anchor)、三尺度(FPN)、解码公式 bx = σ(tx) + cx、bw = pw · e^{tw} 分别是什么意思。
  • Darknet 的 .cfg 配置文件是怎么被解析成 PyTorch 网络层的:[net]、[convolutional]、[route]、[shortcut]、[upsample]、[yolo] 各是什么,filters = 3 × (classes + 5) 这条规则从哪来。
  • route 的拼接、shortcut 的残差相加、upsample 的上采样,是怎么组合出 FPN 多尺度特征的。
  • 标注数据是怎么流转的:从 labelme 的 .json → YOLO 的 .txt(归一化的 cx cy w h)→ Dataset 里读出来还原成像素坐标 → 训练时再切回 grid 尺度。
  • 损失函数是怎么设计的:build_targets 里的「掩码 + 责任分配(哪个 anchor 该负责哪个目标)」讲明白之后,obj/noobj 的 BCE、坐标的 MSE、分类的 BCE 就水到渠成了。
  • 训练、评估(mAP)、推理检测三个脚本分别干什么,常见坑怎么绕。

说明:本文引用的代码全部来自本项目的真实文件,按「文件 → 函数/代码块 → 逐段讲解」的顺序展开。为方便你对照,代码里的注释有的是作者原文,我会在此基础上用大白话补充解释。文中所有路径一律使用相对路径。


一、YOLOv3 原理速览

1.1 单阶段检测:一步到位,不要「先找候选再分类」

目标检测大致分成两条技术路线:

  • 两阶段(two-stage):如 Faster R-CNN,先让「区域建议网络」在图上圈出一堆可能含物体的候选框,再对每个候选框做分类和回归。精度高但慢。
  • 单阶段(one-stage):如 YOLO、SSD,直接在特征图上「一次前向」同时预测框的位置、置信度和类别。速度快,端到端。

YOLOv3 是典型的单阶段检测器:一张图只过一次网络,输出的就是一堆带类别和分数的框。

1.2 网格 + 先验框 + 三尺度

YOLO 的核心思想是「把检测问题转成网格上的回归问题」:

  1. 把输入图(例如 416 × 416)划分成 S × S 个网格(grid)。416 输入下,YOLOv3 会用到三种网格:13 × 13、26 × 26、52 × 52。
  2. 物体的中心点落在哪个格子里,就由哪个格子负责预测它。这解决了「谁来负责」的问题。
  3. 每个格子不只预测 1 个框,而是预测多个「先验框(anchor)」。YOLOv3 每个尺度上放 3 个 anchor,三种尺度一共 9 个 anchor。也就是每个格子输出 3 × (5 + classes) 个数:5 是 tx, ty, tw, th, conf(置信度),classes 是每个类别的概率。
  4. 三尺度(FPN):小网格(13×13)感受野大,负责大物体;大网格(52×52)分辨率高,负责小物体。三者互补,这就是「多尺度检测」。大尺度特征图上采样后与浅层特征拼接,兼顾「语义强」和「细节多」。

一句话总结:把图切网格,每个网格用 3 个先验框猜「有没有物体、是什么、框在哪」,三层网格覆盖大中小物体。

1.3 框是怎么「解码」出来的

网络输出的并不是最终坐标,而是相对量。设某个网格左上角坐标为 (cx, cy),该网格对应的 anchor 宽高为 (pw, ph),网络输出 tx, ty, tw, th,那么真实框的中心和宽高是:

复制代码
bx = σ(tx) + cx
by = σ(ty) + cy
bw = pw · e^{tw}
bh = ph · e^{th}

含义:

  • σ 是 sigmoid,把 tx / ty 压到 (0, 1)。意义是「中心点相对当前格子左上角的偏移」,保证中心点仍然落在本格子内部,不让它跑到别的格子去。
  • cx, cy 是格子索引 (相对坐标),σ(tx) + cx 就把「格内相对位置」变成了「全图网格坐标系下的绝对位置」。
  • tw, th 用 e^{tw} 指数映射。因为宽高必须是正数,而网络输出可正可负,于是用指数把它映射成正的缩放系数,再乘上 anchor 的宽高 pw, ph,得到实际框的大小。

所以 anchor 起了「基准尺寸」的作用,网络只学一个「相对 anchor 的缩放和偏移」,学习起来更容易。

置信度 conf 一般理解为 P(有物体) × IoU(预测框, 真实框),分类概率是每个类别的条件概率;推理时两者相乘作为最终得分。

1.4 从「一堆预测」到「最终结果」:NMS

三个尺度解码后会产出极多的框((13² + 26² + 52²) × 3 ≈ 10647 个)。绝大多数是「没有物体的背景框」,还有大量「同一个物体被重复框住」。所以需要一个筛选流程:

  1. 按置信度阈值过滤掉低分框;
  2. 用「置信度 × 类别概率」排序;
  3. 做 非极大值抑制(Non-Maximum Suppression, NMS):对同一类别,如果两个框重叠太多(IoU 超过阈值),就只保留分数高的那个。

这正是 utils/utils.py 里 non_max_suppression 干的事。

1.5 整体数据流(mermaid)

#mermaid-svg-e4QlMNSMq0Q8jaKk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-e4QlMNSMq0Q8jaKk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-e4QlMNSMq0Q8jaKk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-e4QlMNSMq0Q8jaKk .error-icon{fill:#552222;}#mermaid-svg-e4QlMNSMq0Q8jaKk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-e4QlMNSMq0Q8jaKk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-e4QlMNSMq0Q8jaKk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-e4QlMNSMq0Q8jaKk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-e4QlMNSMq0Q8jaKk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-e4QlMNSMq0Q8jaKk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-e4QlMNSMq0Q8jaKk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-e4QlMNSMq0Q8jaKk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-e4QlMNSMq0Q8jaKk .marker.cross{stroke:#333333;}#mermaid-svg-e4QlMNSMq0Q8jaKk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-e4QlMNSMq0Q8jaKk p{margin:0;}#mermaid-svg-e4QlMNSMq0Q8jaKk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-e4QlMNSMq0Q8jaKk .cluster-label text{fill:#333;}#mermaid-svg-e4QlMNSMq0Q8jaKk .cluster-label span{color:#333;}#mermaid-svg-e4QlMNSMq0Q8jaKk .cluster-label span p{background-color:transparent;}#mermaid-svg-e4QlMNSMq0Q8jaKk .label text,#mermaid-svg-e4QlMNSMq0Q8jaKk span{fill:#333;color:#333;}#mermaid-svg-e4QlMNSMq0Q8jaKk .node rect,#mermaid-svg-e4QlMNSMq0Q8jaKk .node circle,#mermaid-svg-e4QlMNSMq0Q8jaKk .node ellipse,#mermaid-svg-e4QlMNSMq0Q8jaKk .node polygon,#mermaid-svg-e4QlMNSMq0Q8jaKk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-e4QlMNSMq0Q8jaKk .rough-node .label text,#mermaid-svg-e4QlMNSMq0Q8jaKk .node .label text,#mermaid-svg-e4QlMNSMq0Q8jaKk .image-shape .label,#mermaid-svg-e4QlMNSMq0Q8jaKk .icon-shape .label{text-anchor:middle;}#mermaid-svg-e4QlMNSMq0Q8jaKk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-e4QlMNSMq0Q8jaKk .rough-node .label,#mermaid-svg-e4QlMNSMq0Q8jaKk .node .label,#mermaid-svg-e4QlMNSMq0Q8jaKk .image-shape .label,#mermaid-svg-e4QlMNSMq0Q8jaKk .icon-shape .label{text-align:center;}#mermaid-svg-e4QlMNSMq0Q8jaKk .node.clickable{cursor:pointer;}#mermaid-svg-e4QlMNSMq0Q8jaKk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-e4QlMNSMq0Q8jaKk .arrowheadPath{fill:#333333;}#mermaid-svg-e4QlMNSMq0Q8jaKk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-e4QlMNSMq0Q8jaKk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-e4QlMNSMq0Q8jaKk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-e4QlMNSMq0Q8jaKk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-e4QlMNSMq0Q8jaKk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-e4QlMNSMq0Q8jaKk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-e4QlMNSMq0Q8jaKk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-e4QlMNSMq0Q8jaKk .cluster text{fill:#333;}#mermaid-svg-e4QlMNSMq0Q8jaKk .cluster span{color:#333;}#mermaid-svg-e4QlMNSMq0Q8jaKk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-e4QlMNSMq0Q8jaKk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-e4QlMNSMq0Q8jaKk rect.text{fill:none;stroke-width:0;}#mermaid-svg-e4QlMNSMq0Q8jaKk .icon-shape,#mermaid-svg-e4QlMNSMq0Q8jaKk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-e4QlMNSMq0Q8jaKk .icon-shape p,#mermaid-svg-e4QlMNSMq0Q8jaKk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-e4QlMNSMq0Q8jaKk .icon-shape .label rect,#mermaid-svg-e4QlMNSMq0Q8jaKk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-e4QlMNSMq0Q8jaKk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-e4QlMNSMq0Q8jaKk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-e4QlMNSMq0Q8jaKk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 输入图像 416x416
Darknet53 骨干

一系列 conv+BN+Leaky
13x13 特征图
26x26 特征图
52x52 特征图
YOLO层1

大物体 3个anchor
YOLO层2

中物体 3个anchor
YOLO层3

小物体 3个anchor
解码: bx,by,bw,bh
拼接所有尺度预测
置信度过滤
NMS 非极大值抑制
最终检测框


二、项目目录结构

2.1 目录树

text 复制代码
PyTorch-YOLOv3_myself_data/
├── config/
│   ├── custom.data            # 自定义数据集配置(2类)
│   ├── coco.data              # COCO 数据集配置(80类)
│   ├── yolov3-custom.cfg      # 自定义模型结构(classes=2, filters=21)
│   ├── yolov3.cfg             # 官方 80 类模型结构
│   ├── yolov3-tiny.cfg        # tiny 版结构
│   └── create_custom_model.sh # 按类别数生成 cfg 的脚本
├── data/
│   ├── custom/                # 官方示例(单张图片 + 对应标签)
│   ├── custom1/               # 真正的自定义数据集
│   │   ├── images/train/      # 训练集图片
│   │   ├── labels/train/      # 训练集标签(归一化的 txt)
│   │   ├── jsons/             # labelme 标注的 json(原始)
│   │   ├── classes.names      # 类别名(dog/cat)
│   │   ├── train.txt          # 训练集文件清单
│   │   └── valid.txt          # 验证集文件清单
│   ├── txt/                   # json2yolo 输出的 txt 目录
│   └── classes.names / coco.names
├── utils/
│   ├── __init__.py
│   ├── parse_config.py        # 解析 .cfg / .data
│   ├── json2yolo.py           # labelme json 转 YOLO txt
│   ├── augmentations.py       # 数据增强
│   ├── datasets.py            # Dataset / collate_fn / 多尺度
│   ├── utils.py               # IoU / build_targets / NMS / mAP
│   └── logger.py              # TensorBoard 日志
├── weights/
│   ├── darknet53.conv.74      # 预训练骨干权重(迁移学习用)
│   └── yolov3.weights         # 官方完整权重
├── models.py                  # Darknet 网络 / YOLOLayer / 损失 / 权重加载
├── train.py                   # 训练入口
├── test.py                    # 评估入口(算 mAP)
├── detect.py                  # 推理/可视化入口
├── checkpoints/               # 训练时保存的 .pth
├── output/                    # detect 可视化结果
└── logs/                      # TensorBoard 日志目录

2.2 各文件一句话职责

文件 一句话职责
config/custom.data 告诉程序「几类、训练/验证清单在哪、类名在哪」
config/yolov3-custom.cfg 描述网络每一层的结构,2 类对应 filters=21
config/create_custom_model.sh 用 sed 式脚本按类别数批量生成一份 cfg
utils/parse_config.py 把 .cfg、.data 文本解析成 Python 的字典/列表
utils/json2yolo.py 把 labelme 的 json 标注转成 YOLO 的归一化 txt
utils/augmentations.py 数据增强(这里实现了水平翻转)
utils/datasets.py 读图片/标签、补边成正方形、缩放、多尺度、拼 batch
utils/utils.py 通用工具:IoU、目标构建、NMS、mAP 计算、坐标变换
utils/logger.py 把训练指标写进 TensorBoard
models.py 网络定义、YOLO 解码层、损失、darknet 权重读写
train.py 训练主循环
test.py 在验证集上评估,输出 precision/recall/mAP
detect.py 对图片推理并画框保存

三、配置文件详解

YOLO 的「网络结构」和「数据集信息」都不写在 Python 里,而是写在配置文件中。这是 darknet 生态的传统,好处是改结构/改类别数时不用动代码。

3.1 config/custom.data:数据集配置

text 复制代码
classes= 2
train=data/custom1/train.txt
valid=data/custom1/valid.txt
names=data/custom1/classes.names

逐行解释:

  • classes= 2:本数据集有 2 个类别(dog、cat)。这个数字必须和 .cfg 里 [yolo] 层的 classes 一致,否则维度会对不上。
  • train=...:训练集图片路径清单文件。注意它指向的不是图片文件夹,而是一个 .txt 清单,里面一行一个图片路径。
  • valid=...:验证集清单。
  • names=...:类别名文件。作用是训练日志里能把「class 0」翻译成「dog」打印出来,另外评估表格里显示类名。

对比 config/coco.data(80 类):

text 复制代码
classes= 80
train=data/coco/trainvalno5k.txt
valid=data/coco/5k.txt
names=data/coco.names
backup=backup/
eval=coco

可以看到多了 backup/、eval= 这类 darknet 专用字段,本项目的 PyTorch 代码其实只用到 classes/train/valid/names 这四个键(parse_data_config 解析时会把其他键也读进来,但代码没使用)。

清单文件长什么样?看 data/custom1/train.txt 的前几行:

text 复制代码
data/custom1/images/train/0239fb95fc6379.jpg
data/custom1/images/train/798846c5c983512.jpg
data/custom1/images/train/00505165161.png
...

关键点 :每行是「图片的路径」。标签路径并不是显式写的,而是靠 datasets.py 里把 images 替换成 labels、把 .jpg/.png 替换成 .txt 推导出来的。所以图片和标签的目录结构必须严格对应,命名必须一一对应。

3.2 config/yolov3-custom.cfg:网络结构

这个文件由 create_custom_model.sh 生成(见 3.3),生成出来的就是官方 yolov3.cfg 的结构,只把类别相关的两处改掉。文件由若干「块」组成,每块以 [类型] 开头,例如 [net]、[convolutional]、[route]、[shortcut]、[upsample]、[yolo]。

[net] 块:全局超参
text 复制代码
[net]
batch=16
subdivisions=1
width=416
height=416
channels=3
momentum=0.9
decay=0.0005
...
learning_rate=0.001
max_batches = 500200
policy=steps
steps=400000,450000
scales=.1,.1
  • width/height=416:注意!真正决定输入尺寸的是代码里的 --img_size(默认 416),而 [net] 里的 height 被 create_modules 用来读取 anchor 缩放的参考尺寸 (img_size = int(hyperparams["height"]))。两者保持一致最安全。
  • channels=3:输入 RGB 三通道。create_modules 用它作为第一层卷积的输入通道数。
  • batch / subdivisions / learning_rate / policy / steps / scales 这些是 darknet 原生的训练超参,在本项目的 PyTorch 代码里基本不会被读取(学习率、batch 都由命令行参数和 DataLoader 决定)。留着它们只是为了兼容,不影响运行。
  • 真正被代码用到的 [net] 字段只有:channels(首层输入通道)、height(anchor 参考尺寸)。
[convolutional] 块:卷积层
text 复制代码
[convolutional]
batch_normalize=1
filters=32
size=3
stride=1
pad=1
activation=leaky
  • batch_normalize=1:是否接 BatchNorm。为 1 时,卷积不带 bias (因为 BN 自带偏置),后面紧跟 BatchNorm2d。
  • filters=32:输出通道数(=卷积核个数)。
  • size=3:卷积核大小 3×3。
  • stride=1:步长。
  • pad=1:原版是「填几圈」。但在 create_modules 里,padding 并不直接取 pad,而是 pad = (kernel_size - 1) // 2,也就是「same padding」,保证输入输出空间尺寸不变(stride=1 时)。所以 pad=1 这行其实被忽略。
  • activation=leaky:LeakyReLU(0.1);若是 linear 则不接激活。
[shortcut] 块:残差相加
text 复制代码
[shortcut]
from=-3
activation=linear

shortcut 实现的是 ResNet 那种「跳连相加」:把当前层输出和前面某一层的输出逐元素相加。from=-3 表示「往前数第 3 层」(相对当前层)。注意这里的 activation=linear 表示相加后不做额外激活。

[route] 块:特征拼接
text 复制代码
[route]
layers = -4
...
[route]
layers = -1, 61
  • layers = -4:只取往前数第 4 层,相当于「索引」。常用于在进入 yolo 层之前,把特征图「引」出来做路由。
  • layers = -1, 61:取「上一层」和「第 61 层」两份特征,然后沿通道维 concat 拼接。这就是 FPN 里的「上采样 + 浅层特征融合」。
[upsample] 块:上采样
text 复制代码
[upsample]
stride=2

把特征图空间尺寸放大 2 倍(最近邻插值 nearest),用于把深层小特征图放大到能和浅层大特征图拼接。

[yolo] 块:检测层
text 复制代码
[yolo]
mask = 6,7,8
anchors = 10,13,  16,30,  33,23,  30,61,  62,45,  59,119,  116,90,  156,198,  373,326
classes=2
num=9
ignore_thresh = .7
truth_thresh = 1
  • anchors:9 个先验框的宽高对,是 COCO 上聚类出来的。按「小 → 大」排列:(10,13) (16,30) (33,23) 偏小,(30,61) (62,45) (59,119) 中等,(116,90) (156,198) (373,326) 偏大。
  • mask = 6,7,8:这一层只用 9 个 anchor 中索引为 6、7、8 的三个 (即最大的三个),因为这一层是 13×13、负责大物体。另两个 yolo 层分别用 mask = 3,4,5(中)和 mask = 0,1,2(小)。
  • classes=2:2 类。
  • ignore_thresh = .7:训练时的「忽略阈值」。某个预测框 IoU 超过它时,即使它不是最负责的那个框,也不算作负样本(避免惩罚「其实框得还不错」的预测)。
  • num=9:anchor 总数。项目代码中 create_modules 只使用了 mask、anchors、classes,num/ignore_thresh/truth_thresh 在 Python 侧被忽略(ignore_thres 硬编码为 0.5)。
三处 yolo 层 + filters=21

文件里有 3 个 [yolo] 块,对应三个尺度。每个 yolo 层前面都有一个「卷积头」把通道数压成:

text 复制代码
[convolutional]
size=1
stride=1
pad=1
filters=21
activation=linear

filters=21 的来历是这条规则:

复制代码
filters = 3 × (classes + 5)

其中:

  • 3:每个格子 3 个 anchor;
  • classes:类别数(这里是 2);
  • 5:每个框要预测 4 个坐标 tx,ty,tw,th + 1 个置信度 conf。

所以 3 × (2 + 5) = 21。这就是「最后一层卷积输出通道」必须等于 21 的原因,也解释了为什么改类别数时必须同步改这最后一层的 filters ------否则解码时 x.view(N, 3, classes+5, g, g) 会对不上。

对比 create_custom_model.sh 生成时用的是:

text 复制代码
filters=$(expr 3 \* $(expr $NUM_CLASSES \+ 5))

也就是脚本里按类别数自动算出来的。

网络整体结构(mermaid)

#mermaid-svg-jDnXJiickqwTrgEA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jDnXJiickqwTrgEA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jDnXJiickqwTrgEA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jDnXJiickqwTrgEA .error-icon{fill:#552222;}#mermaid-svg-jDnXJiickqwTrgEA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jDnXJiickqwTrgEA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jDnXJiickqwTrgEA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jDnXJiickqwTrgEA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jDnXJiickqwTrgEA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jDnXJiickqwTrgEA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jDnXJiickqwTrgEA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jDnXJiickqwTrgEA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jDnXJiickqwTrgEA .marker.cross{stroke:#333333;}#mermaid-svg-jDnXJiickqwTrgEA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jDnXJiickqwTrgEA p{margin:0;}#mermaid-svg-jDnXJiickqwTrgEA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-jDnXJiickqwTrgEA .cluster-label text{fill:#333;}#mermaid-svg-jDnXJiickqwTrgEA .cluster-label span{color:#333;}#mermaid-svg-jDnXJiickqwTrgEA .cluster-label span p{background-color:transparent;}#mermaid-svg-jDnXJiickqwTrgEA .label text,#mermaid-svg-jDnXJiickqwTrgEA span{fill:#333;color:#333;}#mermaid-svg-jDnXJiickqwTrgEA .node rect,#mermaid-svg-jDnXJiickqwTrgEA .node circle,#mermaid-svg-jDnXJiickqwTrgEA .node ellipse,#mermaid-svg-jDnXJiickqwTrgEA .node polygon,#mermaid-svg-jDnXJiickqwTrgEA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jDnXJiickqwTrgEA .rough-node .label text,#mermaid-svg-jDnXJiickqwTrgEA .node .label text,#mermaid-svg-jDnXJiickqwTrgEA .image-shape .label,#mermaid-svg-jDnXJiickqwTrgEA .icon-shape .label{text-anchor:middle;}#mermaid-svg-jDnXJiickqwTrgEA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-jDnXJiickqwTrgEA .rough-node .label,#mermaid-svg-jDnXJiickqwTrgEA .node .label,#mermaid-svg-jDnXJiickqwTrgEA .image-shape .label,#mermaid-svg-jDnXJiickqwTrgEA .icon-shape .label{text-align:center;}#mermaid-svg-jDnXJiickqwTrgEA .node.clickable{cursor:pointer;}#mermaid-svg-jDnXJiickqwTrgEA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-jDnXJiickqwTrgEA .arrowheadPath{fill:#333333;}#mermaid-svg-jDnXJiickqwTrgEA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-jDnXJiickqwTrgEA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-jDnXJiickqwTrgEA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jDnXJiickqwTrgEA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-jDnXJiickqwTrgEA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jDnXJiickqwTrgEA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-jDnXJiickqwTrgEA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-jDnXJiickqwTrgEA .cluster text{fill:#333;}#mermaid-svg-jDnXJiickqwTrgEA .cluster span{color:#333;}#mermaid-svg-jDnXJiickqwTrgEA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-jDnXJiickqwTrgEA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-jDnXJiickqwTrgEA rect.text{fill:none;stroke-width:0;}#mermaid-svg-jDnXJiickqwTrgEA .icon-shape,#mermaid-svg-jDnXJiickqwTrgEA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jDnXJiickqwTrgEA .icon-shape p,#mermaid-svg-jDnXJiickqwTrgEA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-jDnXJiickqwTrgEA .icon-shape .label rect,#mermaid-svg-jDnXJiickqwTrgEA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jDnXJiickqwTrgEA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-jDnXJiickqwTrgEA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-jDnXJiickqwTrgEA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 416x416x3
Darknet53 骨干

逐级下采样
52x52
26x26
13x13
3x3 conv + 1x1 conv

filters=21
YOLO层 mask 6,7,8

13x13 大物体
route -4 -> 1x1 conv -> upsample x2
与第61层 concat
3x3 + 1x1 filters=21
YOLO层 mask 3,4,5

26x26 中物体
route -4 -> 1x1 conv -> upsample x2
与第36层 concat
3x3 + 1x1 filters=21
YOLO层 mask 0,1,2

52x52 小物体

3.3 config/create_custom_model.sh:一键生成 cfg

这是一个 shell 脚本,用途是:给一个类别数,就吐出一份对应的 yolov3-custom.cfg。

bash 复制代码
#!/bin/bash

NUM_CLASSES=$1

echo "
[net]
...
[convolutional]
size=1
stride=1
pad=1
filters=$(expr 3 \* $(expr $NUM_CLASSES \+ 5))
activation=linear


[yolo]
mask = 6,7,8
...
classes=$NUM_CLASSES
num=9
...
" >> yolov3-custom.cfg

讲解:

  • NUM_CLASSES=$1:取命令行第一个参数作为类别数。
  • $(expr 3 \* $(expr $NUM_CLASSES \+ 5)):shell 里做算术,算出 3 × (classes + 5)。注意 \* 和 \+ 的转义,因为在双引号字符串里 *、+ 可能被 shell 特殊处理。
  • 整段内容通过 echo "..." >> yolov3-custom.cfg(追加 )写进文件。因为用的是 >> 而不是 >,如果目标文件已存在且非空,会重复追加,运行前最好确认/清空目标文件。
  • classes=$NUM_CLASSES:三处 yolo 层都会被替换成实际类别数(脚本里这段其实只展示了第一个 yolo 层,完整脚本三个 yolo 层都写了)。

用法(相对路径):

bash 复制代码
bash config/create_custom_model.sh 2

注意:本项目的 config/yolov3-custom.cfg 已经是生成好的成品 (classes=2、三处 filters=21),直接用它即可,一般不需要再跑这个脚本。

【截图占位:项目目录结构 / config 目录截图】


四、utils/parse_config.py 逐段详解

这个文件只有两个函数,却承担了「把文本配置变成程序能用的数据结构」的重任。.cfg 和 .data 两种格式不同,所以用两个解析函数。

4.1 parse_model_config:解析模型结构 .cfg

python 复制代码
def parse_model_config(path):
    """Parses the yolo-v3 layer configuration file and returns module definitions"""
    file = open(path, 'r')
    lines = file.read().split('\n')
    lines = [x for x in lines if x and not x.startswith('#')]   # 去掉空行和注释行
    lines = [x.rstrip().lstrip() for x in lines] # get rid of fringe whitespaces
    module_defs = []
    for line in lines:
        if line.startswith('['): # This marks the start of a new block
            module_defs.append({})
            module_defs[-1]['type'] = line[1:-1].rstrip()
            if module_defs[-1]['type'] == 'convolutional':
                module_defs[-1]['batch_normalize'] = 0
        else:
            key, value = line.split("=")
            value = value.strip()
            module_defs[-1][key.rstrip()] = value.strip()

    return module_defs

逐段讲解:

  • lines = file.read().split('\n'):把整个文件按换行切成一行一行的列表。
  • [x for x in lines if x and not x.startswith('#')]:过滤 。if x 表示保留非空行;not x.startswith('#') 表示去掉以 # 开头的注释行。注意这里还没有去掉行首空白,所以如果某行的注释前面有空格(如 # xxx),startswith('#') 会是 False,这行会被保留------这就是为什么后面的 split("=") 有潜在风险,好在 cfg 里注释都是顶格写的。
  • [x.rstrip().lstrip() for x in lines]:去掉每行首尾空白,等价于 x.strip()。
  • 关键循环:
    • 遇到 [ 开头:说明是个新块的开始,先 append({}) 占位,再把 line[1:-1](即去掉方括号)作为 type 存进去。例如 [convolutional] → {'type': 'convolutional'}。
    • 为什么给 convolutional 默认 batch_normalize=0? 因为如果 cfg 里没写 batch_normalize,后面 create_modules 读 int(module_def["batch_normalize"]) 时会 KeyError。先给个默认值 0 兜底。
    • 否则是 key=value 行:用 = 拆成键值对,key.rstrip() 去空格、value.strip() 去空格,存进「当前块」(module_defs[-1])。用 = 右侧的 .strip() 可以顺便处理 classes= 2 这种带空格的写法。

返回值形如:

python 复制代码
[
    {'type': 'net', 'batch': '16', 'width': '416', ...},
    {'type': 'convolutional', 'batch_normalize': '1', 'filters': '32', ...},
    {'type': 'shortcut', 'from': '-3', ...},
    ...
]

即一个列表,每个元素是一个字典,代表一个块 。注意所有值都是字符串 ,所以 create_modules 里到处是 int(...) 的转换。

4.2 parse_data_config:解析数据集 .data

python 复制代码
def parse_data_config(path):
    """Parses the data configuration file"""
    options = dict()
    options['gpus'] = '0,1,2,3'
    options['num_workers'] = '10'
    with open(path, 'r') as fp:
        lines = fp.readlines()
    for line in lines:
        line = line.strip()
        if line == '' or line.startswith('#'):
            continue
        key, value = line.split('=')
        options[key.strip()] = value.strip()
    return options

讲解:

  • 先给 options 塞两个默认键 gpus、num_workers。这两个其实是官方原版留下的痕迹,本项目代码里并没有真正使用它们(Multi-GPU 的写法被去掉了),留着不碍事。
  • fp.readlines():按行读进来,每行末尾带 \n,被 line.strip() 去掉。
  • 跳过空行和 # 注释行。
  • 同样用 line.split('=') 拆键值。坑点 :如果 value 本身含 =(例如路径里带 =),split 会得到多于两个元素从而报错;但正常的小写 key 不会遇到。更稳妥的写法是 line.split('=', 1)。
  • 返回一个普通字典,例如解析 config/custom.data 得到:
python 复制代码
{'classes': '2', 'train': 'data/custom1/train.txt',
 'valid': 'data/custom1/valid.txt', 'names': 'data/custom1/classes.names',
 'gpus': '0,1,2,3', 'num_workers': '10'}

注意 classes 也是字符串 '2',后续如果要当数字用需要自己转。


五、utils/json2yolo.py 逐段详解(标注格式转换)

训练前要把「人标注的原始数据」变成「YOLO 能吃的格式」。这个文件负责把 labelme 工具产出的 .json 转成 YOLO 的 .txt。

先说清楚两种格式的区别:

  • labelme 的 json :里面存的是若干个 shapes,每个 shape 有 label(类名)、shape_type(形状,这里是 rectangle 矩形)、points(矩形左上、右下两个点的像素坐标),以及整图的 imageWidth、imageHeight。
  • YOLO 的 txt :每行一个物体,格式为 类别索引 cx cy w h,后四个都是归一化到 0~1 的比例 (相对于图像宽高)。cx cy 是框中心,w h 是框宽高。

5.1 类别映射 name2id

python 复制代码
import json
import os
#文件代码功能:是将labelme打标产生的json文件转换为yolo所能识别的标准格式txt。
# name2id = {'dog':0,'cat':1,'Fire extinguisher':2,'Hook':3,'Gas cylinder':4}
name2id = {'dog': 0, 'cat': 1}  # json中有多少个类型,这里就定义多少个
  • name2id 是「类名字符串 → 类别编号」的字典。YOLO 训练只认数字编号,所以必须把 labelme 里的中文/英文类名映射成 0、1、2...
  • 注释里那行被注释掉的 5 类版本,是作者之前的数据集留下的,说明换数据集时只改这里。
  • 重要 :这里的编号顺序,必须和 data/custom1/classes.names 里的行顺序 一致。classes.names 第 0 行是 dog、第 1 行是 cat,对应 name2id 里 dog→0、cat→1。如果两者错位,标签就全错了。

5.2 convert:像素坐标 → 归一化坐标

python 复制代码
def convert(img_size, box):#对标签坐标  做 单位化
    dw = 1. / (img_size[0])
    dh = 1. / (img_size[1])
    x = (box[0] + box[2]) / 2.0 - 1
    y = (box[1] + box[3]) / 2.0 - 1
    w = box[2] - box[0]
    h = box[3] - box[1]
    x = x * dw
    w = w * dw
    y = y * dh
    h = h * dh
    return (x, y, w, h)
  • 输入 box = (x1, y1, x2, y2) 是矩形左上、右下像素坐标;img_size = (img_w, img_h)。
  • dw = 1/img_w、dh = 1/img_h:把「除以宽/高」提前算成乘法,省点除法。
  • x = (x1 + x2)/2:中心横坐标。y = (y1 + y2)/2:中心纵坐标。
  • w = x2 - x1、h = y2 - y1:框宽、框高。
  • 然后分别乘 dw/dh 归一化。返回 (cx, cy, w, h),都在 0~1 之间。
  • 注意一个细节 :这里写成 (box[0] + box[2]) / 2.0 - 1,中间减了个 1。这其实是原作者的写法,严格来说会让中心点偏移 0.5 个像素(归一化前),影响极小,因为后面还会被网络学习吸收,但严格讲这 1 是多余的。知道这个点即可,不影响使用。

5.3 decode_json:逐个 json 转 txt

python 复制代码
def decode_json(json_floder_path, json_name):
    # 转换好的标签路径
    txt_name = '..\\data\\txt\\txt' + json_name[0:-5] + '.txt'
    txt_file = open(txt_name, 'w')
    json_path = os.path.join(json_floder_path, json_name)
    data = json.load(open(json_path, 'r', encoding='utf-8'))
    img_w = data['imageWidth']
    img_h = data['imageHeight']
    for i in data['shapes']:
        label_name = i['label']
        if (i['shape_type'] == 'rectangle'):
            x1 = int(i['points'][0][0])
            y1 = int(i['points'][0][1])
            x2 = int(i['points'][1][0])
            y2 = int(i['points'][1][1])
            bb = (x1, y1, x2, y2)
            bbox = convert((img_w, img_h), bb)  #
            txt_file.write(str(name2id[label_name]) + " " + " ".join([str(a) for a in bbox]) + '\n')
    txt_file.close()

逐段:

  • txt_name = '..\\data\\txt\\txt' + json_name[0:-5] + '.txt':拼出输出 txt 的路径。json_name[0:-5] 是去掉 .json 后缀 (.json 共 5 个字符),得到纯文件名。注意这个路径是相对 写法(..\data\txt\),意味着运行脚本时的工作目录得对得上,且 data/txt/ 目录要提前存在。
  • os.path.join(json_floder_path, json_name):拼出 json 的完整路径。join 会自动处理分隔符,比手写 + 更稳妥。
  • data = json.load(...):读 json。指定 encoding='utf-8' 很重要,中文类名不乱码。
  • img_w / img_h:从 json 里取原图尺寸,用于归一化。
  • for i in data['shapes']:遍历所有标注对象。
    • label_name = i['label']:类名。
    • if i['shape_type'] == 'rectangle':只处理矩形标注(其它如多边形、点会被跳过)。这意味着如果标注时手滑画了非矩形,该目标会被静默丢弃。
    • 取 points[0](左上)和 points[1](右下),转成 int。
    • convert(...) 得到归一化坐标。
    • txt_file.write(str(name2id[label_name]) + " " + " ".join(...) + '\n'):拼成 类号 cx cy w h 一行写进去。如果某个 label 不在 name2id 里,这里会 KeyError 直接崩,提醒你 name2id 漏了类。

5.4 主入口

python 复制代码
if __name__ == "__main__":
    # json文件的路径
    json_floder_path = r'...\merged_dataset\json'  # 原文件此处是作者本机的绝对路径
    json_names = [n for n in os.listdir(json_floder_path) if n.endswith('.json')]
    for json_name in json_names:
        decode_json(json_floder_path, json_name)
  • json_floder_path 是 json 所在文件夹。原文件里写的是作者本机的一条绝对路径(含盘符),这里刻意省略掉了 ------你自己跑的时候请改成你自己的相对路径,例如 data/custom1/jsons(这样跨设备也能跑)。
  • os.listdir 列出文件夹下所有文件,if n.endswith('.json') 只保留 json。
  • 循环逐一转换。

使用提醒:

  1. 运行前先建好输出目录(如 data/txt/)。
  2. 这个脚本输出的路径规则比较「硬」,'..\\data\\txt\\txt' 前缀意味着它假设你在某个子目录里运行。建议直接把输出路径改成你实际要用的相对路径再做批量转换。
  3. 转完以后,还要把生成的 txt 按 images → labels、.jpg/.png → .txt 的命名规则摆到 data/custom1/labels/ 下 ,才能被 datasets.py 正确定位(标签路径靠替换字符串推导出来)。

【截图占位:labelme 标注一张 dog 图片的界面 / 转换后的 txt 内容】


六、utils/augmentations.py 逐段详解

数据增强是「用小数据集练出好模型」的关键手段之一。这个文件很短,只实现了一个增强。

6.1 horisontal_flip:水平翻转

python 复制代码
import torch
import torch.nn.functional as F
import numpy as np


def horisontal_flip(images, targets):
    images = torch.flip(images, [-1])       #将图像进行翻转
    targets[:, 2] = 1 - targets[:, 2]       #标签框也需要翻转
    return images, targets
  • torch.flip(images, [-1]):沿最后一维 翻转。图像张量形状是 (C, H, W),最后一维是宽 W,所以这就是水平镜像。
  • targets[:, 2] = 1 - targets[:, 2]:标签也要跟着翻。datasets.py 里传进来的 targets 每行是 [batch索引, 类号, cx, cy, w, h]。第 2 列(从 0 数)就是 cx。水平翻转后,中心横坐标 cx 变成 1 - cx(因为归一化坐标翻转是镜像的)。
  • 为什么只改 cx 不改 w? 水平翻转不改变框的宽高,也不改变 cy,只需把中心点横坐标镜像即可。
  • 注意函数名拼写 :horisontal 是原作者的拼写(正确应为 horizontal),datasets.py 里也是按这个名字 import 的,所以两处必须一致,不要「自作聪明」改对拼写,否则会 Import 失败。

七、utils/datasets.py 逐段详解

这是数据管道(data pipeline)的核心:读图、读标签、补边、缩放、增强、多尺度、拼 batch。

7.1 pad_to_square:把非正方形图补成正方形

python 复制代码
def pad_to_square(img, pad_value):
    c, h, w = img.shape
    dim_diff = np.abs(h - w)
    # (upper / left) padding and (lower / right) padding
    pad1, pad2 = dim_diff // 2, dim_diff - dim_diff // 2    #剩余部分
    # Determine padding
    pad = (0, 0, pad1, pad2) if h <= w else (pad1, pad2, 0, 0)  #(左,右,上,下)
    # Add padding
    img = F.pad(img, pad, "constant", value=pad_value)

    return img, pad
  • 输入图像可能是长方形。为了让后续「统一缩放到 416×416」不失真,先补边成正方形。
  • dim_diff = |h - w|:长短边差多少。
  • pad1 = dim_diff // 2、pad2 = dim_diff - dim_diff // 2:把差值一分为二 ,分别补在两边,尽量让物体居中。注意用 // 是为了处理奇数差值:多出来的 1 补到后边。
  • pad 元组是 PyTorch F.pad 的格式 (左, 右, 上, 下)。若 h <= w(图偏矮),需要补上下:(0,0,pad1,pad2);否则补左右:(pad1,pad2,0,0)。
  • F.pad(..., "constant", value=pad_value):用常量值填充,这里传的是 0(黑色边)。
  • 返回 (img, pad)。为什么要返回 pad? 因为标签也要「跟着补边偏移」。后面 ListDataset 会用 pad 把标签框的像素坐标平移,保持框和图对齐。

7.2 resize / random_resize:缩放

python 复制代码
def resize(image, size):
    image = F.interpolate(image.unsqueeze(0), size=size, mode="nearest").squeeze(0)
    return image


def random_resize(images, min_size=288, max_size=448):
    new_size = random.sample(list(range(min_size, max_size + 1, 32)), 1)[0]
    images = F.interpolate(images, size=new_size, mode="nearest")
    return images
  • resize:F.interpolate 是插值缩放。unsqueeze(0) 是为了补一个 batch 维(interpolate 需要 4D 输入 (N,C,H,W)),缩放完再 squeeze(0) 去掉。mode="nearest" 是最近邻插值(快、不会引入新像素值,对检测框对齐更友好)。
  • random_resize:从 min_size ~ max_size 范围里、每隔 32 取一个候选尺寸,随机挑一个目标尺寸,直接对整批图像插值缩放------这是「多尺度训练」用得上的随机缩放。本项目实际的多尺度逻辑写在 collate_fn 里(见 7.5),这个函数属于备用工具。

7.3 ImageFolder:给推理用的简单数据集

python 复制代码
class ImageFolder(Dataset):
    def __init__(self, folder_path, img_size=416):
        self.files = sorted(glob.glob("%s/*.*" % folder_path))
        self.img_size = img_size

    def __getitem__(self, index):
        img_path = self.files[index % len(self.files)]
        # Extract image as PyTorch tensor
        img = transforms.ToTensor()(Image.open(img_path))
        # Pad to square resolution
        img, _ = pad_to_square(img, 0)
        # Resize
        img = resize(img, self.img_size)

        return img_path, img

    def __len__(self):
        return len(self.files)
  • glob.glob("%s/*.*" % folder_path):列出文件夹下所有文件(*.* 通配)。sorted 保证顺序稳定(推理时不想乱序)。
  • __getitem__:
    • 取一张图路径。index % len(self.files) 是种「取模防越界」写法。
    • transforms.ToTensor()(Image.open(img_path)):打开图片并转成 (C,H,W) 的 float 张量,数值自动从 0~255 缩放到 0~1,且通道从 HWC 变成 CHW。
    • 补成正方形。
    • 缩放到 img_size。
    • 返回 (img_path, img) :和 ListDataset 不同,这里不返回标签------因为推理时只有图片。detect.py 正是用这个类。
  • 注意:这里没有 convert('RGB') ,如果喂进来的图是灰度或带 alpha,通道数会和网络期望的 3 不一致,可能报错。训练用的 ListDataset 有 convert('RGB')。

7.4 ListDataset:训练/验证用的数据集

7.4.1 构造函数
python 复制代码
class ListDataset(Dataset):
    def __init__(self, list_path, img_size=416, augment=True, multiscale=True, normalized_labels=True):
        with open(list_path, "r") as file:
            self.img_files = file.readlines()

        self.label_files = [
            path.replace("images", "labels").replace(".png", ".txt").replace(".jpg", ".txt")
            for path in self.img_files
        ]
        self.img_size = img_size
        self.max_objects = 100
        self.augment = augment      #数据增强
        self.multiscale = multiscale
        self.normalized_labels = normalized_labels
        self.min_size = self.img_size - 3 * 32
        self.max_size = self.img_size + 3 * 32
        self.batch_count = 0
  • list_path 就是 .data 里的 train/valid 清单文件路径。
  • file.readlines():每行一个图片路径(末尾带 \n,后面用 .rstrip() 去掉)。
  • label_files 是推导出来的 :
    • .replace("images", "labels"):路径里的 images 目录名换成 labels;
    • .replace(".png", ".txt")、.replace(".jpg", ".txt"):扩展名换成 .txt。
    • 所以图片和标签必须目录对称、同名 。如果你的图片在 images/train/,标签就得在 labels/train/,文件名(除扩展名)一模一样。
  • max_objects = 100:单张图最多物体数(一个预留参数,代码里没实际用于截断)。
  • normalized_labels=True:表示标签的 cx cy w h 是归一化 的(在 datasets.py 里,如果是归一化标签,h_factor=w_factor=1,否则用图像宽高)。
  • min_size = img_size - 96、max_size = img_size + 96:多尺度训练的取值范围(416 上下浮动 96,即 320~512,每隔 32 取一个)。
  • batch_count = 0:用于「每 10 个 batch 换一次输入尺寸」。
7.4.2 __getitem__:读一张图和它的标签
python 复制代码
    def __getitem__(self, index):

        # ---------
        #  Image
        # ---------

        img_path = self.img_files[index % len(self.img_files)].rstrip()
        # Extract image as PyTorch tensor
        img = transforms.ToTensor()(Image.open(img_path).convert('RGB'))

        # Handle images with less than three channels
        if len(img.shape) != 3:
            img = img.unsqueeze(0)
            img = img.expand((3, img.shape[1:]))

        _, h, w = img.shape
        h_factor, w_factor = (h, w) if self.normalized_labels else (1, 1)
        # Pad to square resolution
        img, pad = pad_to_square(img, 0)
        _, padded_h, padded_w = img.shape
  • rstrip():去掉行尾 \n。
  • Image.open(img_path).convert('RGB'):强制转 RGB ,解决灰度图/四通道图的问题。这一步很关键,否则 ToTensor 出来的通道数可能不是 3。
  • if len(img.shape) != 3:兜底(理论上 convert('RGB') 后一定是 3 维,这里是原作者的保险)。
  • h_factor, w_factor = (h, w) if normalized_labels else (1, 1):如果标签是归一化的,要在「还原像素坐标」时乘图像宽高,就把 h,w 作为系数记下来;否则乘 1。
  • pad_to_square 补边并记下 pad,后面标签要用。
  • _, padded_h, padded_w = img.shape:拿到补边后的尺寸,用于后面把标签重新归一化到补边后的坐标系。
python 复制代码
        # ---------
        #  Label
        # ---------

        label_path = self.label_files[index % len(self.img_files)].rstrip()

        targets = None
        if os.path.exists(label_path):
            boxes = torch.from_numpy(np.loadtxt(label_path).reshape(-1, 5))
            # Extract coordinates for unpadded + unscaled image
            x1 = w_factor * (boxes[:, 1] - boxes[:, 3] / 2)
            y1 = h_factor * (boxes[:, 2] - boxes[:, 4] / 2)
            x2 = w_factor * (boxes[:, 1] + boxes[:, 3] / 2)
            y2 = h_factor * (boxes[:, 2] + boxes[:, 4] / 2)
            # Adjust for added padding
            x1 += pad[0]
            y1 += pad[2]
            x2 += pad[1]
            y2 += pad[3]
            # Returns (x, y, w, h)
            boxes[:, 1] = ((x1 + x2) / 2) / padded_w
            boxes[:, 2] = ((y1 + y2) / 2) / padded_h
            boxes[:, 3] *= w_factor / padded_w
            boxes[:, 4] *= h_factor / padded_h

            targets = torch.zeros((len(boxes), 6))
            targets[:, 1:] = boxes

这是全文件最需要理解的段落。它做的是「把归一化的 cx cy w h 还原成像素坐标,加上补边偏移,再重新归一化到补边后的坐标系」。

  • np.loadtxt(label_path):读 txt 成 (N, 5) 数组(每行 类号 cx cy w h)。reshape(-1, 5) 保证即使只有一行也能正确成二维。
  • 第 0 列是类号,第 1~4 列是 cx cy w h。
  • 先由中心+宽高求出左上、右下 (这就是 yolo 内部喜欢用的 xyxy 格式):
    • x1 = cx - w/2、y1 = cy - h/2、x2 = cx + w/2、y2 = cy + h/2;
    • 再乘 w_factor/h_factor。因为 normalized_labels=True 时 w_factor=w,把归一化坐标还原成原图像素坐标。
  • Adjust for added padding:F.pad 的元组是 (左,右,上,下),所以:
    • x1 += pad[0](左)、x2 += pad[1](右);
    • y1 += pad[2](上)、y2 += pad[3](下)。
    • 这样标签框就随图一起平移,补边后仍然对齐。
  • Returns (x, y, w, h):再转回「中心 + 宽高」并除以补边后的宽/高 重新归一化:
    • cx = (x1+x2)/2 / padded_w,cy = (y1+y2)/2 / padded_h;
    • w = w_factor/padded_w、h = h_factor/padded_h。
    • 到这里,标签是「相对补边后正方形图的归一化坐标」。
  • targets = torch.zeros((len(boxes), 6)):申请 (N, 6),6 列 = [batch索引, 类号, cx, cy, w, h] 。targets[:, 1:] = boxes 把 boxes 的 5 列(类号+cxcywh)填进第 1~5 列,第 0 列(batch 索引)先留 0,等 collate_fn 时再填。
  • if os.path.exists:如果标签文件不存在,targets 保持 None (作为纯背景样本)。collate_fn 里会把 None 的过滤掉。
python 复制代码
        # Apply augmentations应用增强
        if self.augment:
            if np.random.random() < 0.5:
                img, targets = horisontal_flip(img, targets)

        return img_path, img, targets
  • augment 为真时,有 50% 概率 做一次水平翻转(np.random.random() < 0.5)。
  • 返回三元组 (路径, 图像张量, 标签)。
7.4.3 collate_fn:把一个 batch 拼起来(含多尺度)
python 复制代码
    def collate_fn(self, batch):
        paths, imgs, targets = list(zip(*batch))
        # Remove empty placeholder targets
        targets = [boxes for boxes in targets if boxes is not None]
        # Add sample index to targets
        for i, boxes in enumerate(targets):
            boxes[:, 0] = i
        targets = torch.cat(targets, 0) #将targets按行进行拼接
        # Selects new image size every tenth batch
        if self.multiscale and self.batch_count % 10 == 0:
            self.img_size = random.choice(range(self.min_size, self.max_size + 1, 32))
        # Resize images to input shape
        imgs = torch.stack([resize(img, self.img_size) for img in imgs])
        self.batch_count += 1
        return paths, imgs, targets
  • list(zip(*batch)):把 batch(一个 list of (path, img, target))转置成 (所有path, 所有img, 所有target) 三个元组。
  • targets = [box for box in targets if box is not None]:丢弃「没有标签」的样本(背景图)。
  • for i, boxes in enumerate(targets): boxes[:, 0] = i:给每个标签行打上它属于 batch 里第几张图的编号。这样后面所有图的标签能拼成一个大张量,网络靠第 0 列区分是哪张图的。
  • torch.cat(targets, 0):把所有图的标签按行拼接 成 (总物体数, 6)。注意拼接后 targets 不再区分图像,第 0 列承担了这个职责。
  • 多尺度 :if self.multiscale and self.batch_count % 10 == 0 表示「每 10 个 batch 换一次输入尺寸」,从 min_size~max_size 步长 32 里随机选。这迫使网络适应不同分辨率,提升泛化。
  • imgs = torch.stack([resize(img, self.img_size) for img in imgs]):把每张已补成正方形的图缩放到当前 img_size,再 stack 成一个 4D 张量 (N, 3, size, size)。注意 resize 发生在补边之后、拼 batch 之前,所以 batch 内尺寸一致,可以直接 stack。
  • self.batch_count += 1 记录已处理的 batch 数。
  • 为什么需要自定义 collate_fn? 因为每张图的图片尺寸相同(都是正方),但标签数量不同,默认的 collate 没法处理这种「变长标签」,必须自己写。

【截图占位:data/custom1 的 images/labels 目录与一张图的标签 txt】


八、utils/utils.py 逐段详解

这是「工具箱」文件:IoU、目标构建、NMS、mAP、坐标变换,全在这里。

8.1 to_cpu / load_classes

python 复制代码
def to_cpu(tensor):
    return tensor.detach().cpu()


def load_classes(path):
    """
    Loads class labels at 'path'
    """
    fp = open(path, "r")
    names = fp.read().split("\n")[:-1]
    return names
  • to_cpu:把张量从 GPU 挪到 CPU 并断开计算图 (detach())。日志里记录指标时用,避免把计算图带进日志、也避免 GPU 张量不能直接 .item()/序列化的问题。
  • load_classes:读类名文件,split("\n")[:-1] 去掉最后那个空行。返回值是列表 ,第 i 个就是编号 i 对应的类名。这也是为什么 classes.names 的行顺序决定编号。

8.2 weights_init_normal:参数初始化

python 复制代码
def weights_init_normal(m):
    classname = m.__class__.__name__
    if classname.find("Conv") != -1:
        torch.nn.init.normal_(m.weight.data, 0.0, 0.02)
    elif classname.find("BatchNorm2d") != -1:
        torch.nn.init.normal_(m.weight.data, 1.0, 0.02)
        torch.nn.init.constant_(m.bias.data, 0.0)
  • 这个函数配合 model.apply(weights_init_normal) 使用:apply 会递归遍历每一个子模块 ,把 m 逐个传进来。
  • m.__class__.__name__:取类名字符串。用 find("Conv") 判断是不是卷积层(覆盖 Conv2d 等)。
  • init.normal_(m.weight.data, 0.0, 0.02):把卷积权重初始化为 均值 0、标准差 0.02 的正态分布。这是 YOLO 一贯的初始化方式(作者认为这样训练更稳、初始输出更合理)。
  • BatchNorm:权重初始化为「均值 1、标准差 0.02」的正态(BN 的 weight 是缩放因子 γ,靠近 1 合理),bias(偏移 β)初始化为 0。
  • 重要 :model.apply(weights_init_normal) 会覆盖所有参数。但训练时紧接着会加载 darknet53.conv.74 预训练权重,把骨干层的参数覆盖回来(见 models.py 的 load_darknet_weights),所以「先随机初始化、再加载骨干」这个顺序是刻意的:没被预训练覆盖的部分(比如新的检测头)就保留随机初始化。

8.3 rescale_boxes:把检测框还原到原图尺寸

python 复制代码
def rescale_boxes(boxes, current_dim, original_shape):
    """ Rescales bounding boxes to the original shape """
    orig_h, orig_w = original_shape
    # The amount of padding that was added
    pad_x = max(orig_h - orig_w, 0) * (current_dim / max(original_shape))
    pad_y = max(orig_w - orig_h, 0) * (current_dim / max(original_shape))
    # Image height and width after padding is removed
    unpad_h = current_dim - pad_y
    unpad_w = current_dim - pad_x
    # Rescale bounding boxes to dimension of original image
    boxes[:, 0] = ((boxes[:, 0] - pad_x // 2) / unpad_w) * orig_w
    boxes[:, 1] = ((boxes[:, 1] - pad_y // 2) / unpad_h) * orig_h
    boxes[:, 2] = ((boxes[:, 2] - pad_x // 2) / unpad_w) * orig_w
    boxes[:, 3] = ((boxes[:, 3] - pad_y // 2) / unpad_h) * orig_h
    return boxes

推理时,图片被「补成正方形 → 缩放」,所以框是画在「补边+缩放后的坐标」上的。要画回原图,就要反向操作:先去掉补边,再按比例还原。

  • orig_h, orig_w:原图高、宽。
  • pad_x、pad_y:当初左右/上下各补了多少(换算到 current_dim 尺度)。max(orig_h - orig_w, 0) 保证只有一边有补;current_dim / max(original_shape) 是缩放比例。
  • unpad_w = current_dim - pad_x:去掉补边后,有效图像的宽。
  • 每个坐标:先 - pad_x//2(去掉左侧补边偏移),再 / unpad_w(还原到 0~1),最后 * orig_w(乘以原图宽)→ 得到原图上的像素坐标。
  • 注意这里 boxes 是 xyxy 格式(第 0、2 列是 x1、x2;第 1、3 列是 y1、y2),所以第 0、2 列用宽相关的量,第 1、3 列用高相关的量。

8.4 xywh2xyxy:中心格式转对角格式

python 复制代码
def xywh2xyxy(x):
    y = x.new(x.shape)
    y[..., 0] = x[..., 0] - x[..., 2] / 2
    y[..., 1] = x[..., 1] - x[..., 3] / 2
    y[..., 2] = x[..., 0] + x[..., 2] / 2
    y[..., 3] = x[..., 1] + x[..., 3] / 2
    return y
  • 输入每行是 (cx, cy, w, h),输出 (x1, y1, x2, y2):
    • x1 = cx - w/2、y1 = cy - h/2、x2 = cx + w/2、y2 = cy + h/2。
  • x.new(x.shape):创建一个和 x 同类型(同 device、同 dtype)、同形状的空张量。用 new 而不是 torch.zeros 能保证 GPU/CPU 类型一致。
  • ... 是省略号索引,表示「前面的维度都保留」,最后按第 0~3 列赋值。
  • NMS 和评估都要用 xyxy 来算框交并。为什么是 xyxy? 因为算交集只需要「左上取 max、右下取 min」,xyxy 格式最方便。

8.5 bbox_iou 与 bbox_wh_iou:两种 IoU

8.5.1 bbox_wh_iou:只按宽高算 IoU(用于 anchor 匹配)
python 复制代码
def bbox_wh_iou(wh1, wh2):
    wh2 = wh2.t()
    w1, h1 = wh1[0], wh1[1]
    w2, h2 = wh2[0], wh2[1]
    inter_area = torch.min(w1, w2) * torch.min(h1, h2)
    union_area = (w1 * h1 + 1e-16) + w2 * h2 - inter_area
    return inter_area / union_area
  • 输入 wh1 是一个 anchor (pw, ph),wh2 是一堆目标的 (gw, gh)。
  • wh2.t() 转置,让 w2, h2 都是「一列目标」。
  • 关键 :这里把两个框都假想成「以同一点为中心」,于是:
    • 交集宽 = min(w1, w2),交集高 = min(h1, h2),交集面积 = min(w1,w2) * min(h1,h2);
    • 并集 = w1*h1 + w2*h2 - 交集。
  • +1e-16 防止除零。
  • 为什么需要它? 训练时要把「每个目标」分配给「大小最合适的 anchor」。此时还没有位置,只能靠宽高来衡量「这个 anchor 形状和目标像不像」。所以用小函数算「形状 IoU」。
8.5.2 bbox_iou:标准框 IoU(用于 NMS、评估)
python 复制代码
def bbox_iou(box1, box2, x1y1x2y2=True):
    """
    Returns the IoU of two bounding boxes
    """
    if not x1y1x2y2:
        # Transform from center and width to exact coordinates
        b1_x1, b1_x2 = box1[:, 0] - box1[:, 2] / 2, box1[:, 0] + box1[:, 2] / 2
        b1_y1, b1_y2 = box1[:, 1] - box1[:, 3] / 2, box1[:, 1] + box1[:, 3] / 2
        b2_x1, b2_x2 = box2[:, 0] - box2[:, 2] / 2, box2[:, 0] + box2[:, 2] / 2
        b2_y1, b2_y2 = box2[:, 1] - box2[:, 3] / 2, box2[:, 1] + box2[:, 3] / 2
    else:
        # Get the coordinates of bounding boxes
        b1_x1, b1_y1, b1_x2, b1_y2 = box1[:, 0], box1[:, 1], box1[:, 2], box1[:, 3]
        b2_x1, b2_y1, b2_x2, b2_y2 = box2[:, 0], box2[:, 1], box2[:, 2], box2[:, 3]

    # get the corrdinates of the intersection rectangle
    inter_rect_x1 = torch.max(b1_x1, b2_x1)
    inter_rect_y1 = torch.max(b1_y1, b2_y1)
    inter_rect_x2 = torch.min(b1_x2, b2_x2)
    inter_rect_y2 = torch.min(b1_y2, b2_y2)
    # Intersection area
    inter_area = torch.clamp(inter_rect_x2 - inter_rect_x1 + 1, min=0) * torch.clamp(
        inter_rect_y2 - inter_rect_y1 + 1, min=0
    )
    # Union Area
    b1_area = (b1_x2 - b1_x1 + 1) * (b1_y2 - b1_y1 + 1)
    b2_area = (b2_x2 - b2_x1 + 1) * (b2_y2 - b2_y1 + 1)

    iou = inter_area / (b1_area + b2_area - inter_area + 1e-16)

    return iou
  • 参数 x1y1x2y2:告诉函数输入是不是 xyxy 格式。若为 False,先由 cx cy w h 转成 xyxy(就是 8.4 的逻辑)。
  • 交集矩形:左上角取两个框左上的 max ,右下角取两个框右下的 min。这是「两个矩形重叠区域」的标准求法。
  • +1 是原作者的像素级「闭区间」习惯(暗含:框占用的像素个数要 +1),很多检测代码都这么写,对 IoU 影响很小。
  • torch.clamp(..., min=0):把负值(根本不重叠时的负数)截成 0。
  • 并集 = 两个框面积相加减交集面积。
  • 返回逐元素 IoU(输入是 (N,4) 和 (M,4),广播后是 (N,M))。

8.6 non_max_suppression:非极大值抑制(重点)

python 复制代码
def non_max_suppression(prediction, conf_thres=0.5, nms_thres=0.4):
    """
    Removes detections with lower object confidence score than 'conf_thres' and performs
    Non-Maximum Suppression to further filter detections.
    Returns detections with shape:
        (x1, y1, x2, y2, object_conf, class_score, class_pred)
    """

    # From (center x, center y, width, height) to (x1, y1, x2, y2)
    prediction[..., :4] = xywh2xyxy(prediction[..., :4])
    output = [None for _ in range(len(prediction))]
  • prediction 形状 (N, 预测框数, classes+5)。这个输入来自 Darknet.forward 把三个 yolo 层输出拼起来的结果,每行是 [cx, cy, w, h, conf, 各类得分...],共 classes+5 列(这里 2 类就是 7 列)。函数后续用 [:, 5:] 取类别分,逻辑通用。
    • 先把前 4 列从 cx cy w h 转成 x1 y1 x2 y2(原地赋值)。
    • output:为每张图准备一个结果槽,先全填 None。
python 复制代码
    for image_i, image_pred in enumerate(prediction):
        # Filter out confidence scores below threshold
        image_pred = image_pred[image_pred[:, 4] >= conf_thres]
        # If none are remaining => process next image
        if not image_pred.size(0):
            continue
        # Object confidence times class confidence
        score = image_pred[:, 4] * image_pred[:, 5:].max(1)[0]
        # Sort by it
        image_pred = image_pred[(-score).argsort()]
        class_confs, class_preds = image_pred[:, 5:].max(1, keepdim=True)
        detections = torch.cat((image_pred[:, :5], class_confs.float(), class_preds.float()), 1)
  • 逐图处理。
  • image_pred[:, 4] >= conf_thres:第 4 列是 objectness(置信度),过滤掉低于阈值的框。
  • if not image_pred.size(0): continue:这张图一个框都没剩,跳到下一张。
  • score = conf × max(类别概率):最终得分 = 「有物体的置信度」×「最可能类别的概率」。这是 YOLO 的标准做法。
  • (-score).argsort():按 score 从大到小排序(取负号后升序 = 原值降序)。
  • class_confs, class_preds:取类别维度上的最大值和对应索引,即「最可能的类别概率」和「类别编号」。
  • torch.cat((image_pred[:, :5], class_confs, class_preds), 1):拼成 7 列 (x1,y1,x2,y2,conf,class_conf,class_pred)。
python 复制代码
        # Perform non-maximum suppression
        keep_boxes = []
        while detections.size(0):
            large_overlap = bbox_iou(detections[0, :4].unsqueeze(0), detections[:, :4]) > nms_thres
            label_match = detections[0, -1] == detections[:, -1]
            # Indices of boxes with lower confidence scores, large IOUs and matching labels
            invalid = large_overlap & label_match
            weights = detections[invalid, 4:5]
            # Merge overlapping bboxes by order of confidence
            detections[0, :4] = (weights * detections[invalid, :4]).sum(0) / weights.sum()
            keep_boxes += [detections[0]]
            detections = detections[~invalid]
        if keep_boxes:
            output[image_i] = torch.stack(keep_boxes)

这是 NMS 的核心循环,逐句解释:

  • keep_boxes = []:保存「保留下来的框」。
  • while detections.size(0):只要还有候选框就继续。
  • 因为 detections 已按分数降序排好 ,所以每次取的是「当前最高分框」------即 detections[0]。
  • large_overlap = bbox_iou(detections[0,:4], detections[:, :4]) > nms_thres:算最高分框和所有框(含自己)的 IoU,超过阈值(默认 0.4)就认为「重叠太多」。
  • label_match = detections[0,-1] == detections[:, -1]:类别是否相同(第 -1 列是类别编号)。只在同类之间做抑制,不同类的框不互相压(比如一个人骑在马上的情况)。
  • invalid = large_overlap & label_match:既重叠又是同类 → 判定为「重复框」,要被压制/合并。
  • weights = detections[invalid, 4:5]:取这些框的置信度作为加权权重。
  • detections[0, :4] = (weights * detections[invalid, :4]).sum(0) / weights.sum():按置信度加权平均,合并重叠框的位置。这是一个「soft-NMS 式」的加权融合,比直接丢弃低分框更精细------把一组重复框融合成坐标更准的一个框。
  • keep_boxes += [detections[0]]:把融合后的最高分框保留下来。
  • detections = detections[~invalid]:用布尔掩码 ~invalid 把「被合并掉的框」删掉,剩下的进入下一轮循环。
  • 循环结束后,torch.stack(keep_boxes) 把保留下来的框堆成张量,存进该图的结果槽。
  • 最终 output 是一个长度 N 的列表,每张图要么是「若干检测框」张量,要么是 None。

一句话理解 NMS:反复取当前分数最高的框,把它和「同类别、位置高度重叠」的框合并掉,直到没有框为止。

8.7 build_targets:为损失构建「目标」(全项目最难的一段)

训练时,网络输出的是「每个格子、每个 anchor 的预测」。而我们手上只有「原图坐标的真值」。所以需要把真值**分配(assign)**到具体的「格子 + anchor」上,并算出训练要拟合的目标值 tx, ty, tw, th。这就是 build_targets 的任务。

python 复制代码
def build_targets(pred_boxes, pred_cls, target, anchors, ignore_thres):
#标签的转换
    ByteTensor = torch.cuda.ByteTensor if pred_boxes.is_cuda else torch.ByteTensor
    FloatTensor = torch.cuda.FloatTensor if pred_boxes.is_cuda else torch.FloatTensor

    nB = pred_boxes.size(0) # batch
    nA = pred_boxes.size(1) # 每个格子对应了多少个anchor
    nC = pred_cls.size(-1)  # 类别的数量
    nG = pred_boxes.size(2) # gridsize

    # Output tensors
    obj_mask = ByteTensor(nB, nA, nG, nG).fill_(0)  # obj,anchor包含物体,为1,默认为0
    noobj_mask = ByteTensor(nB, nA, nG, nG).fill_(1) # noobj,anchor不包含物体,为1,默认为1
    class_mask = FloatTensor(nB, nA, nG, nG).fill_(0) # 类别掩膜,类别预测正确即为1,默认全为0
    iou_scores = FloatTensor(nB, nA, nG, nG).fill_(0) # 预测框与真实框的iou得分
    tx = FloatTensor(nB, nA, nG, nG).fill_(0)
    ty = FloatTensor(nB, nA, nG, nG).fill_(0)
    tw = FloatTensor(nB, nA, nG, nG).fill_(0)
    th = FloatTensor(nB, nA, nG, nG).fill_(0)
    tcls = FloatTensor(nB, nA, nG, nG, nC).fill_(0)
  • pred_boxes 形状 (nB, nA, nG, nG, 4),pred_cls 形状 (nB, nA, nG, nG, nC)。
  • nB:batch 大小;nA:anchor 个数(每层 3);nC:类别数;nG:网格边长。
  • 申请一堆与预测同形状 的「目标张量」,全部用 0/1 填好初值:
    • obj_mask:哪个位置「有物体」(正样本),初始全 0,最后只有「负责的目标位置」被置 1。
    • noobj_mask:哪个位置「没物体」(负样本),初始全 1,之后把正样本位置和目标太像的位置清零。
    • class_mask:分类预测是否正确(用于统计准确率)。
    • iou_scores:预测框和真实框的 IoU(用于统计 recall/precision)。
    • tx/ty/tw/th:坐标回归目标。
    • tcls:类别 one-hot 目标,形状多了一维 nC。
python 复制代码
    # Convert to position relative to box
    target_boxes = target[:, 2:6] * nG #target中的xywh都是0-1的,可以得到其在当前gridsize上的xywh
    gxy = target_boxes[:, :2]
    gwh = target_boxes[:, 2:]
    # Get anchors with best iou
    ious = torch.stack([bbox_wh_iou(anchor, gwh) for anchor in anchors]) #每一种规格的预选框anchor跟每个标签上的框的IOU得分
    best_ious, best_n = ious.max(0) # 得到其最高分以及哪种规格框和当前目标最相似
  • target 每行是 [batch索引, 类号, cx, cy, w, h],target[:, 2:6] 就是归一化的 cx cy w h。
  • * nG:把归一化坐标乘以网格边长 ,得到「以格子为单位」的坐标。例如 cx=0.5、nG=13 → 6.5,表示中心在第 6.5 个格子的位置。
  • gxy(中心)、gwh(宽高)。
  • ious = stack([bbox_wh_iou(anchor, gwh) for anchor in anchors]):对 3 个 anchor,各算它们与所有目标「形状 IoU」,得到形状 (3, 目标数)。
  • best_ious, best_n = ious.max(0):沿 anchor 维取最大 → 每个目标对应「最合适的那个 anchor 的索引 best_n」以及该最大形状 IoU。
python 复制代码
    # Separate target values
    b, target_labels = target[:, :2].long().t() # 真实框所对应的batch,以及实际类别
    gx, gy = gxy.t()
    gw, gh = gwh.t()
    gi, gj = gxy.long().t() #位置信息,向下取整了
    # Set masks
    obj_mask[b, best_n, gj, gi] = 1     # 实际包含物体的设置成1
    noobj_mask[b, best_n, gj, gi] = 0   # 相反
  • target[:, :2].long().t():取前两列 [batch索引, 类号] 转成整型再转置 → b(每个目标属于哪张图)、target_labels(每个目标的类号)。
  • gx, gy = gxy.t():每个目标的中心(格子单位,可能带小数)。
  • gw, gh = gwh.t():每个目标的宽高(格子单位)。
  • gi, gj = gxy.long().t():中心坐标向下取整 得到「所在格子的索引」。gi 是 x 方向格子号(列),gj 是 y 方向格子号(行)。
  • 核心的「责任分配」 :
    • obj_mask[b, best_n, gj, gi] = 1:把「第 b 张图、第 best_n 个 anchor、第 gj 行、第 gi 列」这个位置的 obj 掩码置 1,即「由这个 anchor、这个格子来负责这个目标」。
    • noobj_mask[b, best_n, gj, gi] = 0:它既然有物体,自然不再是「无物体」。
    • 用高级索引一次性给「每个目标对应的那个位置」赋值 :b/best_n/gj/gi 都是长度 = 目标数的向量,PyTorch 会逐个取出对应位置。
python 复制代码
    # Set noobj mask to zero where iou exceeds ignore threshold
    for i, anchor_ious in enumerate(ious.t()): # IOU超过了指定的阈值就相当于有物体了
        noobj_mask[b[i], anchor_ious > ignore_thres, gj[i], gi[i]] = 0
  • ious.t() 形状 (目标数, 3),每行是这个目标对 3 个 anchor 的形状 IoU。
  • 对每个目标:如果「某个 anchor 的形状 IoU 超过 ignore_thres」且它中心落在 (gi[i], gj[i]),就把对应 noobj_mask 置 0。
  • 为什么? 因为一个目标可能和不止一个 anchor 形状相似,非「最佳」但「也很像」的 anchor 预测出的框其实相当准,如果还把它当负样本(noobj)去惩罚,会伤害训练。所以把这些「模糊样本」忽略掉 (既不奖励也不惩罚),这正是 ignore_thresh 的用途。
python 复制代码
    # Coordinates
    tx[b, best_n, gj, gi] = gx - gx.floor() # 相对所在格子左上角的偏移
    ty[b, best_n, gj, gi] = gy - gy.floor()
    # Width and height
    tw[b, best_n, gj, gi] = torch.log(gw / anchors[best_n][:, 0] + 1e-16)
    th[b, best_n, gj, gi] = torch.log(gh / anchors[best_n][:, 1] + 1e-16)
  • gx - gx.floor():中心在格子内部 的相对偏移(0~1),正好对应预测的 σ(tx) 要拟合的量。这就是前面解码公式 bx = σ(tx) + cx 的逆过程。
  • tw = log(gw / pw):真实宽比 anchor 宽的比值再取对数,对应解码的 bw = pw · e^{tw} 的逆。+1e-16 防分母 0。
  • 注意 :这里 anchors[best_n][:, 0] 用的是原始 anchor (不是 scaled_anchors),因为 gw、gh 是在「格子单位」下的宽高,而 anchor 也是「格子单位」的原始像素值(如 10×13)------但这里是归一化前的像素尺寸,由于 bw = pw·e^{tw} 中两边都是像素,比值一致,所以用原始 anchor 即可。幸福的是,build_targets 传入的 anchors 其实是 YOLOLayer.scaled_anchors(见 model.py),已经除以 stride 了;这里逻辑上以「格子单位」为准,两者一致。

继续:

python 复制代码
    # One-hot encoding of label
    tcls[b, best_n, gj, gi, target_labels] = 1 #将真实框的标签转换为one-hot编码形式
    # Compute label correctness and iou at best anchor
    class_mask[b, best_n, gj, gi] = (pred_cls[b, best_n, gj, gi].argmax(-1) == target_labels).float()
    iou_scores[b, best_n, gj, gi] = bbox_iou(pred_boxes[b, best_n, gj, gi], target_boxes, x1y1x2y2=False)

    tconf = obj_mask.float() # 真实框的置信度,也就是1
    return iou_scores, class_mask, obj_mask, noobj_mask, tx, ty, tw, th, tcls, tconf
  • tcls[..., target_labels] = 1:把负责位置的类别 one-hot 置 1。tcls 最后一维是类别,target_labels 指明是第几类。
  • class_mask = (pred_cls[负责位置].argmax(-1) == target_labels):预测类别(取 argmax)是否等于真实类号 → 得到「分类是否正确」的掩码,用于算 cls_acc。
  • iou_scores = bbox_iou(pred_boxes[负责位置], target_boxes, x1y1x2y2=False):预测框(cxcywh)与真实框的 IoU,用于算 precision/recall。
  • tconf = obj_mask.float():置信度目标就等于 obj_mask(有物体=1,无物体=0)。
  • 返回这一堆张量,供 YOLOLayer.forward 算损失。

小结 build_targets 三件事:

  1. 分配责任:每个目标分给「形状最像的那个 anchor + 中心所在的格子」。
  2. 生成目标值:坐标用「格内偏移 + 对数尺寸比」,类别用 one-hot。
  3. 生成掩码 :obj_mask(正样本)、noobj_mask(负样本,且抹掉模糊样本)、class_mask、iou_scores。

#mermaid-svg-1DXJnMBKxCTSXcyw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1DXJnMBKxCTSXcyw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1DXJnMBKxCTSXcyw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1DXJnMBKxCTSXcyw .error-icon{fill:#552222;}#mermaid-svg-1DXJnMBKxCTSXcyw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1DXJnMBKxCTSXcyw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1DXJnMBKxCTSXcyw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1DXJnMBKxCTSXcyw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1DXJnMBKxCTSXcyw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1DXJnMBKxCTSXcyw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1DXJnMBKxCTSXcyw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1DXJnMBKxCTSXcyw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1DXJnMBKxCTSXcyw .marker.cross{stroke:#333333;}#mermaid-svg-1DXJnMBKxCTSXcyw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1DXJnMBKxCTSXcyw p{margin:0;}#mermaid-svg-1DXJnMBKxCTSXcyw .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1DXJnMBKxCTSXcyw .cluster-label text{fill:#333;}#mermaid-svg-1DXJnMBKxCTSXcyw .cluster-label span{color:#333;}#mermaid-svg-1DXJnMBKxCTSXcyw .cluster-label span p{background-color:transparent;}#mermaid-svg-1DXJnMBKxCTSXcyw .label text,#mermaid-svg-1DXJnMBKxCTSXcyw span{fill:#333;color:#333;}#mermaid-svg-1DXJnMBKxCTSXcyw .node rect,#mermaid-svg-1DXJnMBKxCTSXcyw .node circle,#mermaid-svg-1DXJnMBKxCTSXcyw .node ellipse,#mermaid-svg-1DXJnMBKxCTSXcyw .node polygon,#mermaid-svg-1DXJnMBKxCTSXcyw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1DXJnMBKxCTSXcyw .rough-node .label text,#mermaid-svg-1DXJnMBKxCTSXcyw .node .label text,#mermaid-svg-1DXJnMBKxCTSXcyw .image-shape .label,#mermaid-svg-1DXJnMBKxCTSXcyw .icon-shape .label{text-anchor:middle;}#mermaid-svg-1DXJnMBKxCTSXcyw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1DXJnMBKxCTSXcyw .rough-node .label,#mermaid-svg-1DXJnMBKxCTSXcyw .node .label,#mermaid-svg-1DXJnMBKxCTSXcyw .image-shape .label,#mermaid-svg-1DXJnMBKxCTSXcyw .icon-shape .label{text-align:center;}#mermaid-svg-1DXJnMBKxCTSXcyw .node.clickable{cursor:pointer;}#mermaid-svg-1DXJnMBKxCTSXcyw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1DXJnMBKxCTSXcyw .arrowheadPath{fill:#333333;}#mermaid-svg-1DXJnMBKxCTSXcyw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1DXJnMBKxCTSXcyw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1DXJnMBKxCTSXcyw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1DXJnMBKxCTSXcyw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1DXJnMBKxCTSXcyw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1DXJnMBKxCTSXcyw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1DXJnMBKxCTSXcyw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1DXJnMBKxCTSXcyw .cluster text{fill:#333;}#mermaid-svg-1DXJnMBKxCTSXcyw .cluster span{color:#333;}#mermaid-svg-1DXJnMBKxCTSXcyw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1DXJnMBKxCTSXcyw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1DXJnMBKxCTSXcyw rect.text{fill:none;stroke-width:0;}#mermaid-svg-1DXJnMBKxCTSXcyw .icon-shape,#mermaid-svg-1DXJnMBKxCTSXcyw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1DXJnMBKxCTSXcyw .icon-shape p,#mermaid-svg-1DXJnMBKxCTSXcyw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1DXJnMBKxCTSXcyw .icon-shape .label rect,#mermaid-svg-1DXJnMBKxCTSXcyw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1DXJnMBKxCTSXcyw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1DXJnMBKxCTSXcyw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1DXJnMBKxCTSXcyw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 真实标签 cx,cy,w,h 归一化
乘 grid size -> 格子单位
对3个anchor算形状IoU
取最大 -> best_n 责任anchor
中心向下取整 -> gi,gj 责任格子
obj_mask 置1 / noobj_mask 置0
形状IoU > ignore_thres 的置0 忽略
tx,ty = 格内偏移
tw,th = log 尺寸比
tcls one-hot

8.8 ap_per_class 与 compute_ap:算 mAP

评估时,我们要统计每个类别的 AP(Average Precision),再求平均得到 mAP。

python 复制代码
def ap_per_class(tp, conf, pred_cls, target_cls):
    # Sort by objectness
    i = np.argsort(-conf)
    tp, conf, pred_cls = tp[i], conf[i], pred_cls[i]

    # Find unique classes
    unique_classes = np.unique(target_cls)

    # Create Precision-Recall curve and compute AP for each class
    ap, p, r = [], [], []
    for c in tqdm.tqdm(unique_classes, desc="Computing AP"):
        i = pred_cls == c
        n_gt = (target_cls == c).sum()  # Number of ground truth objects
        n_p = i.sum()  # Number of predicted objects

        if n_p == 0 and n_gt == 0:
            continue
        elif n_p == 0 or n_gt == 0:
            ap.append(0)
            r.append(0)
            p.append(0)
        else:
            # Accumulate FPs and TPs
            fpc = (1 - tp[i]).cumsum()
            tpc = (tp[i]).cumsum()

            # Recall
            recall_curve = tpc / (n_gt + 1e-16)
            r.append(recall_curve[-1])

            # Precision
            precision_curve = tpc / (tpc + fpc)
            p.append(precision_curve[-1])

            # AP from recall-precision curve
            ap.append(compute_ap(recall_curve, precision_curve))

    # Compute F1 score (harmonic mean of precision and recall)
    p, r, ap = np.array(p), np.array(r), np.array(ap)
    f1 = 2 * p * r / (p + r + 1e-16)

    return p, r, ap, f1, unique_classes.astype("int32")
  • argsort(-conf):所有预测按置信度降序。为啥要排序?因为 PR 曲线是对「按分数从高到低逐个接受」绘制的。
  • unique_classes = np.unique(target_cls):只统计真实出现过的类别(没出现过的类算 AP 没意义)。
  • 逐类循环:
    • i = pred_cls == c:该类别的所有预测的布尔掩码。
    • n_gt:该类真实框数;n_p:该类预测框数。
    • 两者都为 0 → 跳过;只有一个为 0 → 记 0。
    • tpc = tp[i].cumsum():按分数排序后,累计 TP(真正例)。
    • fpc = (1 - tp[i]).cumsum():累计 FP(假正例)。
    • recall_curve = tpc / n_gt:召回率曲线 = 累计 TP / 总真实数。
    • precision_curve = tpc / (tpc + fpc):精确率曲线 = 累计 TP / 累计被接受总数。
    • r/p 记的是曲线末端值(最终召回/精确率);ap 由 compute_ap 算出。
  • f1 = 2pr/(p+r):精确率和召回率的调和平均。
  • 返回各类的 p、r、ap、f1、类号数组。
python 复制代码
def compute_ap(recall, precision):
    # correct AP calculation
    # first append sentinel values at the end
    mrec = np.concatenate(([0.0], recall, [1.0]))
    mpre = np.concatenate(([0.0], precision, [0.0]))

    # compute the precision envelope
    for i in range(mpre.size - 1, 0, -1):
        mpre[i - 1] = np.maximum(mpre[i - 1], mpre[i])

    # to calculate area under PR curve, look for points
    # where X axis (recall) changes value
    i = np.where(mrec[1:] != mrec[:-1])[0]

    # and sum (\Delta recall) * prec
    ap = np.sum((mrec[i + 1] - mrec[i]) * mpre[i + 1])
    return ap
  • 这是PASCAL VOC 的 11 点/全点插值法的经典实现(作者注明来自 py-faster-rcnn)。
  • mrec/mpre 首尾加上哨兵值 0 和 1,把曲线锚定在 0 和 1。
  • precision envelope:从后往前,把 mpre[i-1] 更新为它和右边所有值的最大值,让精确率曲线单调不增。这是 VOC AP 的标准处理。
  • np.where(mrec[1:] != mrec[:-1]):找出召回率「发生变化」的位置。
  • ap = Σ (Δrecall) × precision:PR 曲线下的面积,就是 AP。用「召回率变化量 × 对应精确率」的矩形面积累加来近似积分。

8.9 get_batch_statistics:逐图判定 TP

python 复制代码
def get_batch_statistics(outputs, targets, iou_threshold):
    batch_metrics = []
    for sample_i in range(len(outputs)):
        if outputs[sample_i] is None:
            continue

        output = outputs[sample_i]
        pred_boxes = output[:, :4]
        pred_scores = output[:, 4]
        pred_labels = output[:, -1]

        true_positives = np.zeros(pred_boxes.shape[0])

        annotations = targets[targets[:, 0] == sample_i][:, 1:]
        target_labels = annotations[:, 0] if len(annotations) else []
        if len(annotations):
            detected_boxes = []
            target_boxes = annotations[:, 1:]

            for pred_i, (pred_box, pred_label) in enumerate(zip(pred_boxes, pred_labels)):
                # If targets are found break
                if len(detected_boxes) == len(annotations):
                    break

                # Ignore if label is not one of the target labels
                if pred_label not in target_labels:
                    continue

                iou, box_index = bbox_iou(pred_box.unsqueeze(0), target_boxes).max(0)
                if iou >= iou_threshold and box_index not in detected_boxes:
                    true_positives[pred_i] = 1
                    detected_boxes += [box_index]
        batch_metrics.append([true_positives, pred_scores, pred_labels])
    return batch_metrics
  • 遍历每张图的检测结果。outputs[sample_i] 是 NMS 后的框,None 则跳过。
  • pred_boxes/pred_scores/pred_labels 分别取框、分数、类别。
  • true_positives = np.zeros(...):为每个预测框准备「是不是真阳性」的标记。
  • annotations = targets[targets[:,0]==sample_i][:, 1:]:取出属于这张图的真实标签(去掉 batch 索引列),得到 [类号, x1,y1,x2,y2]。
  • pred_label not in target_labels: continue:预测的类不在该图真实类里,一定不是 TP,跳过。
  • iou, box_index = bbox_iou(pred_box, target_boxes).max(0):找和该预测框 IoU 最大的真实框,记录 IoU 值和索引。
  • 判定为 TP 的条件:iou >= iou_threshold 且 这个真实框还没被别人匹配过(box_index not in detected_boxes,避免一个真值被多个预测重复算 TP)。
  • 记录 [TP标记, 分数, 类别],交给 ap_per_class 去累加。

8.10 Logger(utils/logger.py)

python 复制代码
import tensorflow as tf


class Logger(object):
    def __init__(self, log_dir):
        """Create a summary writer logging to log_dir."""
        self.writer = tf.summary.create_file_writer(log_dir)

    def scalar_summary(self, tag, value, step):
        with self.writer.as_default():
            tf.summary.scalar(tag, value, step=step)
            self.writer.flush()

    def list_of_scalars_summary(self, tag_value_pairs, step):
        with self.writer.as_default():
            for tag, value in tag_value_pairs:
                tf.summary.scalar(tag, value, step=step)
            self.writer.flush()
  • 注意 :这里用的是 TensorFlow 2.x 的接口 tf.summary(而不是 PyTorch 自带的 torch.utils.tensorboard)。所以运行环境需要装 tensorflow。
  • create_file_writer(log_dir):指定日志目录,train.py 里传的是 "logs"。
  • scalar_summary:写单个标量曲线。
  • list_of_scalars_summary:写一组 (标签, 数值) 对,train.py 每批次都用它把各 yolo 层的指标一次性写进去。
  • self.writer.flush():立即刷盘,保证随时能通过 TensorBoard 看到最新数据。
  • 如果不想装 TensorFlow,可以把 Logger 换成 torch.utils.tensorboard.SummaryWriter,或者干脆注释掉日志。

九、models.py 逐段详解

这是整个项目的「心脏」:网络定义、YOLO 解码层、损失计算、darknet 权重加载。

9.1 create_modules:从配置字典到 PyTorch 网络层

这个函数把 parse_model_config 输出的「块列表」翻译成真正的 PyTorch 模块。

python 复制代码
def create_modules(module_defs):
    """
    Constructs module list of layer blocks from module configuration in module_defs
    """
    hyperparams = module_defs.pop(0)
    output_filters = [int(hyperparams["channels"])]
    module_list = nn.ModuleList()
    for module_i, module_def in enumerate(module_defs):
        modules = nn.Sequential()
  • module_defs.pop(0):把列表第 0 个元素([net] 块)单独取出来 ,作为全局超参 hyperparams。注意 pop 会从原列表删除它,所以循环里就只剩各层的块了。
  • output_filters = [int(hyperparams["channels"])]:初始通道数 = 输入通道(3)。这个列表会随着层推进不断追加「每层输出通道数」,用来给后面的层判断输入通道。
  • module_list = nn.ModuleList():PyTorch 的模块容器,可以像列表一样 append,并会自动注册参数(比普通 Python list 更适合放网络层)。
  • 循环里每层都新建一个 nn.Sequential() 装「一层可能包含的多个子模块」(如 conv+BN+Leaky)。

为什么用两个容器(ModuleList + Sequential)?

  • 外层 ModuleList:装「所有层」,顺序执行。
  • 每层的 Sequential:装「这一层的若干小零件」(卷积+BN+激活)。
  • 有些层(route/shortcut)没有可训练参数,用 EmptyLayer 占位,靠外层 Darknet.forward 特殊处理。
9.1.1 卷积层
python 复制代码
        if module_def["type"] == "convolutional":
            bn = int(module_def["batch_normalize"])
            filters = int(module_def["filters"])
            kernel_size = int(module_def["size"])
            pad = (kernel_size - 1) // 2
            modules.add_module(
                f"conv_{module_i}",
                nn.Conv2d(
                    in_channels=output_filters[-1],
                    out_channels=filters,
                    kernel_size=kernel_size,
                    stride=int(module_def["stride"]),
                    padding=pad,
                    bias=not bn,
                ),
            )
            if bn:
                modules.add_module(f"batch_norm_{module_i}", nn.BatchNorm2d(filters, momentum=0.9))
            if module_def["activation"] == "leaky":
                modules.add_module(f"leaky_{module_i}", nn.LeakyReLU(0.1))
  • bn = int(module_def["batch_normalize"]):0 或 1。
  • filters:输出通道。kernel_size:卷积核大小。
  • pad = (kernel_size - 1) // 2:same padding 。3×3 → pad=1;1×1 → pad=0。这样 stride=1 时空间尺寸不变。注意它没用 cfg 里的 pad 字段。
  • nn.Conv2d(in_channels=output_filters[-1], ...):输入通道取「上一层的输出通道」。这就是为什么 output_filters 要一路维护下去。
  • bias = not bn:有 BN 就不要 bias(BN 的 β 已能承担偏置),没 BN 就要 bias(否则缺少位移)。
  • BatchNorm2d(filters, momentum=0.9):动量是 0.9(darknet 的习惯,比 PyTorch 默认的 0.1 更「记旧」)。
  • activation == "leaky" → 接 LeakyReLU(0.1);如果是 linear(比如检测头最后一层)就不接激活。
  • add_module(f"xxx_{module_i}", ...):给子模块命名。名字前缀很重要 ,因为 weights_init_normal 靠类名判断类型,而权重加载靠顺序,命名只是便于调试。
  • f"conv_{module_i}" 用 module_i 保证名字唯一(否则同名会报错)。
9.1.2 maxpool / upsample
python 复制代码
        elif module_def["type"] == "maxpool":
            kernel_size = int(module_def["size"])
            stride = int(module_def["stride"])
            if kernel_size == 2 and stride == 1:
                modules.add_module(f"_debug_padding_{module_i}", nn.ZeroPad2d((0, 1, 0, 1)))
            maxpool = nn.MaxPool2d(kernel_size=kernel_size, stride=stride, padding=int((kernel_size - 1) // 2))
            modules.add_module(f"maxpool_{module_i}", maxpool)

        elif module_def["type"] == "upsample":
            upsample = Upsample(scale_factor=int(module_def["stride"]), mode="nearest")
            modules.add_module(f"upsample_{module_i}", upsample)
  • maxpool (yolov3 主结构里其实没有池化层,用 stride=2 卷积代替下采样,所以这段基本不执行;tiny 版会用到):一个特殊处理是「kernel=2、stride=1」时先用 ZeroPad2d 在右下各补 1 像素,再池化,模拟 darknet 的对齐行为。
  • upsample :用自定义的 Upsample(因为 nn.Upsample 已弃用),scale_factor=stride(cfg 里写 2,即放大 2 倍),mode="nearest" 最近邻。
9.1.3 route / shortcut
python 复制代码
        elif module_def["type"] == "route":
            layers = [int(x) for x in module_def["layers"].split(",")]
            filters = sum([output_filters[1:][i] for i in layers])
            modules.add_module(f"route_{module_i}", EmptyLayer())

        elif module_def["type"] == "shortcut":
            filters = output_filters[1:][int(module_def["from"])]
            modules.add_module(f"shortcut_{module_i}", EmptyLayer())
  • route :layers 解析成整数列表(如 [-1, 61])。
    • filters = sum([output_filters[1:][i] for i in layers]):拼接后的通道 = 各被引用层通道之和 。注意用 output_filters[1:] 是因为 output_filters[0] 存的是输入通道 3,[1:] 才对应「第 0 层卷积的输出通道」。索引 i 支持负数(-1 表示最后一层)。
    • EmptyLayer():route 本身没有参数,占位即可,真正拼接在 Darknet.forward 里做。
  • shortcut :filters = output_filters[1:][from]:残差相加不改变通道,所以输出通道 = 被相加层的通道。同样占位。
9.1.4 yolo 层
python 复制代码
        elif module_def["type"] == "yolo":
            anchor_idxs = [int(x) for x in module_def["mask"].split(",")]
            # Extract anchors
            anchors = [int(x) for x in module_def["anchors"].split(",")]
            anchors = [(anchors[i], anchors[i + 1]) for i in range(0, len(anchors), 2)]
            anchors = [anchors[i] for i in anchor_idxs]
            num_classes = int(module_def["classes"])
            img_size = int(hyperparams["height"])
            # Define detection layer
            yolo_layer = YOLOLayer(anchors, num_classes, img_size)
            modules.add_module(f"yolo_{module_i}", yolo_layer)
  • anchor_idxs = [6,7,8]:mask 解析成索引。
  • anchors:把 18 个数字(9 对)解析成 9 个 (w,h) 元组。range(0, len, 2) 是隔一取一的标准写法。
  • anchors = [anchors[i] for i in anchor_idxs]:按 mask 筛出本层要用的 3 个 anchor。
  • img_size = int(hyperparams["height"]):取 [net] 里的 height(416)作为 anchor 缩放的参考尺寸。
  • 创建 YOLOLayer,塞进 Sequential。
python 复制代码
        module_list.append(modules)
        output_filters.append(filters)

    return hyperparams, module_list
  • 每层处理完,把 modules(Sequential)追加进 module_list,并把该层输出通道数 追加进 output_filters(供后续层查询)。
  • 返回 (hyperparams, module_list)。

9.2 Upsample / EmptyLayer

python 复制代码
class Upsample(nn.Module):
    """ nn.Upsample is deprecated """

    def __init__(self, scale_factor, mode="nearest"):
        super(Upsample, self).__init__()
        self.scale_factor = scale_factor
        self.mode = mode

    def forward(self, x):
        x = F.interpolate(x, scale_factor=self.scale_factor, mode=self.mode)
        return x


class EmptyLayer(nn.Module):
    """Placeholder for 'route' and 'shortcut' layers"""

    def __init__(self):
        super(EmptyLayer, self).__init__()
  • Upsample.forward 实际就是 F.interpolate(..., scale_factor, mode)。放大 2 倍、最近邻。为什么不用 nn.Upsample? 因为它在当时被标记为弃用,作者自封装一个更保险。
  • EmptyLayer 是纯占位,forward 不做任何事(甚至没定义 forward)。它的存在只是为了 module_list 的索引能一一对应 cfg 的层,真正逻辑在 Darknet.forward。

9.3 YOLOLayer:检测层(解码 + 损失)

9.3.1 构造函数
python 复制代码
class YOLOLayer(nn.Module):
    def __init__(self, anchors, num_classes, img_dim=416):
        super(YOLOLayer, self).__init__()
        self.anchors = anchors
        self.num_anchors = len(anchors)
        self.num_classes = num_classes
        self.ignore_thres = 0.5
        self.mse_loss = nn.MSELoss()
        self.bce_loss = nn.BCELoss()
        self.obj_scale = 1
        self.noobj_scale = 100
        self.metrics = {}
        self.img_dim = img_dim
        self.grid_size = 0  # grid size
  • self.anchors:本层的 3 个 anchor。
  • ignore_thres = 0.5:注意 ------这里硬编码成 0.5,而 cfg 里写的是 0.7。也就是说 cfg 的 ignore_thresh 在这份代码里没被使用,实际以 0.5 为准。
  • mse_loss = nn.MSELoss():坐标回归用均方误差。
  • bce_loss = nn.BCELoss():置信度和分类用二元交叉熵(配合 sigmoid 输出)。
  • obj_scale=1、noobj_scale=100:负样本的置信度损失放大 100 倍。因为绝大多数格子是背景(负样本),如果不加权,模型会倾向「全预测没物体」,加权让模型更重视背景误检的惩罚。
  • metrics = {}:一个字典,用来给 train.py 读各种训练指标。
  • grid_size = 0:缓存当前网格大小,避免重复计算网格偏移。
9.3.2 compute_grid_offsets:生成网格坐标和缩放后的 anchor
python 复制代码
    def compute_grid_offsets(self, grid_size, cuda=True):
        self.grid_size = grid_size
        g = self.grid_size
        FloatTensor = torch.cuda.FloatTensor if cuda else torch.FloatTensor
        self.stride = self.img_dim / self.grid_size
        # Calculate offsets for each grid
        self.grid_x = torch.arange(g).repeat(g, 1).view([1, 1, g, g]).type(FloatTensor)
        self.grid_y = torch.arange(g).repeat(g, 1).t().view([1, 1, g, g]).type(FloatTensor)
        self.scaled_anchors = FloatTensor([(a_w / self.stride, a_h / self.stride) for a_w, a_h in self.anchors])
        self.anchor_w = self.scaled_anchors[:, 0:1].view((1, self.num_anchors, 1, 1))
        self.anchor_h = self.scaled_anchors[:, 1:2].view((1, self.num_anchors, 1, 1))
  • self.stride = img_dim / grid_size:一个格子代表原图多少像素。416/13=32,416/26=16,416/52=8。所以三层的 stride 分别是 32、16、8。
  • grid_x:x 方向的格子索引。torch.arange(g).repeat(g,1) 先把 0..g-1 复制 g 行,view([1,1,g,g]) 变成 (1,1,g,g) 便于广播。
  • grid_y:repeat(g,1).t() 后转置,让 y 沿行方向变化,再 view。为什么要 .t()? 因为图像坐标里 x 是列、y 是行;单独构造的等差数列默认按行扩展,转置一次才能让 y 的方向正确。
  • scaled_anchors:把 anchor 除以 stride ,得到「格子单位」的 anchor 宽高。因为解码时 pred_boxes 是在格子坐标系下算的,anchor 也必须换算到同一坐标系。
  • anchor_w/anchor_h rehape 成 (1, num_anchors, 1, 1),方便按 anchor 维广播。
9.3.3 forward:解码
python 复制代码
    def forward(self, x, targets=None, img_dim=None):
        # Tensors for cuda support
        FloatTensor = torch.cuda.FloatTensor if x.is_cuda else torch.FloatTensor
        LongTensor = torch.cuda.LongTensor if x.is_cuda else torch.LongTensor
        ByteTensor = torch.cuda.ByteTensor if x.is_cuda else torch.ByteTensor

        self.img_dim = img_dim
        num_samples = x.size(0)
        grid_size = x.size(2)

        prediction = (
            x.view(num_samples, self.num_anchors, self.num_classes + 5, grid_size, grid_size)
            .permute(0, 1, 3, 4, 2)
            .contiguous()
        )
  • 输入 x 形状 (N, 21, g, g)(21 = 3×(2+5))。
  • x.view(N, 3, classes+5, g, g):把 21 个通道拆成「3 个 anchor × 每个 anchor 的 7 个数」。
  • .permute(0,1,3,4,2):把「7 个数的维度」挪到最后,变成 (N, 3, g, g, 7)。
  • .contiguous():permute 后内存不连续,.contiguous() 整理成连续内存(后面 view/赋值需要)。
  • 为什么能这么拆? 因为最后一层卷积输出的 21 个通道,其通道顺序天然就是「按 anchor、按属性」排好的(卷积输出通道即「编码维度」)。
python 复制代码
        # Get outputs
        x = torch.sigmoid(prediction[..., 0])  # Center x
        y = torch.sigmoid(prediction[..., 1])  # Center y
        w = prediction[..., 2]  # Width
        h = prediction[..., 3]  # Height
        pred_conf = torch.sigmoid(prediction[..., 4])  # Conf
        pred_cls = torch.sigmoid(prediction[..., 5:])  # Cls pred.
  • 按惯例拆出 tx, ty, tw, th, conf, cls。
  • x, y 过 sigmoid(格内偏移 0~1)。
  • w, h 不过 sigmoid(后面用 exp)。
  • pred_conf、pred_cls 过 sigmoid(变成概率)。
python 复制代码
        # If grid size does not match current we compute new offsets
        if grid_size != self.grid_size:
            self.compute_grid_offsets(grid_size, cuda=x.is_cuda)

        # Add offset and scale with anchors
        pred_boxes = FloatTensor(prediction[..., :4].shape)
        pred_boxes[..., 0] = x.data + self.grid_x
        pred_boxes[..., 1] = y.data + self.grid_y
        pred_boxes[..., 2] = torch.exp(w.data) * self.anchor_w
        pred_boxes[..., 3] = torch.exp(h.data) * self.anchor_h
  • 如果当前网格大小和缓存不一致(第一次、或输入尺寸变了),重新计算网格偏移。
  • 解码(前面公式的落地) :
    • bx = σ(tx) + grid_x;
    • by = σ(ty) + grid_y;
    • bw = e^{tw} · anchor_w;
    • bh = e^{th} · anchor_h。
  • 得到 pred_boxes 形状 (N, 3, g, g, 4),是「格子坐标系」下的框。
python 复制代码
        output = torch.cat(
            (
                pred_boxes.view(num_samples, -1, 4) * self.stride,  # 还原到原图
                pred_conf.view(num_samples, -1, 1),
                pred_cls.view(num_samples, -1, self.num_classes),
            ),
            -1,
        )
  • pred_boxes.view(N, -1, 4) * stride:把 (N,3,g,g,4) 拉平成 (N, 3*g*g, 4),并乘 stride 还原成原图像素坐标。
  • 和置信度、类别概率一起 cat 到最后一维,得到 (N, 3*g*g, 4+1+classes)。
  • 这就是该尺度上所有预测框的完整信息。
python 复制代码
        if targets is None:
            return output, 0
        else:
            iou_scores, class_mask, obj_mask, noobj_mask, tx, ty, tw, th, tcls, tconf = build_targets(
                pred_boxes=pred_boxes,
                pred_cls=pred_cls,
                target=targets,
                anchors=self.scaled_anchors,
                ignore_thres=self.ignore_thres,
            )
  • 推理时 (targets is None):直接返回 (output, 0),损失为 0。
  • 训练时 :调用 build_targets,传入 pred_boxes(格子坐标)、pred_cls、真值 targets、scaled_anchors、ignore_thres,得到「目标」和「掩码」。
9.3.4 损失计算
python 复制代码
            loss_x = self.mse_loss(x[obj_mask], tx[obj_mask]) # 只计算有目标的
            loss_y = self.mse_loss(y[obj_mask], ty[obj_mask])
            loss_w = self.mse_loss(w[obj_mask], tw[obj_mask])
            loss_h = self.mse_loss(h[obj_mask], th[obj_mask])
            loss_conf_obj = self.bce_loss(pred_conf[obj_mask], tconf[obj_mask])
            loss_conf_noobj = self.bce_loss(pred_conf[noobj_mask], tconf[noobj_mask])
            loss_conf = self.obj_scale * loss_conf_obj + self.noobj_scale * loss_conf_noobj
            loss_cls = self.bce_loss(pred_cls[obj_mask], tcls[obj_mask])
            total_loss = loss_x + loss_y + loss_w + loss_h + loss_conf + loss_cls
  • x[obj_mask]:布尔掩码索引 ,只挑出「正样本位置」的预测值和目标值。obj_mask 是 ByteTensor 当布尔掩码用。
  • 坐标四项都用 MSE,且只在正样本上算(背景没有框,不该回归坐标)。
  • 置信度分两部分 :
    • loss_conf_obj:正样本位置,目标 tconf=1(希望预测接近 1);
    • loss_conf_noobj:负样本位置,目标 tconf=0(希望预测接近 0);
    • loss_conf = obj_scale*obj + noobj_scale*noobj(noobj 权重 100)。
  • loss_cls:分类损失,也只在正样本上算(背景没有类别可言)。
  • total_loss:六项相加。注意这里没有做平衡系数(如 λcoord),坐标项直接权重 1,属于这份实现的简化。
9.3.5 统计指标
python 复制代码
            cls_acc = 100 * class_mask[obj_mask].mean()
            conf_obj = pred_conf[obj_mask].mean()
            conf_noobj = pred_conf[noobj_mask].mean()
            conf50 = (pred_conf > 0.5).float()
            iou50 = (iou_scores > 0.5).float()
            iou75 = (iou_scores > 0.75).float()
            detected_mask = conf50 * class_mask * tconf
            precision = torch.sum(iou50 * detected_mask) / (conf50.sum() + 1e-16)
            recall50 = torch.sum(iou50 * detected_mask) / (obj_mask.sum() + 1e-16)
            recall75 = torch.sum(iou75 * detected_mask) / (obj_mask.sum() + 1e-16)
  • cls_acc:正样本上「分类正确率」(class_mask 只有分类对的为 1)。
  • conf_obj/conf_noobj:正/负样本的平均置信度,用来观察模型是否「把背景压下去了」。
  • conf50:预测置信度 > 0.5 的掩码;iou50/iou75:IoU 阈值掩码。
  • detected_mask = conf50 * class_mask * tconf:既「置信度过 0.5」又「分类正确」又「真有物体」。
  • precision:在所有这些「被认作正样本」的位置里,IoU>0.5 的比例。
  • recall50/recall75:在所有真实正样本里,IoU 超阈值的比例。
python 复制代码
            self.metrics = {
                "loss": to_cpu(total_loss).item(),
                "x": ..., "y": ..., "w": ..., "h": ..., "conf": ..., "cls": ...,
                "cls_acc": ..., "recall50": ..., "recall75": ..., "precision": ...,
                "conf_obj": ..., "conf_noobj": ..., "grid_size": grid_size,
            }

            return output, total_loss
  • 把这些指标存进 self.metrics,供 train.py 打印和写日志。
  • 返回 (output, total_loss)。

9.4 Darknet:整体网络

9.4.1 构造
python 复制代码
class Darknet(nn.Module):
    """YOLOv3 object detection model"""

    def __init__(self, config_path, img_size=416):
        super(Darknet, self).__init__()
        self.module_defs = parse_model_config(config_path)
        self.hyperparams, self.module_list = create_modules(self.module_defs)
        self.yolo_layers = [layer[0] for layer in self.module_list if hasattr(layer[0], "metrics")]
        self.img_size = img_size
        self.seen = 0
        self.header_info = np.array([0, 0, 0, self.seen, 0], dtype=np.int32)
  • parse_model_config + create_modules:读配置、建层。
  • self.yolo_layers:从 module_list 里挑出那些「第 0 个子模块有 metrics 属性」的层------也就是 YOLOLayer。这样就能拿到三个 yolo 层的引用,训练时读它们的 metrics。
  • seen = 0:darknet 的「已见图片数」计数器,保存 .weights 时要写进 header。
  • header_info:darknet .weights 文件开头 5 个 int32(major, minor, revision, seen, transpose)的模板。
9.4.2 forward:按 cfg 顺序执行
python 复制代码
    def forward(self, x, targets=None):
        img_dim = x.shape[2]
        loss = 0
        layer_outputs, yolo_outputs = [], []
        for i, (module_def, module) in enumerate(zip(self.module_defs, self.module_list)):
            if module_def["type"] in ["convolutional", "upsample", "maxpool"]:
                x = module(x)
            elif module_def["type"] == "route":
                x = torch.cat([layer_outputs[int(layer_i)] for layer_i in module_def["layers"].split(",")], 1)
            elif module_def["type"] == "shortcut":
                layer_i = int(module_def["from"])
                x = layer_outputs[-1] + layer_outputs[layer_i]
            elif module_def["type"] == "yolo":
                x, layer_loss = module[0](x, targets, img_dim)
                loss += layer_loss
                yolo_outputs.append(x)
            layer_outputs.append(x)
        yolo_outputs = to_cpu(torch.cat(yolo_outputs, 1))
        return yolo_outputs if targets is None else (loss, yolo_outputs)
  • img_dim = x.shape[2]:输入高(等于宽),传给 YOLOLayer 用于算 stride。
  • layer_outputs:保存每一层的输出(顺序与 cfg 层一一对应),因为 route/shortcut 要「回头取前面某层的输出」。
  • yolo_outputs:收集三个检测头的输出。
  • 逐层:
    • conv/upsample/maxpool :直接 x = module(x),前向推进。
    • route :torch.cat([layer_outputs[索引] ...], 1),沿通道维(dim=1)拼接 。索引由 layers 决定,支持负数。
    • shortcut :layer_outputs[-1] + layer_outputs[layer_i],把「上一层输出」和「指定层输出」逐元素相加 (残差)。这里 layer_outputs[-1] 就是上一层的结果(因为上一轮尾部 append 了它)。
    • yolo :调用 module[0](即 YOLOLayer),得到该尺度输出和损失;损失累加;输出收集起来。
    • 最后 :无论哪种层,都把当前 x(或拼接/相加结果)append 进 layer_outputs,保证索引稳定。
  • 循环结束后 torch.cat(yolo_outputs, 1):把三个尺度的预测 (N, 3*g², 7) 沿框维拼接 成 (N, 10647, 7)。to_cpu 转 CPU(推理时用)。
  • 返回:推理返回 yolo_outputs(框张量);训练返回 (loss, yolo_outputs)。

注意一个易错点 :layer_outputs 是按循环顺序 (即 cfg 层顺序)append 的,而 route 里的 layers = -1, 61 中的 61 指的是cfg 里第 61 个块的输出 。所以 layer_outputs[i] 的下标 i 必须和 cfg 块下标严格对应------这也是为什么 EmptyLayer 必须存在:它占着位置,保证索引不串。

9.5 load_darknet_weights:加载 darknet 权重(迁移学习关键)

python 复制代码
    def load_darknet_weights(self, weights_path):
        """Parses and loads the weights stored in 'weights_path'"""

        # Open the weights file
        with open(weights_path, "rb") as f:
            header = np.fromfile(f, dtype=np.int32, count=5)  # 前5个是开头值
            self.header_info = header
            self.seen = header[3]
            weights = np.fromfile(f, dtype=np.float32)  # 读取剩余的权重数据

        # Establish cutoff for loading backbone weights
        cutoff = None
        if "darknet53.conv.74" in weights_path:
            cutoff = 75

        ptr = 0
        for i, (module_def, module) in enumerate(zip(self.module_defs, self.module_list)):
            if i == cutoff:
                break
            if module_def["type"] == "convolutional":
                conv_layer = module[0]
                if module_def["batch_normalize"]:
                    bn_layer = module[1]
                    num_b = bn_layer.bias.numel()  # Number of biases
                    ...
  • 以二进制 打开 .weights 文件。
  • np.fromfile(f, dtype=np.int32, count=5):先读开头 5 个 int32(darknet 的 header)。
  • header[3] 是 seen。
  • np.fromfile(f, dtype=np.float32):接着读剩下的全部 float32 ------这些就是权重,按层顺序平铺。所以加载时必须知道「每层各有多少个参数」,按顺序切分。
  • cutoff = 75 的含义 :当加载的是 darknet53.conv.74(只含骨干网络 、不含检测头的预训练权重)时,只加载前 75 层(i == cutoff 就 break)。因为 darknet53.conv.74 只有骨干权重,检测头(后面新增的 yolo 层相关卷积)没有对应的预训练参数,加载会错位。这就是「迁移学习」:骨干用预训练的,检测头随机初始化后自己学。
  • ptr:一个「读到哪了」的指针,每加载一段就 ptr += 数量。
  • 逐层遍历 module_defs 和 module_list:
    • 只处理 convolutional 层(其他层没有参数)。
    • conv_layer = module[0]:Sequential 的第 0 个是卷积。
    • 若 batch_normalize:第 1 个子模块是 BN。darknet 对「带 BN 的卷积」保存顺序是 BN 的 bias → weight → running_mean → running_var → 卷积 weight (没有卷积 bias ,因为带 BN 就不带 bias)。每段都取 num_b = bn_layer.bias.numel() 个参数,view_as 成对应形状后 copy_。
    • 若无 BN:先读卷积 bias,再读卷积 weight。
    • 卷积权重段 num_w = conv_layer.weight.numel(),view_as(conv_layer.weight) 后 copy_。
python 复制代码
    def save_darknet_weights(self, path, cutoff=-1):
        fp = open(path, "wb")
        self.header_info[3] = self.seen
        self.header_info.tofile(fp)

        for i, (module_def, module) in enumerate(zip(self.module_defs[:cutoff], self.module_list[:cutoff])):
            if module_def["type"] == "convolutional":
                conv_layer = module[0]
                if module_def["batch_normalize"]:
                    bn_layer = module[1]
                    bn_layer.bias.data.cpu().numpy().tofile(fp)
                    bn_layer.weight.data.cpu().numpy().tofile(fp)
                    bn_layer.running_mean.data.cpu().numpy().tofile(fp)
                    bn_layer.running_var.data.cpu().numpy().tofile(fp)
                else:
                    conv_layer.bias.data.cpu().numpy().tofile(fp)
                conv_layer.weight.data.cpu().numpy().tofile(fp)

        fp.close()
  • save_darknet_weights 是反过来:把网络参数按 darknet 顺序写回二进制文件。
  • header_info[3] = self.seen:更新 seen 再写 header。
  • 遍历([:cutoff] 切片,cutoff=-1 表示全部):按 BN(bias, weight, mean, var) → conv(weight) 的顺序写。顺序必须和 darknet 读的顺序一致,否则文件读不回来。
  • 注意 :顺序是「bias 在前、weight 在后」,这是 darknet 的约定,写错就读不回来------这也是加载那条 bn_bias → bn_weight → bn_rm → bn_rv → conv_weight 的来源。

【截图占位:darknet53.conv.74 权重文件 / 打印网络结构】


十、train.py 逐段详解

这是训练入口。整体流程:解析参数 → 建模型 → 加载预训练 → 建 DataLoader → 循环训练 → 定期评估 → 保存检查点。

10.1 导入与命令行参数

python 复制代码
from models import *
from utils.logger import *
from utils.utils import *
from utils.datasets import *
from utils.parse_config import *
from test import evaluate

import warnings
warnings.filterwarnings("ignore")

from terminaltables import AsciiTable

import os
import time
import datetime
import argparse

import torch
from torch.utils.data import DataLoader
from torch.autograd import Variable
  • 一堆 from xxx import * 把几个模块的东西全导进来。
  • from test import evaluate:关键 ------ 复用 test.py 里的 evaluate 函数做验证,避免重复实现。
  • warnings.filterwarnings("ignore"):屏蔽警告,让控制台干净。
  • terminaltables.AsciiTable:用来打印漂亮的 ASCII 表格(训练指标表)。
  • Variable:历史遗留的自动微分包装类(新版 PyTorch 里 Tensor 本身就带梯度,Variable 只是薄包装,用不用都行)。
python 复制代码
if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--epochs", type=int, default=10, help="number of epochs")
    parser.add_argument("--batch_size", type=int, default=1, help="size of each image batch")
    parser.add_argument("--gradient_accumulations", type=int, default=1, help="number of gradient accums before step")
    parser.add_argument("--model_def", type=str, default="config/yolov3.cfg", help="path to model definition file")
    parser.add_argument("--data_config", type=str, default="config/coco.data", help="path to data config file")
    parser.add_argument("--pretrained_weights", type=str, help="if specified starts from checkpoint model")
    parser.add_argument("--n_cpu", type=int, default=0, help="number of cpu threads to use during batch generation")
    parser.add_argument("--img_size", type=int, default=416, help="size of each image dimension")
    parser.add_argument("--checkpoint_interval", type=int, default=1, help="interval between saving model weights")
    parser.add_argument("--evaluation_interval", type=int, default=1, help="interval evaluations on validation set")
    parser.add_argument("--compute_map", default=False, help="if True computes mAP every tenth batch")
    parser.add_argument("--multiscale_training", default=True, help="allow for multi-scale training")
    opt = parser.parse_args()
    print(opt)
  • 每个参数都有默认值。注意默认值是「官方 COCO 配置」 (config/yolov3.cfg、config/coco.data),要训练自己的数据必须在命令行显式传参覆盖,见第十四章。
  • --gradient_accumulations:梯度累积次数,用于「小显存模拟大 batch」------攒够 N 次梯度再更新参数。
  • --pretrained_weights:预训练权重路径。本项目用它加载 weights/darknet53.conv.74。
  • --multiscale_training 默认 True(多尺度训练开关)。
  • --img_size:输入尺寸,默认 416。

10.2 环境准备与数据配置

python 复制代码
    logger = Logger("logs")#日志文件

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    os.makedirs("output", exist_ok=True)
    os.makedirs("checkpoints", exist_ok=True)

    # Get data configuration
    data_config = parse_data_config(opt.data_config)
    train_path = data_config["train"]
    valid_path = data_config["valid"]
    class_names = load_classes(data_config["names"])
  • Logger("logs"):创建 TensorBoard 日志写入器,日志目录 logs。
  • device:自动选 GPU/CPU。
  • os.makedirs(..., exist_ok=True):保证输出目录存在(exist_ok=True 避免目录已存在时报错)。
  • 解析 .data 文件,取出训练/验证清单路径和类名文件路径。

10.3 建模型并加载预训练权重

python 复制代码
    # Initiate model
    model = Darknet(opt.model_def).to(device)
    model.apply(weights_init_normal)

    # If specified we start from checkpoint
    if opt.pretrained_weights:
        if opt.pretrained_weights.endswith(".pth"):
            model.load_state_dict(torch.load(opt.pretrained_weights))
        else:
            model.load_darknet_weights(opt.pretrained_weights)
  • Darknet(opt.model_def):按 cfg 建网络,.to(device) 搬到设备。
  • model.apply(weights_init_normal):先把所有权重随机初始化(前面讲过,这是给「没有预训练的那部分」打底)。
  • 加载预训练:
    • 以 .pth 结尾:用 load_state_dict(PyTorch 自己的保存格式 ,是完整 state_dict,全部参数都能对上)。
    • 否则(.weights / .conv.74):用 load_darknet_weights(darknet 二进制,按 cutoff 只加载骨干)。
  • endswith 判断后缀是区分两种格式的关键。

10.4 DataLoader

python 复制代码
    # Get dataloader
    dataset = ListDataset(train_path, augment=True, multiscale=opt.multiscale_training)
    dataloader = torch.utils.data.DataLoader(
        dataset,
        batch_size=opt.batch_size,
        shuffle=True,
        num_workers=opt.n_cpu,
        pin_memory=True,
        collate_fn=dataset.collate_fn,
    )
  • ListDataset(train_path, augment=True, multiscale=...):训练集开增强、开多尺度。
  • DataLoader 关键参数:
    • batch_size:每批样本数。
    • shuffle=True:每 epoch 打乱,提升泛化。
    • num_workers:子进程加载数据的数量(0 = 主进程加载)。Windows 上设 >0 有时会有兼容问题,可设 0。
    • pin_memory=True:把数据固定在「锁页内存」,GPU 传输更快(建议配合 GPU 使用)。
    • collate_fn=dataset.collate_fn:用自定义的拼 batch 函数(处理变长标签 + 多尺度),这是必须的。

10.5 优化器与指标表头

python 复制代码
    optimizer = torch.optim.Adam(model.parameters())

    metrics = [
        "grid_size", "loss", "x", "y", "w", "h", "conf", "cls",
        "cls_acc", "recall50", "recall75", "precision", "conf_obj", "conf_noobj",
    ]
  • Adam:自适应学习率优化器,默认 lr=1e-3。这份代码没用学习率调度(简单粗暴)。
  • metrics:要打印/记录的指标名列表,和 YOLOLayer.metrics 字典里的键一一对应。

10.6 训练主循环

python 复制代码
    for epoch in range(opt.epochs):
        model.train()
        start_time = time.time()
        for batch_i, (_, imgs, targets) in enumerate(dataloader):
            batches_done = len(dataloader) * epoch + batch_i

            imgs = Variable(imgs.to(device))
            targets = Variable(targets.to(device), requires_grad=False)
            loss, outputs = model(imgs, targets)
            loss.backward()

            if batches_done % opt.gradient_accumulations:
                optimizer.step()
                optimizer.zero_grad()

            model.seen += imgs.size(0)
  • model.train():切训练模式(BN 用批统计、Dropout 生效)。
  • for batch_i, (_, imgs, targets) in enumerate(dataloader):第一个返回值是路径,用 _ 忽略。
  • batches_done:全局步数(epoch 数 × 每轮批数 + 当前批号),用于梯度累积判断和日志 step。
  • Variable(imgs.to(device))、targets 搬运到设备。requires_grad=False:标签不需要梯度。
  • loss, outputs = model(imgs, targets):传入 targets 走训练分支,返回总损失和各尺度输出。
  • loss.backward():反向传播算梯度。
  • if batches_done % opt.gradient_accumulations::梯度累积 。当累积次数=1 时,batches_done % 1 == 0 恒真 → 每批都更新;当为 N 时,每 N 批才 step + zero_grad 一次。注意 :这里 zero_grad 放在 step 之后,所以在「没到累积点」的批次里梯度会一直累加------这是该实现的一个小陷阱:zero_grad 只在 step 时执行一次,逻辑上可行但把 zero_grad 写在 step 前更规范。
  • model.seen += imgs.size(0):记录已见图片数(darknet 惯例)。

训练时打印(略):

python 复制代码
            log_str = "\n---- [Epoch %d/%d, Batch %d/%d] ----\n" % (epoch, opt.epochs, batch_i, len(dataloader))
            metric_table = [["Metrics", *[f"YOLO Layer {i}" for i in range(len(model.yolo_layers))]]]

            for i, metric in enumerate(metrics):
                formats = {m: "%.6f" for m in metrics}
                formats["grid_size"] = "%2d"
                formats["cls_acc"] = "%.2f%%"
                row_metrics = [formats[metric] % yolo.metrics.get(metric, 0) for yolo in model.yolo_layers]
                metric_table += [[metric, *row_metrics]]

                tensorboard_log = []
                for j, yolo in enumerate(model.yolo_layers):
                    for name, metric in yolo.metrics.items():
                        if name != "grid_size":
                            tensorboard_log += [(f"{name}_{j+1}", metric)]
                tensorboard_log += [("loss", loss.item())]
                logger.list_of_scalars_summary(tensorboard_log, batches_done)

            log_str += AsciiTable(metric_table).table
            log_str += f"\nTotal loss {loss.item()}"
            ...
            print(log_str)
  • metric_table 表头是 ["Metrics", "YOLO Layer 0", "YOLO Layer 1", "YOLO Layer 2"],三个检测头分别打印各自指标。
  • formats:grid_size 用整数、cls_acc 用百分比、其余保留 6 位小数。
  • yolo.metrics.get(metric, 0):从每个 yolo 层的 metrics 里取对应值(取不到就 0)。
  • TensorBoard 日志:把三个层的指标按 名字_层号 写进去,外加一个 loss。
  • AsciiTable(...).table:把表格渲染成字符串,拼进 log_str。
  • ETA 计算:epoch_batches_left × 平均每批耗时,估算剩余时间(datetime.timedelta)。

10.7 验证与保存

python 复制代码
        if epoch % opt.evaluation_interval == 0:
            print("\n---- Evaluating Model ----")
            precision, recall, AP, f1, ap_class = evaluate(
                model,
                path=valid_path,
                iou_thres=0.5,
                conf_thres=0.5,
                nms_thres=0.5,
                img_size=opt.img_size,
                batch_size=8,
            )
            evaluation_metrics = [
                ("val_precision", precision.mean()),
                ("val_recall", recall.mean()),
                ("val_mAP", AP.mean()),
                ("val_f1", f1.mean()),
            ]
            logger.list_of_scalars_summary(evaluation_metrics, epoch)

            ap_table = [["Index", "Class name", "AP"]]
            for i, c in enumerate(ap_class):
                ap_table += [[c, class_names[c], "%.5f" % AP[i]]]
            print(AsciiTable(ap_table).table)
            print(f"---- mAP {AP.mean()}")

        if epoch % opt.checkpoint_interval == 0:
            torch.save(model.state_dict(), f"checkpoints/yolov3_ckpt_%d.pth" % epoch)
  • epoch % evaluation_interval == 0:按间隔在验证集上评估,调用 test.py 的 evaluate,拿到各类 p/r/AP/f1。
  • 写入 TensorBoard,并打印一张「类名 → AP」的表和总 mAP。
    • class_names[c] 把类号翻译成 dog/cat。
    • ---- mAP 0.xxxx 是核心指标。
  • epoch % checkpoint_interval == 0:按间隔保存 .pth(torch.save(model.state_dict(), ...)),文件名带 epoch 号。

训练产出的关键观察点:

  • loss 是否稳定下降;
  • conf_obj 趋近 1、conf_noobj 趋近 0(说明模型能区分前景/背景);
  • cls_acc 上升;precision/recall、mAP 上升。

【截图占位:训练过程的 AsciiTable 指标表 / loss 下降曲线】


十一、test.py 逐段详解

test.py 有两个角色:命令行评估工具 + 给 train.py 复用的 evaluate 函数。

11.1 evaluate 函数

python 复制代码
def evaluate(model, path, iou_thres, conf_thres, nms_thres, img_size, batch_size):
    model.eval()

    # Get dataloader
    dataset = ListDataset(path, img_size=img_size, augment=False, multiscale=False)
    dataloader = torch.utils.data.DataLoader(
        dataset, batch_size=batch_size, shuffle=False, num_workers=1, collate_fn=dataset.collate_fn
    )

    Tensor = torch.cuda.FloatTensor if torch.cuda.is_available() else torch.FloatTensor

    labels = []
    sample_metrics = []  # List of tuples (TP, confs, pred)
    for batch_i, (_, imgs, targets) in enumerate(tqdm.tqdm(dataloader, desc="Detecting objects")):

        # Extract labels
        labels += targets[:, 1].tolist()
        # Rescale target
        targets[:, 2:] = xywh2xyxy(targets[:, 2:])
        targets[:, 2:] *= img_size

        imgs = Variable(imgs.type(Tensor), requires_grad=False)

        with torch.no_grad():
            outputs = model(imgs)
            outputs = non_max_suppression(outputs, conf_thres=conf_thres, nms_thres=nms_thres)

        sample_metrics += get_batch_statistics(outputs, targets, iou_threshold=iou_thres)

    # Concatenate sample statistics
    true_positives, pred_scores, pred_labels = [np.concatenate(x, 0) for x in list(zip(*sample_metrics))]
    precision, recall, AP, f1, ap_class = ap_per_class(true_positives, pred_scores, pred_labels, labels)

    return precision, recall, AP, f1, ap_class

逐段:

  • model.eval():切评估模式(BN 用滑动平均统计量,不更新),保证推理稳定、可复现。
  • 建 ListDataset:augment=False, multiscale=False(评估不能随机增强/换尺寸,否则指标不可比)。
  • DataLoader:shuffle=False(顺序无关紧要但保持稳定),num_workers=1。
  • labels:收集所有真实标签的类别编号。
  • sample_metrics:收集每批的 [TP标记, 分数, 类别]。
  • 循环里:
    • labels += targets[:, 1].tolist():真值类别。
    • targets[:, 2:] = xywh2xyxy(targets[:, 2:]):把归一化的 cx cy w h 转成 xyxy。
    • targets[:, 2:] *= img_size:从归一化还原到像素坐标(乘以输入尺寸),这样才能和预测框(像素坐标)算 IoU。
    • with torch.no_grad():关闭梯度,省显存、加速。
    • outputs = model(imgs):不传 targets,走推理分支。
    • non_max_suppression(...):过滤 + NMS。
    • get_batch_statistics(...):逐图判定 TP。
  • list(zip(*sample_metrics)):把「每批的 3 元组」合并成「三个列表」,再各自 np.concatenate 成整段数组。
  • ap_per_class(...):算各类 AP。
  • 返回 precision, recall, AP, f1, ap_class。

11.2 命令行入口

python 复制代码
if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--batch_size", type=int, default=8, help="size of each image batch")
    parser.add_argument("--model_def", type=str, default="config/yolov3-custom.cfg", ...)
    parser.add_argument("--data_config", type=str, default="config/custom.data", ...)
    parser.add_argument("--weights_path", type=str, default="checkpoints/yolov3_ckpt_1.pth", ...)
    parser.add_argument("--class_path", type=str, default="data/classes.names", ...)
    parser.add_argument("--iou_thres", type=float, default=0.5, ...)
    parser.add_argument("--conf_thres", type=float, default=0.001, ...)
    parser.add_argument("--nms_thres", type=float, default=0.5, ...)
    parser.add_argument("--n_cpu", type=int, default=8, ...)
    parser.add_argument("--img_size", type=int, default=416, ...)
    opt = parser.parse_args()
    print(opt)

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    data_config = parse_data_config(opt.data_config)
    valid_path = data_config["valid"]
    class_names = load_classes(data_config["names"])

    # Initiate model
    model = Darknet(opt.model_def).to(device)
    if opt.weights_path.endswith(".weights"):
        model.load_darknet_weights(opt.weights_path)
    else:
        model.load_state_dict(torch.load(opt.weights_path))

    print("Compute mAP...")

    precision, recall, AP, f1, ap_class = evaluate(
        model, path=valid_path, iou_thres=opt.iou_thres, conf_thres=opt.conf_thres,
        nms_thres=opt.nms_thres, img_size=opt.img_size, batch_size=8,
    )

    print("Average Precisions:")
    for i, c in enumerate(ap_class):
        print(f"+ Class '{c}' ({class_names[c]}) - AP: {AP[i]}")

    print(f"mAP: {AP.mean()}")
  • 默认参数已经是「自定义数据集」版 (config/yolov3-custom.cfg、config/custom.data),说明作者主要用它评估自己的模型。
  • conf_thres 默认 0.001(非常低) ------ 评估 mAP 时故意不卡高置信度,让「按分数排序」发挥作用;低分框会排到最后,不影响前面高分的精确率。
  • weights_path 默认指向 checkpoints/yolov3_ckpt_1.pth(这是本副本已存在的训练产物)。
  • 加载权重:.weights 用 darknet 加载,否则用 load_state_dict。
  • 打印每类的 AP 和总 mAP。

注意 :命令行入口里 class_path 参数其实没被使用 (用的是 data_config["names"]),属于官方遗留参数。

【截图占位:test.py 输出的各类 AP 与 mAP】


十二、detect.py 逐段详解

这是「对一批图片推理并画框保存」的脚本,用 ImageFolder 而非 ListDataset(推理不需要标签)。

12.1 参数与模型

python 复制代码
if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--image_folder", type=str, default="data/samples", help="path to dataset")
    parser.add_argument("--model_def", type=str, default="config/yolov3.cfg", help="path to model definition file")
    parser.add_argument("--weights_path", type=str, default="weights/yolov3.weights", help="path to weights file")
    parser.add_argument("--class_path", type=str, default="data/coco.names", help="path to class label file")
    parser.add_argument("--conf_thres", type=float, default=0.8, help="object confidence threshold")
    parser.add_argument("--nms_thres", type=float, default=0.4, help="iou thresshold for non-maximum suppression")
    parser.add_argument("--batch_size", type=int, default=1, help="size of the batches")
    parser.add_argument("--n_cpu", type=int, default=0, help="number of cpu threads to use during batch generation")
    parser.add_argument("--img_size", type=int, default=416, help="size of each image dimension")
    parser.add_argument("--checkpoint_model", type=str, help="path to checkpoint model")
    opt = parser.parse_args()
    print(opt)

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    os.makedirs("output", exist_ok=True)

    # Set up model
    model = Darknet(opt.model_def, img_size=opt.img_size).to(device)

    if opt.weights_path.endswith(".weights"):
        model.load_darknet_weights(opt.weights_path)
    else:
        model.load_state_dict(torch.load(opt.weights_path))

    model.eval()  # Set in evaluation mode
  • 默认值是 COCO 版 (config/yolov3.cfg、weights/yolov3.weights、data/coco.names)------推理自己的模型时要覆盖这三个参数。
  • conf_thres 默认 0.8(比评估高得多):推理时宁可少框、不可乱框,所以阈值调高。
  • model.eval():评估模式。
  • 注意 :--checkpoint_model 参数存在但没有被使用,属于遗留参数。

12.2 数据与推理

python 复制代码
    dataloader = DataLoader(
        ImageFolder(opt.image_folder, img_size=opt.img_size),
        batch_size=opt.batch_size,
        shuffle=False,
        num_workers=opt.n_cpu,
    )

    classes = load_classes(opt.class_path)
    Tensor = torch.cuda.FloatTensor if torch.cuda.is_available() else torch.FloatTensor

    imgs = []  # Stores image paths
    img_detections = []  # Stores detections for each image index

    print("\nPerforming object detection:")
    prev_time = time.time()
    for batch_i, (img_paths, input_imgs) in enumerate(dataloader):
        # Configure input
        input_imgs = Variable(input_imgs.type(Tensor))

        # Get detections
        with torch.no_grad():
            detections = model(input_imgs)
            detections = non_max_suppression(detections, opt.conf_thres, opt.nms_thres)

        # Log progress
        current_time = time.time()
        inference_time = datetime.timedelta(seconds=current_time - prev_time)
        prev_time = current_time
        print("\t+ Batch %d, Inference Time: %s" % (batch_i, inference_time))

        # Save image and detections
        imgs.extend(img_paths)
        img_detections.extend(detections)
  • ImageFolder(...):只加载图片(返回 (path, img))。
  • classes = load_classes(opt.class_path):读推理用的类名文件 (记住要指向你自己的 data/custom1/classes.names)。
  • 循环:把图送进模型(with torch.no_grad()),NMS 过滤;顺手记录每批的推理耗时。
  • imgs.extend(img_paths)、img_detections.extend(detections):把路径和结果累积起来,等会统一画图。
  • 注意 :推理走的是 targets is None 分支,返回的是预测框张量(不是 (loss, out))。

12.3 画框并保存

python 复制代码
    # Bounding-box colors
    cmap = plt.get_cmap("tab20b")
    colors = [cmap(i) for i in np.linspace(0, 1, 20)]

    print("\nSaving images:")
    for img_i, (path, detections) in enumerate(zip(imgs, img_detections)):

        print("(%d) Image: '%s'" % (img_i, path))

        # Create plot
        img = np.array(Image.open(path))
        plt.figure()
        fig, ax = plt.subplots(1)
        ax.imshow(img)

        if detections is not None:
            # Rescale boxes to original image
            detections = rescale_boxes(detections, opt.img_size, img.shape[:2])
            unique_labels = detections[:, -1].cpu().unique()
            n_cls_preds = len(unique_labels)
            bbox_colors = random.sample(colors, n_cls_preds)
            for x1, y1, x2, y2, conf, cls_conf, cls_pred in detections:

                print("\t+ Label: %s, Conf: %.5f" % (classes[int(cls_pred)], cls_conf.item()))

                box_w = x2 - x1
                box_h = y2 - y1

                color = bbox_colors[int(np.where(unique_labels == int(cls_pred))[0])]
                # Create a Rectangle patch
                bbox = patches.Rectangle((x1, y1), box_w, box_h, linewidth=2, edgecolor=color, facecolor="none")
                ax.add_patch(bbox)
                plt.text(
                    x1, y1, s=classes[int(cls_pred)], color="white",
                    verticalalignment="top",
                    bbox={"color": color, "pad": 0},
                )

        # Save generated image with detections
        plt.axis("off")
        plt.gca().xaxis.set_major_locator(NullLocator())
        plt.gca().yaxis.set_major_locator(NullLocator())
        filename = path.split("/")[-1].split(".")[0]
        plt.savefig(f"output/{filename}.png", bbox_inches="tight", pad_inches=0.0)
        plt.close()
  • cmap = plt.get_cmap("tab20b")、colors = [cmap(i) for i in np.linspace(0,1,20)]:准备 20 种颜色,给不同类别上色。
  • np.array(Image.open(path)):以 numpy 数组读原图用于显示(保留原图分辨率)。
  • rescale_boxes(detections, opt.img_size, img.shape[:2]):把框从「416 补边坐标系」还原到原图像素坐标(就是 8.3 的函数),否则框的位置/大小会不对。
  • unique_labels = detections[:,-1].cpu().unique():本图出现的类别。
  • bbox_colors = random.sample(colors, n_cls_preds):为这些类别随机配色。
  • for x1, y1, x2, y2, conf, cls_conf, cls_pred in detections::解包 7 列。
  • color = bbox_colors[int(np.where(unique_labels == int(cls_pred))[0])]:找到该类别在 unique_labels 中的位置,取对应颜色。
  • patches.Rectangle(...):画矩形框;ax.add_patch 加进子图;plt.text 在框左上角写类名。
  • 保存:
    • plt.axis("off") + NullLocator:去掉坐标轴。
    • filename = path.split("/")[-1].split(".")[0]:从路径取文件名(不含扩展名)。注意这里用 / 分隔符 ,所以在 Windows 路径下要留意(Path 里可能混用 \)。
    • plt.savefig(f"output/{filename}.png", bbox_inches="tight", pad_inches=0.0):存到 output/ 目录。

两个值得注意的细节:

  1. random 的来源 :detect.py 里用到了 random.sample,但文件开头并没有 import random。它能跑通,是因为 from utils.datasets import * 把 datasets.py 里 import random 的模块名也一起带进来了。这种「依赖星号导入」的写法比较脆弱:如果你把 datasets.py 的 import random 删了、或改用 __all__ 限制导出,这里就会 NameError。稳妥做法是在 detect.py 里显式 import random。
  2. fig, ax = plt.subplots(1) 之前还多 plt.figure();对最终结果不影响,只是多建了一个空图。

【截图占位:detect.py 输出的带框图片(dog/cat)】


十三、常见坑(现象 --- 原因 --- 对策)

13.1 一训练就 KeyError 或维度不匹配

  • 现象 :报 x.view(...) 维度错误,或 yolo 层崩,提示通道数不对。
  • 原因 :改了 custom.data 的类别数,但忘了改 .cfg 最后一层卷积的 filters 。filters 必须等于 3 × (classes + 5)。
  • 对策 :2 类 → filters=21,三处都要改(或直接用现成的 config/yolov3-custom.cfg,它就是按 2 类生成好的)。

13.2 Class '0' (dog) 预测错乱 / 标签和类名对不上

  • 现象:训练能跑,但评估出的类名张冠李戴。
  • 原因 :json2yolo.py 的 name2id 顺序,和 data/custom1/classes.names 的行顺序不一致。
  • 对策 :让 classes.names 第 0 行 = name2id 里映射为 0 的类。本项目是第 0 行 dog、第 1 行 cat,对应 {'dog':0, 'cat':1}。

13.3 找不到标签文件,训练「看不见框」

  • 现象 :loss 里的坐标项一直很小甚至消失,模型只学「全背景」,或者 targets 为空报错。
  • 原因 :ListDataset 靠把路径里的 images 换成 labels、扩展名换成 .txt 来定位标签。如果目录结构不对称 (图片在 images/train/,标签却在别处)或文件名不一致 ,就找不到标签,targets 保持 None,样本退化成纯背景。
  • 对策 :保证 data/custom1/images/... 与 data/custom1/labels/... 目录一一对应、文件名(除扩展名)相同。用 label_files 的推导规则核对一遍路径。

13.4 json2yolo.py 运行报「找不到文件 / 路径不存在」

  • 现象 :FileNotFoundError。
  • 原因 :脚本里 json 目录写的是绝对路径,输出目录 ..\data\txt\ 也依赖当前工作目录,且输出目录必须预先存在 (open(...,'w') 不会自动建目录)。
  • 对策 :把 json 目录改成你的相对路径(如 data/custom1/jsons),先建好输出目录 ,再运行。转换完把 txt 按规则摆进 labels/。

13.5 Windows 下 num_workers > 0 卡死或报错

  • 现象 :DataLoader 启动多个子进程时挂起,或 RuntimeError。
  • 原因 :Windows 的多进程启动方式和 Linux 不同,某些环境(尤其在 __main__ 外定义、或 IDE 直接跑)会出问题。
  • 对策 :把 --n_cpu 设成 0(主进程加载);或在 if __name__ == "__main__" 保护下运行。train.py 已有该保护,直接设 --n_cpu 0 最稳。

13.6 显存不够 / CUDA out of memory

  • 现象:训练启动就 OOM。
  • 原因 :batch_size 太大、图像尺寸太大(多尺度会随机到 512)、模型本身较重。
  • 对策 :减小 --batch_size(如 1 或 2)、必要时关多尺度(--multiscale_training 传 False 或改代码)、用更小的 --img_size。也可用 --gradient_accumulations 在「小 batch」下模拟「大 batch」。

13.7 tensorflow 没装导致训练起不来

  • 现象 :import tensorflow 失败。
  • 原因 :utils/logger.py 用的是 TensorFlow 2.x 的 tf.summary。
  • 对策 :pip install tensorflow;或把 Logger 换成 torch.utils.tensorboard.SummaryWriter(需装 tensorboard 并改 train.py 调用方式)。

13.8 迁移学习「加载权重错位」

  • 现象 :加载 darknet53.conv.74 后效果异常。
  • 原因 :darknet53.conv.74 只含骨干 ,不含检测头;代码里靠 cutoff=75 只加载前 75 层。如果你把它改个名字(不含 darknet53.conv.74 字样),cutoff 就失效,会尝试全量加载而错位。
  • 对策 :保留文件名里的 darknet53.conv.74 关键字符串;或显式修改 load_darknet_weights 里的 cutoff 逻辑。

13.9 推理框位置不对(画出来偏了 / 大小不对)

  • 现象 :detect.py 画的框位置明显偏移。
  • 原因 :忘了 rescale_boxes,或类名文件 --class_path 不是自己数据集的。
  • 对策 :确认 rescale_boxes(detections, opt.img_size, img.shape[:2]) 生效;推理参数指向 config/yolov3-custom.cfg + 训练好的 .pth + data/custom1/classes.names。

13.10 cfg 里 ignore_thresh=.7 却不生效

  • 现象 :调试 ignore_thresh 没反应。
  • 原因 :YOLOLayer.__init__ 里 self.ignore_thres = 0.5 硬编码 ,cfg 的 ignore_thresh 没被读取。
  • 对策 :想改忽略阈值就直接改 models.py 里那行;或改成从 module_def 读取。

十四、运行命令(只用相对路径)与结语

以下命令都在项目根目录下执行,路径全部是相对路径。

14.1 环境准备

bash 复制代码
# 建议用虚拟环境
python -m venv venv
# Windows
venv\Scripts\activate
# 安装依赖(按需)
pip install torch torchvision numpy pillow matplotlib tqdm terminaltables tensorflow

14.2 准备数据

bash 复制代码
# 0) 用 labelme 标注图片,导出 json 到 data/custom1/jsons/
# 1) 建好 json2yolo 的输出目录
mkdir data\txt
# 2) 修改 utils/json2yolo.py 里的 json 目录为相对路径后运行
python utils/json2yolo.py
# 3) 把生成的 txt 按 images->labels 的命名规则摆到 data/custom1/labels/

14.3 生成/确认模型配置

config/yolov3-custom.cfg 已经是 2 类的成品,可直接用。若要重新生成:

bash 复制代码
bash config/create_custom_model.sh 2

(生成的是 yolov3-custom.cfg;注意脚本用 >> 追加,运行前先确认目标文件为空。)

14.4 训练

bash 复制代码
python train.py --model_def config/yolov3-custom.cfg --data_config config/custom.data --pretrained_weights weights/darknet53.conv.74 --epochs 100 --batch_size 4 --n_cpu 0 --img_size 416

参数说明:

  • --model_def:自定义模型 cfg。
  • --data_config:自定义数据集 .data。
  • --pretrained_weights:骨干预训练权重,做迁移学习。
  • --epochs、--batch_size、--n_cpu、--img_size:按机器情况调整。

14.5 评估

bash 复制代码
python test.py --model_def config/yolov3-custom.cfg --data_config config/custom.data --weights_path checkpoints/yolov3_ckpt_10.pth --img_size 416

14.6 推理检测

bash 复制代码
python detect.py --image_folder data/samples --model_def config/yolov3-custom.cfg --weights_path checkpoints/yolov3_ckpt_10.pth --class_path data/custom1/classes.names --img_size 416

结果图保存在 output/ 目录。

14.7 查看训练曲线(可选)

bash 复制代码
tensorboard --logdir logs

14.8 整体流程(mermaid)

#mermaid-svg-VKZk1uhAhxHcLAdb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VKZk1uhAhxHcLAdb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VKZk1uhAhxHcLAdb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VKZk1uhAhxHcLAdb .error-icon{fill:#552222;}#mermaid-svg-VKZk1uhAhxHcLAdb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VKZk1uhAhxHcLAdb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VKZk1uhAhxHcLAdb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VKZk1uhAhxHcLAdb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VKZk1uhAhxHcLAdb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VKZk1uhAhxHcLAdb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VKZk1uhAhxHcLAdb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VKZk1uhAhxHcLAdb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VKZk1uhAhxHcLAdb .marker.cross{stroke:#333333;}#mermaid-svg-VKZk1uhAhxHcLAdb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VKZk1uhAhxHcLAdb p{margin:0;}#mermaid-svg-VKZk1uhAhxHcLAdb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VKZk1uhAhxHcLAdb .cluster-label text{fill:#333;}#mermaid-svg-VKZk1uhAhxHcLAdb .cluster-label span{color:#333;}#mermaid-svg-VKZk1uhAhxHcLAdb .cluster-label span p{background-color:transparent;}#mermaid-svg-VKZk1uhAhxHcLAdb .label text,#mermaid-svg-VKZk1uhAhxHcLAdb span{fill:#333;color:#333;}#mermaid-svg-VKZk1uhAhxHcLAdb .node rect,#mermaid-svg-VKZk1uhAhxHcLAdb .node circle,#mermaid-svg-VKZk1uhAhxHcLAdb .node ellipse,#mermaid-svg-VKZk1uhAhxHcLAdb .node polygon,#mermaid-svg-VKZk1uhAhxHcLAdb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VKZk1uhAhxHcLAdb .rough-node .label text,#mermaid-svg-VKZk1uhAhxHcLAdb .node .label text,#mermaid-svg-VKZk1uhAhxHcLAdb .image-shape .label,#mermaid-svg-VKZk1uhAhxHcLAdb .icon-shape .label{text-anchor:middle;}#mermaid-svg-VKZk1uhAhxHcLAdb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VKZk1uhAhxHcLAdb .rough-node .label,#mermaid-svg-VKZk1uhAhxHcLAdb .node .label,#mermaid-svg-VKZk1uhAhxHcLAdb .image-shape .label,#mermaid-svg-VKZk1uhAhxHcLAdb .icon-shape .label{text-align:center;}#mermaid-svg-VKZk1uhAhxHcLAdb .node.clickable{cursor:pointer;}#mermaid-svg-VKZk1uhAhxHcLAdb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VKZk1uhAhxHcLAdb .arrowheadPath{fill:#333333;}#mermaid-svg-VKZk1uhAhxHcLAdb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VKZk1uhAhxHcLAdb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VKZk1uhAhxHcLAdb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VKZk1uhAhxHcLAdb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VKZk1uhAhxHcLAdb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VKZk1uhAhxHcLAdb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VKZk1uhAhxHcLAdb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VKZk1uhAhxHcLAdb .cluster text{fill:#333;}#mermaid-svg-VKZk1uhAhxHcLAdb .cluster span{color:#333;}#mermaid-svg-VKZk1uhAhxHcLAdb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VKZk1uhAhxHcLAdb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VKZk1uhAhxHcLAdb rect.text{fill:none;stroke-width:0;}#mermaid-svg-VKZk1uhAhxHcLAdb .icon-shape,#mermaid-svg-VKZk1uhAhxHcLAdb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VKZk1uhAhxHcLAdb .icon-shape p,#mermaid-svg-VKZk1uhAhxHcLAdb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VKZk1uhAhxHcLAdb .icon-shape .label rect,#mermaid-svg-VKZk1uhAhxHcLAdb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VKZk1uhAhxHcLAdb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VKZk1uhAhxHcLAdb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VKZk1uhAhxHcLAdb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} labelme 标注 json
json2yolo.py 转 txt
images/labels + train.txt/valid.txt + classes.names
custom.data + yolov3-custom.cfg
train.py 训练
checkpoints/*.pth
test.py 评估 mAP
detect.py 推理画框

结语

这份代码的价值在于「麻雀虽小,五脏俱全」:网络构建、数据管道、先验框匹配、损失、NMS、mAP、权重加载,一个完整目标检测项目该有的环节全都在,而且逻辑集中在四个文件里,改起来、读起来都不费劲。

如果你能顺着本文的顺序,把每个函数都亲手跑一遍、打印中间张量的形状(代码里本来就保留了 print(x.shape)、print(ious.shape) 这类调试输出),你就会对「YOLOv3 到底怎么把一张图变成一个个框」建立起非常具体的直觉。之后再去看 YOLOv4/v5/v8、DETR 等新模型,也会轻松很多。

相关推荐
lisw051 小时前
“科学前沿极限突破计划”述评!
大数据·人工智能·学习
戴西软件1 小时前
企业如何搭建自己的AI智能体?戴西智能体管理与戴西SKILL管理实战解析
运维·jvm·人工智能·自动化·rpa
青蓝智慧马老师2 小时前
想学数据分析但零基础,正确顺序不是先学 Python
大数据·人工智能·算法
用户2991422196802 小时前
GitHub 热榜项目:日榜(2026-10-17)
人工智能·开源·github
罗小罗同学2 小时前
西交联合腾讯和剑桥团队发表轻量化医学智能体,无需重新训练模型即可大幅提升诊断准确率,同时还可以增强模型解释性
人工智能·深度学习·机器学习·医学图像处理·医工交叉·医学ai
jimmyleeee2 小时前
大模型安全之四十七:当AI拥有行动权——智能体系统开发与安全防御指南
人工智能·安全
2601_962966642 小时前
2027校招:应用统计学转制造业质量管理,SPC、MSA与数据分析怎么学
大数据·人工智能
刘一说2 小时前
D37-全参微调 vs LoRA 选型:什么时候该上全参,什么时候 LoRA 就够了
人工智能·科技
尘世中一位迷途小书童2 小时前
想自研大模型,你得先过哪些技术关
人工智能