【YOLOv3】 源码总体结构分析
概述
框架与代码结构分析
- train.py文件调用Backbone层以及Neck层中的模块
- Backbone层对应的是model.py和yolo.py文件
- Neck中的具实现存在于模型定义文件中
- Head部分的实现细节同样在模型定义文件中,但是train.py文件中负责训练的关键部分,也就是损失计算、梯度更新和参数优化
- 后处理阶段:detect.py
模块对应关系
Backbone
该模块的主要功能就是特则提取网络,主要负责提取多层次特征
解析backbone部分网络结构
- 对应Backbone模块
- 负责将输入图像(416×416×3)逐步下采样,生成多个特征层(52×52、26×26、13×13)
def parse_model(d, ch): # 将解析的网络模型结构作为输入,是字典形式,输入通道数(通常为3)
"""
主要功能:parse_model模块用来解析模型文件(从Model中传来的字典形式),并搭建网络结构。
在上面Model模块的__init__函数中调用
这个函数其实主要做的就是: 更新当前层的args(参数),计算c2(当前层的输出channel) =>
使用当前层的参数搭建当前层 =>
生成 layers + save
:params d: model_dict 模型文件 字典形式 yolov3.yaml中的网络结构元素 + ch
:params ch: 记录模型每一层的输出channel 初始ch=[3] 后面会删除
:return nn.Sequential(*layers): 网络的每一层的层结构
:return sorted(save): 把所有层结构中from不是-1的值记下 并排序 [4, 6, 10, 14, 17, 20, 23]
"""
# LOGGER.info(f"\n{'':>3}{'from':>18}{'n':>3}{'params':>10} {'module':<40}{'arguments':<30}")
print(f"\n{'':>3}{'from':>18}{'n':>3}{'params':>10} {'module':<40}{'arguments':<30}")
# 读取d字典中的anchors和parameters(nc、depth_multiple、width_multiple)
# nc(number of classes)数据集类别个数;
# depth_multiple,通过深度参数depth gain在搭建每一层的时候,实际深度 = 理论深度(每一层的参数n) * depth_multiple,起到一个动态调整模型深度的作用。
# width_multiple,在模型中间层的每一层的实际输出channel = 理论channel(每一层的参数c2) * width_multiple,起到一个动态调整模型宽度的作用。
anchors, nc, gd, gw = d['anchors'], d['nc'], d['depth_multiple'], d['width_multiple']
"""
如果anchors是一个列表,则计算锚点的数量na。
具体来说,取anchors列表的第一个元素的长度除以2,
因为每个锚点由两个值(宽度和高度)表示。如果anchors不是列表,则直接使用 anchors 的值。
"""
na = (len(anchors[0]) // 2) if isinstance(anchors, list) else anchors # na为每个检测头的anchor数量
no = na * (nc + 5) # 计算输出数量 no。输出数量等于锚点数量乘以(类别数nc加上5)。这里的5包括4个边界框坐标(x, y, w, h)和1个置信度分数。
# 开始搭建网络
# layers: 保存每一层的层结构
# save: 记录下所有层结构中from中不是-1的层结构序号
# c2: 保存当前层的输出channel
layers, save, c2 = [], [], ch[-1] # layers: 保存每一层的层结构,save: 记录下所有层结构中from中不是-1的层结构序号,c2: 保存当前层的输出channel
for i, (f, n, m, args) in enumerate(d['backbone'] + d['head']): # 遍历模型的backbone和head部分,获取from, number, module, args
m = eval(m) if isinstance(m, str) else m # 如果m是字符串,则使用eval函数将其转换为实际的模块类或函数,计算该模块的值。
for j, a in enumerate(args):
try:
# 如果 a 是一个字符串,则使用 eval(a) 计算其值,并将结果赋给 args[j]
# 如果 a 不是字符串,则直接将 a 赋给 args[j]。
args[j] = eval(a) if isinstance(a, str) else a
except NameError:
pass
# print("argshaha", args)
# 该部分借用yolov5算法的中方法,利用调整系数gd来改变对应模块的重复次数,以达到增大模型大小的目标
# 原本的yolov3是没有这个功能的,该版本的代码传承了Ultralytics公司的,yolov5就是该公司出品的
n = n_ = max(round(n * gd), 1) if n > 1 else n # depth gain
# if m in [Conv, GhostConv, Bottleneck, GhostBottleneck, SPP, SPPF, DWConv, MixConv2d, Focus, CrossConv,
# BottleneckCSP, C3, C3TR, C3SPP, C3Ghost]:
if m in [Conv, Bottleneck, SPP, MixConv2d, Focus, CrossConv]:
c1, c2 = ch[f], args[0] # 获取当前层输入通道数 c1 和输出通道数 c2。
if c2 != no: # if not output # 判断是否等于输出通道大小。
# make_divisible 函数的作用是将输入x调整为大于或等于x且能被divisor整除的最小整数。
# 它使用 math.ceil 函数来实现这一目的。
# 其中调整系数gw来改变对应模块的通道大小
# 原本的yolov3是没有这个功能的,该版本的代码传承了Ultralytics公司的,yolov5就是该公司出品的
c2 = make_divisible(c2 * gw, 8)
args = [c1, c2, *args[1:]] # 更新 args,将输入通道数 c1 和调整后的输出通道数 c2 作为新的参数列表的前两个元素。
elif m is nn.BatchNorm2d:
args = [ch[f]] # 仅将输入通道数 ch[f] 作为参数 args。
elif m is Concat:
c2 = sum(ch[x] for x in f) # 计算多个输入通道数 ch[x] 的总和,得到新的输出通道数 c2。
elif m is Detect:
args.append([ch[x] for x in f]) # 在参数 args 中附加包含输入通道数的列表 ch[x]。
if isinstance(args[1], int): # number of anchors
args[1] = [list(range(args[1] * 2))] * len(f) #如果 args[1] 是整数,则将其转换为包含适当数量的锚框数的列表。
elif m is Contract:
c2 = ch[f] * args[0] ** 2 # 根据输入通道数 ch[f] 和参数 args[0] 的平方,计算新的输出通道数 c2。
elif m is Expand:
c2 = ch[f] // args[0] ** 2 # 根据输入通道数 ch[f] 和参数 args[0] 的平方,计算新的输出通道数 c2
else:
c2 = ch[f] # 其他的情况,默认将当前输入通道数 ch[f] 作为输出通道数 c2
# 在Python中,前面的*符号用于解包参数列表。*args 允许你将一个参数列表传递给函数
# 而在函数内部可以将这个参数列表解包成单独的参数。
# 义了一个变量m_,其值取决于变量n的大小。如果n大于1,则创建一个包含n个m(*args)实例的nn.Sequential模块;
# 否则,直接创建一个 m(*args) 实例。具体来说,这段代码是在处理神经网络模块的堆叠和实例化。
m_ = nn.Sequential(*(m(*args) for _ in range(n))) if n > 1 else m(*args) # module
# 这行代码将模块m转换为字符串,并截取其类型字符串的中间部分(去掉前8个字符和最后2个字符),然后去掉__main__.前缀。
t = str(m)[8:-2].replace('__main__.', '') # module type
# 这行代码计算模块m_ 中所有参数的总数量。m_.parameters()
# 返回模块的参数迭代器,x.numel() 返回每个参数的元素数量,sum 计算所有参数的总数量。
np = sum(x.numel() for x in m_.parameters()) # number params
# 这行代码将索引i、'from'索引f、模块类型字符串t、参数数量np附加到模块m_上,方便后续使用。
m_.i, m_.f, m_.type, m_.np = i, f, t, np # attach index, 'from' index, type, number params
print(f'{i:>3}{str(f):>18}{n_:>3}{np:10.0f} {t:<40}{str(args):<30}') # print
# 将满足条件的元素添加到 save 列表中
# 将模块 m_ 添加到 layers 列表中。
save.extend(x % i for x in ([f] if isinstance(f, int) else f) if x != -1) # append to savelist
layers.append(m_)
# 初始化列表ch,并不断保存输出通道数到该列表中。
if i == 0:
ch = []
ch.append(c2)
return nn.Sequential(*layers), sorted(save)
Neck
前向推理脚本(detect.py)
总体逻辑
主要流程
- 初始化配置:解析命令行参数,设置设备、模型路径等
- 加载模型和参数:选择设备,加载模型权重,设置模型参数(如半精度)
- 加载数据:根据输入源(图片、视频、流等)加载数据
- 推理过程:对每一帧或图片进行前向传播,得到预测结果
- 后处理:应用非极大值抑制(NMS),绘制边界框,保存或展示结果
- 结果输出:保存检测结果,打印推理速度等信息
模块分析
源码中将整个推理过程都统一放在了一个run函数中,仅挑选重要的组成学习
初始化,参数分析
def run(weights=ROOT / 'yolov3.pt', # 模型文件路径,默认加载 YOLOv3 模型('yolov3.pt')
source=ROOT / 'data/images', # 数据源,可以是文件路径、目录路径、URL、通配符(如 '*.jpg'),或者 '0' 表示使用摄像头
imgsz=640, # 推理时输入图像的大小,单位为像素,默认为 640(图像会被缩放到此尺寸进行处理)
conf_thres=0.25, # 置信度阈值,只有置信度大于该值的检测结果才会被保留
iou_thres=0.45, # 非极大值抑制(NMS)中的 IOU 阈值,IOU 超过此值的重叠框会被抑制
max_det=1000, # 每张图像的最大检测框数,默认最大为 1000 个
device='', # 设备设置,可以是 '0'、'0,1' 等 GPU 设备索引,或 'cpu' 表示使用 CPU 进行推理
view_img=False, # 是否显示检测结果图像,默认为 False,即不显示
save_txt=False, # 是否将检测结果保存为文本文件 (*.txt),默认为 False
save_conf=False, # 是否在保存的文本文件中包含每个检测框的置信度,默认为 False
save_crop=False, # 是否保存裁剪后的预测框图像,默认为 False
nosave=False, # 是否不保存图像/视频,默认为 False,保存处理后的图像
classes=None, # 按类别过滤,指定要检测的类别,默认情况下检测所有类别。例如,传入 [0] 只检测类别 0 的物体
agnostic_nms=False, # 是否启用类别无关的非极大值抑制(NMS),默认 False
augment=False, # 是否进行增强推理,增强推理使用多种图像变换来提高模型鲁棒性,默认为 False
visualize=False, # 是否可视化特征图,默认为 False
update=False, # 是否自动更新模型权重,默认为 False
project=ROOT / 'runs/detect', # 结果保存的根目录路径,默认保存在 'runs/detect' 文件夹中
name='exp', # 结果保存的项目名称,默认是 'exp',可以根据需要修改
exist_ok=False, # 是否允许已存在的文件夹名称,如果为 False,则会递增文件夹名(例如 'exp1', 'exp2' 等)
line_thickness=3, # 边界框的线条厚度(像素),默认为 3
hide_labels=False, # 是否隐藏标签文字,默认为 False,即显示标签
hide_conf=False, # 是否隐藏置信度值,默认为 False,即显示置信度
half=False, # 是否启用 FP16 半精度推理,以加速计算和节省内存,默认为 False
dnn=False, # 是否使用 OpenCV DNN 模块进行 ONNX 推理,默认为 False,使用 PyTorch 推理
):
初始化配置
# ===================================== 1、初始化一些配置 =====================================
# 将 source 转换为字符串类型,确保后续操作中 source 是一个路径字符串
source = str(source)
# 根据 'nosave' 和 'source' 是否是文本文件来决定是否保存推理图像
# 'save_img' 表示是否保存推理后的图像,只有在不为 'nosave' 且 source 不是文本文件时才保存
save_img = not nosave and not source.endswith('.txt') # 是否保存推理图像
# 检查 source 是否为文件格式(图像或视频),通过文件扩展名判断
# 'is_file' 为 True 表示 source 是有效的图像或视频文件
is_file = Path(source).suffix[1:] in (IMG_FORMATS + VID_FORMATS) # 检查是否为文件
# 检查 source 是否为 URL,如果 URL 以 'rtsp://'、'rtmp://'、'http://' 或 'https://' 开头,则为 URL
# 'is_url' 为 True 表示 source 是一个 URL 地址
is_url = source.lower().startswith(('rtsp://', 'rtmp://', 'http://', 'https://')) # 检查是否为 URL
# 检查 source 是否为摄像头输入
# 如果 source 是数字(如 '0' 或 '1',表示摄像头索引)或以 '.txt' 结尾(表示文件列表),
# 或者是 URL 并且不是一个文件,则认为是摄像头输入
webcam = source.isnumeric() or source.endswith('.txt') or (is_url and not is_file) # 检查是否为摄像头输入
# 如果 source 是 URL 且同时是一个文件,执行文件下载操作
# 使用 check_file 函数来检查并下载文件
if is_url and is_file:
source = check_file(source) # 下载文件
# 创建保存结果的目录路径
# 'save_dir' 表示存储推理结果的目录,使用 'increment_path' 函数来确保目录名称的唯一性
# 如果已存在相同目录,则会递增名称(例如:'exp', 'exp1', 'exp2' 等)
save_dir = increment_path(Path(project) / name, exist_ok=exist_ok) # 递增运行目录
# 创建存储文件的目录
# 根据是否保存文本文件('save_txt')来选择不同的子目录:
# - 如果保存文本文件,创建 'labels' 子目录
# - 如果不保存文本文件,直接在 'save_dir' 目录下创建保存文件夹
(save_dir / 'labels' if save_txt else save_dir).mkdir(parents=True, exist_ok=True) # 创建目录
加载模块调整参数(训练模型准备阶段)
# ===================================== 2、载入模型和模型参数并调整模型 =====================================
# 选择推理设备,通过 'select_device' 函数选择使用的设备(如 CPU 或 GPU)
device = select_device(device) # 选择设备
# 加载 YOLOv3 模型,'DetectMultiBackend' 用于支持不同后端(PyTorch、ONNX、TensorRT 等)
# 'weights' 是模型的路径,'device' 是设备(如 CPU 或 GPU),'dnn' 表示是否使用 OpenCV DNN 后端
model = DetectMultiBackend(weights, device=device, dnn=dnn) # 加载模型
# 获取模型的一些重要属性:
# - stride: 模型的步幅(影响推理时的图像尺寸选择)
# - names: 模型支持的类别名称(物体检测的类别)
# - pt: 是否是 PyTorch 模型
# - jit: 是否是 TorchScript 模型
# - onnx: 是否是 ONNX 模型
stride, names, pt, jit, onnx = model.stride, model.names, model.pt, model.jit, model.onnx # 获取模型属性
# 检查输入图像的大小,确保它与模型的步幅(stride)匹配
# 'check_img_size' 会根据模型的步幅调整输入图像大小,确保图像尺寸是步幅的整数倍
imgsz = check_img_size(imgsz, s=stride) # 检查图像大小
# 设置半精度推理,'half' 表示是否启用 FP16(半精度浮点数)推理
# 如果设备是 GPU 且模型是 PyTorch 模型,则启用半精度推理
half &= pt and device.type != 'cpu' # 半精度仅支持在 CUDA 上的 PyTorch
# 如果是 PyTorch 模型,根据 'half' 参数设置模型的精度:
# 如果 'half' 为 True,则将模型转换为 FP16 精度;否则,使用 FP32(默认精度)
if pt:
model.model.half() if half else model.model.float() # 使用半精度或单精度浮点数
加载推理数据
- 根据输入源(摄像头或文件夹路径)选择不同的数据加载器
- 如果使用 GPU 且为 PyTorch 模型,执行模型预热,以便在第一次推理时减少延迟
- 初始化用于视频流输出的路径和写入器
- 准备数据和性能监控变量,开始推理过程
# ===================================== 3、加载推理数据 =====================================
# 设置数据加载器
if webcam:
# 如果输入是摄像头视频流:
# 使用 check_imshow() 检查是否支持显示图像
# 该函数通常会检查显示窗口是否可以成功打开,适用于显示推理结果
view_img = check_imshow() # 检查是否可以显示图像
# 设置 cudnn.benchmark 为 True 可以加速恒定图像大小的推理
# cudnn.benchmark 通常用于 GPU 上的卷积操作,如果图像尺寸恒定,可以加速计算
cudnn.benchmark = True # 设置为 True 可以加速恒定图像大小的推理
# 使用 LoadStreams 加载视频流数据(如摄像头流、RTSP、RTMP 等)
# 'source' 是输入的视频流源,'img_size' 为输入图像的大小,'stride' 为模型的步幅,'auto' 取决于是否为 PyTorch 模型
dataset = LoadStreams(source, img_size=imgsz, stride=stride, auto=pt and not jit) # 加载流数据
# 批量大小为数据流的长度,即摄像头输入的流数
bs = len(dataset) # 批量大小
else:
# 如果不是摄像头输入,加载本地文件目录中的图像或视频文件
# 使用 LoadImages 加载文件夹中的图像或视频文件
# 'source' 为文件夹路径,'img_size' 为输入图像的大小,'stride' 为模型的步幅,'auto' 取决于是否为 PyTorch 模型
dataset = LoadImages(source, img_size=imgsz, stride=stride, auto=pt and not jit) # 加载图像数据
# 批量大小设为 1,因为一般是逐张加载图像
bs = 1 # batch_size
# 初始化视频路径和写入器
# 如果输入是视频流或摄像头输入,我们需要为每个流初始化一个视频路径和视频写入器
vid_path, vid_writer = [None] * bs, [None] * bs # 视频路径和写入器初始化
# 如果使用的是 PyTorch 模型且设备类型不是 'cpu',执行预热步骤:
if pt and device.type != 'cpu':
# 使用全零张量作为输入进行预热
# 这里通过创建一个形状为 (1, 3, *imgsz) 的全零张量,并将其移动到指定的设备上('device')进行模型的预热
# 这样做是为了在推理前提前加载模型并优化性能
# 'type_as' 确保输入张量的数据类型与模型参数的类型匹配
model(torch.zeros(1, 3, *imgsz).to(device).type_as(next(model.model.parameters()))) # warmup
# 初始化时间和数据计数变量
# dt 存储不同阶段的时间(推理时间等),seen 用于记录处理过的图像数量
dt, seen = [0.0, 0.0, 0.0], 0 # 初始化变量 dt 和 seen
推理过程(重点)
- 遍历数据集中的每张图像或视频流
- 对每张图像进行处理(格式转换、归一化、维度扩展等)
- 使用模型进行推理,并记录每个阶段的时间(数据处理、推理、NMS)
- 对推理结果进行非极大值抑制,去除冗余的检测框
- (可选)如果启用可视化,保存图像处理结果
# ===================================== 5、正式推理 =====================================
# 遍历数据集中的每张图像或每个视频流
for path, im, im0s, vid_cap, s in dataset:
# path: 当前图像或视频文件的路径
# im: 经 resize 和 padding 后的图像(用于推理的标准尺寸图像)
# im0s: 原始尺寸的图像(用于保存或展示原图)
# vid_cap: 如果是读取图片,则为 None;如果是读取视频,则为视频源对象
# 5.1、对每张图片或每个视频进行前向推理
t1 = time_sync() # 记录推理开始时间
# 将图像数据转为 PyTorch tensor,并移动到指定的设备(CPU 或 GPU)
im = torch.from_numpy(im).to(device) # 5.2、处理每一张图片/视频的格式
# 如果需要半精度推理(FP16),将图像数据转换为半精度;否则使用默认的 FP32 精度
im = im.half() if half else im.float() # 半精度训练 uint8 转换为 fp16 或 fp32
# 归一化处理,将图像像素值从 0-255 转换为 0.0 - 1.0
im /= 255 # 归一化 0 - 255 到 0.0 - 1.0
# 如果输入图像是三维的(表示 RGB 图像),则在前面添加一个维度,使其变成四维,代表一个 batch
# 这里需要确保输入图像是 [batch_size, channel, width, height] 这样的四维数据格式
if len(im.shape) == 3:
im = im[None] # expand for batch dim,添加 batch 维度,表示 batch_size=1
t2 = time_sync() # 获取当前时间,并进行时间同步
dt[0] += t2 - t1 # 累加时间差到 dt[0] 中,用于记录数据预处理的时间
# 如果需要可视化推理结果,则创建一个保存路径,并为其创建目录
visualize = increment_path(save_dir / Path(path).stem, mkdir=True) if visualize else False
# 使用模型进行推理预测,'augment' 参数表示是否使用数据增强
# 'visualize' 参数表示是否进行可视化(保存特征图等)
pred = model(im, augment=augment, visualize=visualize)
# 获取当前时间并记录推理过程时间
t3 = time_sync()
dt[1] += t3 - t2 # 累加时间差到 dt[1] 中,用于记录推理的时间
# 使用非极大值抑制(NMS)来处理预测结果,减少冗余的框
# 'conf_thres' 为置信度阈值,'iou_thres' 为 IOU 阈值,'classes' 为要检测的类别,'agnostic_nms' 表示是否类别无关
# 'max_det' 控制每张图像检测的最大框数
pred = non_max_suppression(pred, conf_thres, iou_thres, classes, agnostic_nms, max_det=max_det)
# 累加 NMS 阶段的时间差到 dt[2] 中
dt[2] += time_sync() - t3
后处理与数据保存
- 检测框从标准尺寸映射回原图
- 可选的保存预测框到文件
- 将预测结果绘制到图像上并保存
- 对视频流的处理,确保检测后的视频能够保存下来
# ===================================== 6、保存或打印预测信息 =====================================
# 遍历每张图像的预测结果
for i, det in enumerate(pred): # per image
seen += 1 # 统计处理过的图像数
# 处理 webcam 输入时的批量(batch_size >= 1)
if webcam:
p, im0, frame = path[i], im0s[i].copy(), dataset.count
s += f'{i}: ' # 记录图像索引
else:
# 处理非摄像头输入(如文件夹中的单张图像或视频)
p, im0, frame = path, im0s.copy(), getattr(dataset, 'frame', 0)
# 将路径转为 Path 类型,方便操作
p = Path(p) # to Path
# 保存图片的路径(生成新的文件名)
save_path = str(save_dir / p.name) # im.jpg
# 保存预测框坐标的 txt 文件路径
txt_path = str(save_dir / 'labels' / p.stem) + ('' if dataset.mode == 'image' else f'_{frame}') # im.txt
# 打印图片的尺寸信息(宽度和高度)
s += '%gx%g ' % im.shape[2:] # 输出图像形状 (w, h)
# 归一化增益,用于将检测框从标准化尺寸(640x640)映射回原图尺寸
gn = torch.tensor(im0.shape)[[1, 0, 1, 0]] # normalization gain whwh gain gn = [w, h, w, h]
# 如果需要保存裁剪图像,将裁剪后的图像赋值给 imc(否则仍使用原图)
imc = im0.copy() if save_crop else im0 # imc: for save_crop
# 初始化 Annotator 类用于在图像上绘制边框
annotator = Annotator(im0, line_width=line_thickness, example=str(names))
# 如果检测到目标物体
if len(det):
# 将检测框从标准化大小(640x640)还原回原图大小
det[:, :4] = scale_coords(im.shape[2:], det[:, :4], im0.shape).round()
# 打印检测到的物体类别及数量
for c in det[:, -1].unique(): # 遍历所有检测到的类别
n = (det[:, -1] == c).sum() # 每个类别的检测数量
s += f"{n} {names[int(c)]}{'s' * (n > 1)}, " # 输出类别名称和数量
# 保存预测结果
for *xyxy, conf, cls in reversed(det):
# 如果需要保存预测框坐标到文件(txt 文件)
if save_txt:
# 将预测框坐标从 [xyxy] 转换为 [xywh],并归一化
xywh = (xyxy2xywh(torch.tensor(xyxy).view(1, 4)) / gn).view(-1).tolist() # normalized xywh
# 生成保存文件的内容
line = (cls, *xywh, conf) if save_conf else (cls, *xywh) # 包含或不包含置信度
# 将检测框写入到 .txt 文件
with open(txt_path + '.txt', 'a') as f:
f.write(('%g ' * len(line)).rstrip() % line + '\n')
# 如果需要保存图像、裁剪图像或查看图像(view_img),则在图像上绘制边框
if save_img or save_crop or view_img:
c = int(cls) # 获取类别的整数表示
# 根据是否隐藏标签/置信度来决定绘制标签的内容
label = None if hide_labels else (names[c] if hide_conf else f'{names[c]} {conf:.2f}')
# 在图像上绘制预测框和标签
annotator.box_label(xyxy, label, color=colors(c, True))
# 如果需要保存裁剪后的图像,则裁剪并保存
if save_crop:
save_one_box(xyxy, imc, file=save_dir / 'crops' / names[c] / f'{p.stem}.jpg', BGR=True)
# 打印推理时间
print(f'{s}Done. ({t3 - t2:.3f}s)')
# 如果需要显示推理结果(显示包含检测框的图像)
im0 = annotator.result() # 获取绘制框后的图像
if view_img:
# 使用 OpenCV 显示图像
cv2.imshow(str(p), im0)
cv2.waitKey(1) # 等待 1 毫秒刷新图像
# 如果需要保存检测后的图像或视频
if save_img:
if dataset.mode == 'image': # 如果是图像模式
# 保存图像文件
cv2.imwrite(save_path, im0) # 保存单张图像
else: # 如果是视频或实时流模式
if vid_path[i] != save_path: # 如果保存路径与当前视频路径不同(表示新的视频)
vid_path[i] = save_path
# 释放之前的视频写入器(如果存在)
if isinstance(vid_writer[i], cv2.VideoWriter):
vid_writer[i].release()
if vid_cap: # 如果是视频文件
fps = vid_cap.get(cv2.CAP_PROP_FPS) # 获取视频的帧率
w = int(vid_cap.get(cv2.CAP_PROP_FRAME_WIDTH)) # 获取视频帧宽度
h = int(vid_cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) # 获取视频帧高度
else: # 如果是实时流
fps, w, h = 30, im0.shape[1], im0.shape[0] # 设置默认的帧率和图像尺寸
save_path += '.mp4' # 添加文件扩展名
# 创建新的视频写入器
vid_writer[i] = cv2.VideoWriter(save_path, cv2.VideoWriter_fourcc(*'mp4v'), fps, (w, h))
# 将当前帧写入视频
vid_writer[i].write(im0)
解析命令行参数(训练中参数可以在此处指定)
def parse_opt():
parser = argparse.ArgumentParser()
parser.add_argument('--weights', nargs='+', type=str, default=ROOT / 'weight/yolov3.pt', help='model path(s)') # weights: 模型的权重地址 默认 weights/best.pt
parser.add_argument('--source', type=str, default=ROOT / 'data/images', help='file/dir/URL/glob, 0 for webcam') # source: 测试数据文件(图片或视频)的保存路径 默认data/images
parser.add_argument('--imgsz', '--img', '--img-size', nargs='+', type=int, default=[416], help='inference size h,w') # imgsz: 网络输入图片的大小 默认640
parser.add_argument('--conf-thres', type=float, default=0.6, help='confidence threshold') # conf-thres: object置信度阈值 默认0.25
parser.add_argument('--iou-thres', type=float, default=0.5, help='NMS IoU threshold') # iou-thres: 做nms的iou阈值 默认0.45
parser.add_argument('--max-det', type=int, default=1000, help='maximum detections per image') # max-det: 每张图片最大的目标个数 默认1000
parser.add_argument('--device', default='', help='cuda device, i.e. 0 or 0,1,2,3 or cpu') # device: 设置代码执行的设备 cuda device, i.e. 0 or 0,1,2,3 or cpu
parser.add_argument('--view-img', action='store_true', default=True, help='show results') # view-img: 是否展示预测之后的图片或视频 默认False
parser.add_argument('--save-txt', action='store_true', default=True, help='save results to *.txt') # save-txt: 是否将预测的框坐标以txt文件格式保存 默认False 会在runs/detect/expn/labels下生成每张图片预测的txt文件
parser.add_argument('--save-conf', action='store_true', default=True, help='save confidences in --save-txt labels') # save-conf: 是否保存预测每个目标的置信度到预测tx文件中 默认False
parser.add_argument('--save-crop', action='store_true', default=True, help='save cropped prediction boxes') # save-crop: 是否需要将预测到的目标从原图中扣出来 剪切好 并保存 会在runs/detect/expn下生成crops文件,将剪切的图片保存在里面 默认False
parser.add_argument('--nosave', action='store_true', help='do not save images/vidruns/train/exp/weights/best.pteos') # nosave: 是否不要保存预测后的图片 默认False 就是默认要保存预测后的图片
parser.add_argument('--classes', nargs='+', type=int, help='filter by class: --classes 0, or --classes 0 2 3') # classes: 在nms中是否是只保留某些特定的类 默认是None 就是所有类只要满足条件都可以保留, default=[0,6,1,8,9, 7]
parser.add_argument('--agnostic-nms', action='store_true', help='class-agnostic NMS') # agnostic-nms: 进行nms是否也除去不同类别之间的框 默认False
parser.add_argument('--augment', action='store_true', help='augmented inference') # 是否使用数据增强进行推理,默认为False
parser.add_argument('--visualize', action='store_true', help='visualize features') # -visualize:是否可视化特征图,默认为 False
parser.add_argument('--update', action='store_true', help='update all models') # -update: 如果为True,则对所有模型进行strip_optimizer操作,去除pt文件中的优化器等信息,默认为False
parser.add_argument('--project', default=ROOT / 'runs/detect', help='save results to project/name') # project: 当前测试结果放在哪个主文件夹下 默认runs/detect
parser.add_argument('--name', default='exp', help='save results to project/name') # name: 当前测试结果放在run/detect下的文件名 默认是exp
parser.add_argument('--exist-ok', action='store_true', default=False, help='existing project/name ok, do not increment') # -exist-ok: 是否覆盖已有结果,默认为 False
parser.add_argument('--line-thickness', default=3, type=int, help='bounding box thickness (pixels)') # -line-thickness:画 bounding box 时的线条宽度,默认为 3
parser.add_argument('--hide-labels', default=False, action='store_true', help='hide labels') # -hide-labels:是否隐藏标签信息,默认为 False
parser.add_argument('--hide-conf', default=False, action='store_true', help='hide confidences') # -hide-conf:是否隐藏置信度信息,默认为 False
parser.add_argument('--half', action='store_true', help='use FP16 half-precision inference') # half: 是否使用半精度 Float16 推理 可以缩短推理时间 但是默认是False
parser.add_argument('--dnn', action='store_true', help='use OpenCV DNN for ONNX inference') # -dnn:是否使用 OpenCV DNN 进行 ONNX 推理,默认为 False
opt = parser.parse_args() # 解析命令行参数,并将结果存储在opt对象中
opt.imgsz *= 2 if len(opt.imgsz) == 1 else 1 # 如果imgsz参数的长度为1,则将其值乘以2;否则保持不变
print_args(FILE.stem, opt) # 打印解析后的参数,FILE.stem是文件的名称(不含扩展名)
return opt
训练脚本(train.py)
总体逻辑
- 初始化
- 解析命令行参数(如模型配置、数据集路径、超参数等)
- 设置训练设备(CPU 或 GPU)
- 初始化日志记录器和可视化工具(如 WandB)
- 加载和验证数据集配置
- 创建训练和验证数据加载器
- 模型加载与配置
- 加载预训练权重(如果提供)
- 根据配置文件初始化模型架构
- 冻结指定层(如果需要)
- 优化器与学习率调度器设置
- 配置优化器(如 SGD 或 Adam)
- 设置学习率调度策略(如 One Cycle 或线性调度)
- 训练循环(逐步遍历每个epoch)
- 训练模式下进行前向传播、损失计算、反向传播和参数更新
- 可选的多尺度训练和图像权重调整
- 在特定条件下进行验证
- 记录和保存训练状态(如最佳模型、最新模型)
- 保存最终结果
模块分析
参数初始化
# 将各种超参数从opt(一个包含各种配置参数的对象)中提取出来
save_dir, epochs, batch_size, weights, single_cls, evolve, data, cfg, resume, noval, nosave, workers, freeze = \
Path(opt.save_dir), opt.epochs, opt.batch_size, opt.weights, opt.single_cls, opt.evolve, opt.data, opt.cfg, \
opt.resume, opt.noval, opt.nosave, opt.workers, opt.freeze
# 创建保存路径相关的目录
w = save_dir / 'weights' # 设置保存模型权重的路径,例如:runs/train/exp18/weights
(w.parent if evolve else w).mkdir(parents=True, exist_ok=True) # 如果启用evolve(超参进化),则保存路径为父目录,否则直接保存到w路径
# 如果目录不存在,则创建目录;parents=True表示会创建多级目录,exist_ok=True表示如果目录已存在则不报错
# 设置模型权重文件的路径
last, best = w / 'last.pt', w / 'best.pt' # 最后训练的权重和最佳权重保存文件的路径
# 加载并输出超参数信息
# 判断hyp(超参数配置)是否为字符串类型(通常是一个文件路径)
if isinstance(hyp, str):
with open(hyp, errors='ignore') as f: # 打开文件并忽略可能的错误
hyp = yaml.safe_load(f) # 解析YAML格式的超参数配置文件
# 输出日志,记录超参数信息,LOGGER是日志对象
LOGGER.info(colorstr('hyperparameters: ') + ', '.join(f'{k}={v}' for k, v in hyp.items()))
# 这里将超参数信息格式化为 "key=value" 的形式并输出日志
# 将超参数hyp保存为YAML文件到指定目录
with open(save_dir / 'hyp.yaml', 'w') as f: # 保存超参数到hyp.yaml文件
yaml.safe_dump(hyp, f, sort_keys=False) # 将超参数写入文件,并保持原有顺序
# 将opt配置(包含训练配置、路径等)保存为YAML文件
with open(save_dir / 'opt.yaml', 'w') as f: # 保存训练配置到opt.yaml文件
yaml.safe_dump(vars(opt), f, sort_keys=False) # 将opt对象中的所有属性保存为YAML格式
# 变量data_dict初始化为空(可能会在后续的代码中进行填充)
data_dict = None
日志器初始化
if RANK in [-1, 0]: # 仅在主要进程中初始化日志记录器
loggers = Loggers(save_dir, weights, opt, hyp, LOGGER) # 创建日志记录器实例
if loggers.wandb: # 如果使用 wandb 进行日志记录
data_dict = loggers.wandb.data_dict
if resume: # 如果是恢复训练
weights, epochs, hyp = opt.weights, opt.epochs, opt.hyp # 从 opt 中获取权重、epochs 和超参数
# 注册回调函数
for k in methods(loggers): # 获取 loggers 的方法
callbacks.register_action(k, callback=getattr(loggers, k)) # 注册每个方法为回调函数
数据集检查与加载
- 检查和加载数据集配置
- 确定训练和验证数据的路径
- 设置类别数量和类别名称
- 验证配置的一致性
# 配置
plots = not evolve # 创建绘图
cuda = device.type != 'cpu' # 检查是否使用 GPU
init_seeds(1 + RANK) # 初始化随机种子
with torch_distributed_zero_first(LOCAL_RANK): # 在分布式训练中确保只有一个进程执行
data_dict = data_dict or check_dataset(data) # 检查数据集,如果为 None 则验证数据集
train_path, val_path = data_dict['train'], data_dict['val'] # 获取训练和验证路径
nc = 1 if single_cls else int(data_dict['nc']) # 类别数量
names = ['item'] if single_cls and len(data_dict['names']) != 1 else data_dict['names'] # 类别名称
# 检查类别名称与类别数量是否匹配
assert len(names) == nc, f'{len(names)} names found for nc={nc} dataset in {data}'
is_coco = isinstance(val_path, str) and val_path.endswith('coco/val2017.txt') # 检查是否为 COCO 数据集
加载与配置模型
- 检查并加载预训练权重(如果提供)
- 根据配置文件初始化模型架构
- 处理模型状态字典的加载,包括排除某些键(如锚框)
# 检查权重文件的后缀名是否为.pt
check_suffix(weights, '.pt') # 如果权重文件的后缀不是.pt,抛出错误
# 判断权重文件是否为预训练模型文件(即后缀名为.pt)
pretrained = weights.endswith('.pt') # 如果权重文件以'.pt'结尾,则认为是预训练模型
# 如果是预训练模型(pretrained为True),执行以下操作
if pretrained:
# 在分布式训练中,确保只有一个进程执行文件下载操作
with torch_distributed_zero_first(LOCAL_RANK): # LOCAL_RANK是当前进程的分布式rank(编号)
weights = attempt_download(weights) # 如果本地不存在指定的权重文件,则下载
# 加载预训练模型的检查点(checkpoint)
ckpt = torch.load(weights, map_location=device) # 使用指定的设备(device)加载权重文件
# 创建模型,配置模型的结构。可以通过配置文件(cfg)或检查点文件中的模型配置来创建
# 如果cfg存在,则使用cfg,否则使用检查点文件ckpt中的模型配置(ckpt['model'].yaml)
model = Model(cfg or ckpt['model'].yaml, ch=3, nc=nc, anchors=hyp.get('anchors')).to(device)
# 定义需要排除的键,通常是“anchor”键,防止因模型配置不匹配而导致加载错误
exclude = ['anchor'] if (cfg or hyp.get('anchors')) and not resume else []
# 获取检查点的模型状态字典,并将其转换为FP32(浮点32位)格式
csd = ckpt['model'].float().state_dict() # 将模型参数的类型转换为浮点数32位
# 通过intersect_dicts函数取模型状态字典(ckpt)与当前模型状态字典(model)之间的交集
csd = intersect_dicts(csd, model.state_dict(), exclude=exclude) # 只加载匹配的部分
# 加载模型状态字典,strict=False表示允许部分参数不匹配
model.load_state_dict(csd, strict=False)
# 记录加载了多少项模型参数
LOGGER.info(f'从 {weights} 转移了 {len(csd)}/{len(model.state_dict())} 项') # 输出日志,显示从权重文件加载的参数数量和总参数数量
else:
# 如果不是预训练模型(即weights不是.pt文件),直接创建一个新的模型
model = Model(cfg, ch=3, nc=nc, anchors=hyp.get('anchors')).to(device) # 创建一个新的模型,并将其送到指定设备上(GPU/CPU)
冻结指定层
冻结模型中指定的层,使其在训练过程中不被更新(一般适用于微调预训练模型)
# 冻结层
freeze = [f'model.{x}.' for x in range(freeze)] # 要冻结的层
for k, v in model.named_parameters():
v.requires_grad = True # 允许所有层进行训练
if any(x in k for x in freeze): # 如果当前参数名在冻结列表中
LOGGER.info(f'冻结 {k}') # 记录冻结的层
v.requires_grad = False # 取消该层的梯度计算
图像大小与批量大小设置
- 确保输入图像大小与模型步幅(stride)兼容
- 根据模型和图像大小自动调整批量大小(如果未指定)
# Image size
gs = max(int(model.stride.max()), 32) # 网格大小(最大步幅)
imgsz = check_img_size(opt.imgsz, gs, floor=gs * 2) # 验证图像大小是 gs 的倍数
# Batch size
if RANK == -1 and batch_size == -1: # 仅在单 GPU 下,估计最佳批量大小
batch_size = check_train_batch_size(model, imgsz) # 检查并确定最佳批量大小
优化器与学习率调度器设置
- 配置优化器参数组:
g0
:BatchNorm 层的权重,不进行权重衰减g1
:其他权重,进行权重衰减g2
:偏置参数,不进行权重衰减
- 选择优化器类型(SGD 或 Adam)
- 根据批量大小调整权重衰减
# Optimizer部分的代码开始
nbs = 64 # 名义批量大小(即理论上的批量大小),用于计算和调整权重衰减
# 计算累积梯度的步数,通常在小批量训练时使用。累积梯度的目的是在多次小批量训练后再进行一次优化更新。
accumulate = max(round(nbs / batch_size), 1) # 计算累积次数,即每次优化前累积的损失步数
# 根据批量大小和累积的步数调整权重衰减(L2正则化)。大批量时,权重衰减应该适当增大
hyp['weight_decay'] *= batch_size * accumulate / nbs # 按照批量大小的变化调整权重衰减
LOGGER.info(f"Scaled weight_decay = {hyp['weight_decay']}") # 输出调整后的权重衰减值
# 初始化不同的优化器参数组
g0, g1, g2 = [], [], [] # g0用于存储不带衰减的权重,g1用于存储带衰减的权重,g2用于存储偏置
# 遍历模型中的每一个模块,并根据模块的属性将其加入相应的参数组
for v in model.modules(): # 遍历模型的每一个子模块
if hasattr(v, 'bias') and isinstance(v.bias, nn.Parameter): # 检查模块是否有偏置项(bias)
g2.append(v.bias) # 将偏置项添加到g2组中,偏置一般不进行L2正则化
if isinstance(v, nn.BatchNorm2d): # 检查模块是否是BatchNorm层
g0.append(v.weight) # BatchNorm层的权重(不进行权重衰减),添加到g0组
elif hasattr(v, 'weight') and isinstance(v.weight, nn.Parameter): # 检查模块是否有权重项
g1.append(v.weight) # 普通权重(会进行权重衰减),添加到g1组
# 根据选择的优化器类型初始化优化器
if opt.adam: # 如果选用了Adam优化器
optimizer = Adam(g0, lr=hyp['lr0'], betas=(hyp['momentum'], 0.999)) # 使用Adam优化器,调整beta1为动量
else: # 否则使用SGD优化器
optimizer = SGD(g0, lr=hyp['lr0'], momentum=hyp['momentum'], nesterov=True) # 使用SGD优化器,设置nesterov动量
# 为优化器添加新的参数组
# 为g1(带衰减的权重)添加参数组,并设置权重衰减
optimizer.add_param_group({'params': g1, 'weight_decay': hyp['weight_decay']})
# 为g2(偏置)添加参数组,偏置通常不使用权重衰减
optimizer.add_param_group({'params': g2})
# 输出优化器的类型及其参数组的数量和组成信息
LOGGER.info(f"{colorstr('optimizer:')} {type(optimizer).__name__} with parameter groups "
f"{len(g0)} weight, {len(g1)} weight (no decay), {len(g2)} bias")
# 删除中间变量g0, g1, g2,以节省内存
del g0, g1, g2
学习率调度器
# 学习率调度器
if opt.linear_lr:
lf = lambda x: (1 - x / (epochs - 1)) * (1.0 - hyp['lrf']) + hyp['lrf'] # 线性学习率调整
else:
lf = one_cycle(1, hyp['lrf'], epochs) # 余弦学习率调整,从 1 到 hyp['lrf']
scheduler = lr_scheduler.LambdaLR(optimizer, lr_lambda=lf) # 创建学习率调度器
# plot_lr_scheduler(optimizer, scheduler, epochs) # 可选:绘制学习率调度曲线
模型指数移动平均
使用 EMA 技术对模型参数进行平滑,以提高模型的泛化能力和稳定性
# 单卡训练: 使用EMA(指数移动平均)对模型的参数做平均
ema = ModelEMA(model) if RANK in [-1, 0] else None
加载预训练检查点
- 如果加载了预训练检查点,恢复优化器状态、EMA 状态和训练轮数
- 处理继续训练(resume)的逻辑,确保训练从正确的轮数开始
# 使用预训练模型开始训练
start_epoch, best_fitness = 0, 0.0 # 初始化起始轮数和最佳性能
# 如果是预训练模型(pretrained为True),执行以下步骤
if pretrained:
# 如果检查点(ckpt)包含优化器的状态,则加载优化器的状态
if ckpt['optimizer'] is not None: # 检查优化器状态是否存在
optimizer.load_state_dict(ckpt['optimizer']) # 加载优化器的状态字典
best_fitness = ckpt['best_fitness'] # 更新当前最佳的模型性能(fitness)
# 如果启用了EMA(指数移动平均),并且检查点中包含EMA的状态,加载EMA状态
if ema and ckpt.get('ema'): # 检查是否启用了EMA,并且ckpt中包含ema
ema.ema.load_state_dict(ckpt['ema'].float().state_dict()) # 加载EMA的状态字典
ema.updates = ckpt['updates'] # 更新EMA的更新次数
# 获取训练的起始轮数
start_epoch = ckpt['epoch'] + 1 # 从检查点中获取已训练的轮数并加1,作为新训练的起始轮数
if resume: # 如果是恢复训练模式
assert start_epoch > 0, f'{weights} training to {epochs} epochs is finished, nothing to resume.'
# 如果起始轮数小于等于0,说明训练已经完成,不需要继续恢复训练
# 如果计划训练的总轮数(epochs)小于起始轮数,说明训练已经结束,因此跳过剩余轮次
if epochs < start_epoch:
LOGGER.info(f"{weights} has been trained for {ckpt['epoch']} epochs. Fine-tuning for {epochs} more epochs.")
# 输出日志信息,说明检查点的训练已经完成,接下来将进行进一步的微调
epochs += ckpt['epoch'] # 将总训练轮数加上已完成的轮数,以便进行微调
# 删除加载的检查点和状态字典,释放内存
del ckpt, csd # 删除检查点数据和模型状态字典,避免内存泄漏
多GPU训练设置
- 如果使用多 GPU,建议使用分布式数据并行(DDP)而非
DataParallel
,因为 DDP 性能更好 - 如果启用了同步 BatchNorm(
--sync-bn
),转换模型的 BatchNorm 层为同步模式,适用于 DDP 训练
# 是否使用DP mode
if cuda and RANK == -1 and torch.cuda.device_count() > 1:
LOGGER.warning('WARNING: DP not recommended, use torch.distributed.run for best DDP Multi-GPU results.\n'
'See Multi-GPU Tutorial at https://github.com/ultralytics/yolov5/issues/475 to get started.')
model = torch.nn.DataParallel(model)
# SyncBatchNorm 是否使用跨卡BN
if opt.sync_bn and cuda and RANK != -1:
model = torch.nn.SyncBatchNorm.convert_sync_batchnorm(model).to(device)
LOGGER.info('Using SyncBatchNorm()')
创建数据加载器
- 创建训练数据加载器,包括数据增强、缓存、图像权重调整等
- 验证数据集中标签的类别是否超出定义的类别数量
# Trainloader
train_loader, dataset = create_dataloader(
train_path,
imgsz,
batch_size // WORLD_SIZE,
gs,
single_cls,
hyp=hyp,
augment=True,
cache=opt.cache,
rect=opt.rect,
rank=LOCAL_RANK,
workers=workers,
image_weights=opt.image_weights,
quad=opt.quad,
prefix=colorstr('train: '),
shuffle=True
)
# 获取最大标签类
mlc = int(np.concatenate(dataset.labels, 0)[:, 0].max()) # max label class
nb = len(train_loader) # 批次数量
# 断言标签类不超过类别数
assert mlc < nc, f'Label class {mlc} exceeds nc={nc} in {data}. Possible class labels are 0-{nc - 1}'
验证数据加载器(只存在于主线程)
- 仅在主进程(或单 GPU)中创建验证数据加载器
- 绘制标签分布图(如果启用)
- 检查和调整锚框(如果未禁用自动锚框调整)
- 运行预训练结束时的回调函数
# Process 0
if RANK in [-1, 0]:
# 创建验证数据加载器
val_loader = create_dataloader(val_path, imgsz, batch_size // WORLD_SIZE * 2, gs, single_cls,
hyp=hyp, cache=None if noval else opt.cache, rect=True, rank=-1,
workers=workers, pad=0.5,
prefix=colorstr('val: '))[0]
if not resume:
# 合并标签
labels = np.concatenate(dataset.labels, 0)
if plots:
# 绘制标签分布图
plot_labels(labels, names, save_dir)
# 检查锚框
if not opt.noautoanchor:
check_anchors(dataset, model=model, thr=hyp['anchor_t'], imgsz=imgsz)
model.half().float() # 预减锚框精度
# 运行回调函数
callbacks.run('on_pretrain_routine_end')
分布式数据并行模式
如果使用 DDP 进行多 GPU 训练,将模型包装为 DDP 模式,以便在多个进程间同步梯度
# DDP模式
if cuda and RANK != -1:
# 将模型包装为分布式数据并行模式
model = DDP(model, device_ids=[LOCAL_RANK], output_device=LOCAL_RANK)