引言:这是一个什么样的项目,读完你能学到什么
这是一个基于 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 的核心思想是「把检测问题转成网格上的回归问题」:
- 把输入图(例如
416 × 416)划分成S × S个网格(grid)。416 输入下,YOLOv3 会用到三种网格:13 × 13、26 × 26、52 × 52。 - 物体的中心点落在哪个格子里,就由哪个格子负责预测它。这解决了「谁来负责」的问题。
- 每个格子不只预测 1 个框,而是预测多个「先验框(anchor)」。YOLOv3 每个尺度上放 3 个 anchor,三种尺度一共 9 个 anchor。也就是每个格子输出
3 × (5 + classes)个数:5是tx, ty, tw, th, conf(置信度),classes是每个类别的概率。 - 三尺度(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 个)。绝大多数是「没有物体的背景框」,还有大量「同一个物体被重复框住」。所以需要一个筛选流程:
- 按置信度阈值过滤掉低分框;
- 用「置信度 × 类别概率」排序;
- 做 非极大值抑制(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。- 循环逐一转换。
使用提醒:
- 运行前先建好输出目录(如
data/txt/)。 - 这个脚本输出的路径规则比较「硬」,
'..\\data\\txt\\txt'前缀意味着它假设你在某个子目录里运行。建议直接把输出路径改成你实际要用的相对路径再做批量转换。 - 转完以后,还要把生成的 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元组是 PyTorchF.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。
- 先把前 4 列从
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 三件事:
- 分配责任:每个目标分给「形状最像的那个 anchor + 中心所在的格子」。
- 生成目标值:坐标用「格内偏移 + 对数尺寸比」,类别用 one-hot。
- 生成掩码 :
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_hrehape 成(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,保证索引稳定。
- conv/upsample/maxpool :直接
- 循环结束后
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/目录。
两个值得注意的细节:
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。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 等新模型,也会轻松很多。