从环境对齐到mAP复核:YOLOv5目标检测全链路实战

发布时间:2026/11/3 19:38:44
从环境对齐到mAP复核:YOLOv5目标检测全链路实战

简介:这份文档面向具备一定Python与计算机视觉基础的入门级研究人员和工程技术人员,系统讲解YOLOv5目标检测框架在本地环境的搭建与首次运行,既可作为理论教学材料,也能为实际工程项目提供参考。内容从操作系统、Python版本及GPU加速所需的CUDA、cuDNN版本要求讲起,依次覆盖依赖库安装、虚拟环境创建、官方源码获取、预训练模型下载与示例检测脚本运行,并对weights、source、img、conf、iou等参数含义作出说明,最后简要引介基于自定义数据集训练模型的进阶方向。资源包为单个docx文档,大小约19KB,便于离线查阅与随查随用。已有239人学习,适合初次安装并使用YOLOv5、希望掌握目标检测实操流程的读者按步骤对照练习,并在此基础上尝试调整实验条件、排错与定制化训练。

1. 环境没对齐时,YOLOv5 的出框结果能离谱到什么程度

有人把仓库 clone 下来,依赖装完,python detect.py一跑,终端不报错,输出图里同一辆车被框成了三个重叠的框,置信度还都写着 0.9。这不是模型坏了,是 torch 落到了 CPU 版、NMS 的阈值又开得太松,两个问题叠在一起。YOLOv5 这套东西的门槛从来不在代码本身,而在「Python 版本—PyTorch 轮子—CUDA 运行时—cuDNN」这条链子上,任何一环错位,目标检测的结果就会从「差一点」直接掉到「没法看」。下面按真实落地顺序拆:先把版本对齐,再把权重放对位置,然后才是detect.py的几个参数怎么调、自定义数据集怎么训、翻车了看哪里。刚把 YOLOv5 拉下来准备跑第一张图的人能照着走,已经能跑但结果不稳、想搞清楚边界的人也能对上号。

2. 版本对齐:从驱动倒推 CUDA,再定 torch 与 Python

环境这一步最容易出的错,是顺序反了。多数人先想「我要装哪个 PyTorch 版本」,再去凑 CUDA,最后发现驱动根本带不动。合格的顺序是反过来的:先看显卡驱动能支持到哪一档 CUDA 运行时,再选对应的 torch 轮子,最后才定 Python 小版本。YOLOv5 对 Python 的要求是 3.7 以上,实际用 3.8 最稳,因为 3.9 之后部分依赖的轮子在小众平台上会缺。

2.1 从 nvidia-smi 倒推:驱动版本才是天花板

打开终端先跑一条命令,别急着装任何东西。

# 查看显卡型号、驱动版本、驱动支持的最高 CUDA 运行时版本 nvidia-smi # 只要关键字段,方便脚本里判断 nvidia-smi --query-gpu=name,driver_version --format=csv,noheader

输出右上角那行CUDA Version: 11.7经常被误读成「本机装了 CUDA 11.7」。它不是。它表示这版驱动最多能支撑到 CUDA 11.7 的运行时,本机可能一个 CUDA Toolkit 都没装。这一点必须先拧过来,否则后面所有版本判断都是错的。

真正决定装哪个 torch 的,是这张对照关系:

驱动支持的 CUDA 上限可选的 torch 轮子后缀常见搭配
11.3 及以上+cu113torch 1.10 ~ 1.12
11.7 及以上+cu117torch 1.13 ~ 2.0
11.8 及以上+cu118torch 2.0 ~ 2.2
12.1 及以上+cu121torch 2.1 以上

选后缀的原则只有一条:轮子的 cuXXX 不能高于驱动上限。高一点点也不行,表现是torch.cuda.is_available()直接返回 False,或者能返回 True 但第一次 kernel 启动就抛no kernel image is available for execution on the device。这条报错是血泪经验里出现频率最高的一条,看到它基本可以直接判定版本错配,不用去查模型代码。

还有一个常见误解:装 torch 不需要单独装 CUDA Toolkit,也不需要单独装 cuDNN。官方发布的 CUDA 版 wheel 已经把对应的 CUDA 运行时和 cuDNN 动态库打包进去了,pip install完就能用。单独装一套 Toolkit 反而可能因为LD_LIBRARY_PATH指向了另一套版本,导致运行时找不到库。除非要自己编译自定义算子,否则别碰 Toolkit。

2.2 conda 建环境与安装次序:先 torch,后 requirements

环境隔离用 conda 最省事,主要图它能干净地删掉重来。

# 建一个独立环境,Python 小版本固定,别用 latest conda create -n yolov5_env python=3.8 -y conda activate yolov5_env # 先装带 CUDA 的 torch,-f 指定官方轮子索引,避免 pip 从默认源拉到 CPU 版 pip install torch==1.12.1+cu113 torchvision==0.13.1+cu113 \ -f https://download.pytorch.org/whl/torch_stable.html # 确认装进来的确实是 cu113 版本,不是被替换成 CPU 版 python -c "import torch; print(torch.__version__, torch.version.cuda)"

这里的关键是次序。先装 torch,再去装 YOLOv5 的requirements.txt。原因很实际:requirements.txt里对 torch 的约束通常写得很松,类似torch>=1.8.0,pip 在解析依赖时如果发现当前环境的版本「满足条件」,一般不会动它;但如果先装 requirements,它可能顺手拉一个最新版 torch 进来,把你刚配好的 CUDA 版本顶掉,而且不会报错,只会在运行时表现为各种莫名其妙的失败。

git clone https://github.com/ultralytics/yolov5.git cd yolov5 git checkout v6.1 # 固定 tag,别直接跟 main,行为会漂 pip install -r requirements.txt # 装完再验一次,确认 torch 没被换掉 python -c "import torch; print(torch.__version__, torch.version.cuda)"

关于 CPU 版还有一个平台差异要注意:在 Linux 上,pip install torch从默认源拉到的通常是 CUDA 版;在 Windows 上默认给的是 CPU 版。所以 Windows 用户如果只写pip install torch torchvision,装完is_available()一定是 False,这跟显卡好坏没关系。想装 GPU 版必须显式指定+cuXXX后缀和-f索引地址。

提示:整个安装过程建议把每条命令的实际输出记下来,尤其是版本号那几行。后面出问题时,第一件事就是核对这几行有没有变。

2.3 三条验证命令:把「装完了」变成「装对了」

pip list里能看到包,不等于环境能用。按下面三段依次验证,每一段失败对应的问题类型都不一样。

import torch # 第一段:版本信息,确认编译时带的是哪套 CUDA print("torch:", torch.__version__) # 期望形如 1.12.1+cu113 print("cuda runtime:", torch.version.cuda) # 期望 11.3,None 说明是 CPU 轮子 print("cudnn:", torch.backends.cudnn.version()) print("available:", torch.cuda.is_available()) # 第二段:设备信息,确认能看到卡 if torch.cuda.is_available(): print("device count:", torch.cuda.device_count()) print("device name:", torch.cuda.get_device_name(0)) print("capability:", torch.cuda.get_device_capability(0)) # 如 (8, 6) # 第三段:真跑一次矩阵乘,确认 kernel 能起来、显存能分配 dev = "cuda" if torch.cuda.is_available() else "cpu" x = torch.randn(1024, 1024, device=dev) y = x @ x print("matmul ok:", y.shape, y.device)

三段各自的分工要清楚。第一段看的是「编译期信息」,torch.version.cuda为 None 就说明装的是 CPU 轮子,后面不用看了。第二段看的是「驱动和硬件能不能被枚举到」,is_available()为 True 但device_count()为 0 这种情况极少,一般出现在容器没挂显卡设备。第三段才是真正的黑匣子检测点:前两段都通过、第三段在x @ x处崩掉,八成是驱动版本低于轮子要求,或者显存被别的进程占满。只看is_available()就以为万事大吉,是后面训练阶段 OOM 的第一大来源。

2.4 cuDNN 的两个开关:benchmark 与 deterministic

跑到这一步环境就算通了,但如果后面要做对比实验,还有一个开关值得提前定下来。

import torch # 输入尺寸固定时开启,cuDNN 会自动挑最快的卷积算法 torch.backends.cudnn.benchmark = True # 需要严格可复现时开启,会限制算法选择,速度会降 torch.backends.cudnn.deterministic = True # 同时固定随机种子 torch.manual_seed(0)

benchmark打开后,cuDNN 会在第一次前向时对每种卷积配置做小规模试跑,选出最快的那一个并缓存下来。输入尺寸固定(比如一直是 640×640)时收益明显,能快 10% 到 20%;输入尺寸每次都不一样时反而会拖慢,因为每次都要重新选。deterministic则是另一回事,它关掉那些结果不确定的算法,代价是速度下降,换来同一份数据两次训练的结果能对上。做消融实验时必须开,跑正式训练时一般关掉。这两个开关经常被混为一谈,实际上一个管快、一个管稳,目标相反。

3. 权重与 detect.py:第一次推理必须盯住的五个参数

环境通了,接下来是让模型真的吐出一个框。这一段有两个容易翻车的点:权重文件放错位置或下载不完整,以及detect.py的参数名在不同版本之间改过。

3.1 仓库目录职责:先知道每个文件夹是干什么的

固定到某个 tag 之后,目录结构基本稳定。搞清职责,后面改配置时才知道该动哪个文件。

路径职责什么时候会动它
models/模型结构 yaml,如yolov5s.yaml改类别数、改 anchors
data/数据集描述 yaml,如coco128.yaml训练自定义数据集
utils/数据加载、增强、NMS、指标计算一般不改,排查时可读
detect.py推理入口调参、批量跑
train.py训练入口训练时用
val.py验证入口,算 mAP训练后复核
export.py导出 ONNX / TorchScript部署前

utils/平时不用碰,但出问题时它是第一现场。比如框重叠得离谱,去utils/general.py里找non_max_suppression那个函数,看iou_thres是怎么参与计算的,比在网上翻十篇帖子管用。

3.2 权重下载与完整性校验

预训练权重首次运行detect.py时脚本会自动拉,但自动拉有两个问题:网络抖动时可能拿到一个截断的文件,以及版本和你 clone 的代码对不上。手动下更可控。

# 手动下载,注意 tag 要和代码 checkout 的版本一致 wget https://github.com/ultralytics/yolov5/releases/download/v6.1/yolov5s.pt # 看文件大小,yolov5s 在 14MB 量级,明显偏小就是没下完 ls -lh yolov5s.pt

大小只能粗略判断,更靠谱的是直接把文件读进来,检查结构。

import torch # 加载权重,map_location 保证在无 GPU 的机器上也能检查 ckpt = torch.load("yolov5s.pt", map_location="cpu") # 完整的 checkpoint 通常带这三个键 print("keys:", list(ckpt.keys())) model = ckpt["model"] print("classes:", model.nc) print("param tensors:", len(list(model.parameters())))

三个键里model是结构本体,ema是训练时的指数滑动平均副本,opt是训练参数快照。只做推理时加载的是ema或model,两者权重略有差异但精度接近。如果torch.load直接抛UnpicklingError或者EOFError,基本可以确定文件不完整,删掉重下,不用怀疑代码。

3.3 detect.py 的五个核心参数

第一次跑,命令写短一点,一次只改一个变量。

python detect.py \ --weights yolov5s.pt \ --source ./samples \ --img 640 \ --conf-thres 0.25 \ --iou-thres 0.45 \ --device 0 \ --project runs/detect --name demo --exist-ok

参数逐个说:

--weights指模型文件路径,写相对路径时是相对于仓库根目录,不是相对于你当前 shell 所在目录,这点经常有人搞混。

--source可以是一张图、一个目录、一段视频、一个摄像头编号(写0),也可以是一个 txt 文件,里面每行一个路径。传目录时会递归处理其中的图片和视频。

--img是推理时的输入边长。模型内部会把短边缩放到这个值,长边按比例缩放,再做 letterbox 填充。这个值不要求和训练时完全一致,但差距太大精度会掉。训练用 640、推理用 320,小目标的召回会明显下降。

--conf-thres是置信度阈值,低置信度的候选框在这里被直接丢掉。调低会召回更多但误检变多,调高反之。

--iou-thres是 NMS 的 IoU 阈值,控制「两个框重叠到什么程度算同一个目标」。这个值调高(比如 0.7)会保留更多重叠框,适合密集场景;调低(比如 0.3)会 aggressively 合并,密集场景里相邻的两个目标会被合成一个。

注意:参数名在版本之间改过。较早的版本用--conf和--iou,v6 之后改成了--conf-thres和--iou-thres。写成旧名字不会报「未知参数」,而是走默认值,结果就是你调了半天阈值发现输出没变化。跑之前先python detect.py --help看一眼当前版本认哪些名字。

3.4 输出目录与结果复核

跑完之后结果落在runs/detect/demo/下。不加--exist-ok的话,同名目录会被自动改成demo2、demo3,连跑几次就分不清哪次是哪次了,所以固定加这个参数。

# 同时保存标注文本,方便脚本化复核 python detect.py \ --weights yolov5s.pt --source ./samples \ --img 640 --conf-thres 0.25 --iou-thres 0.45 \ --save-txt --save-conf \ --project runs/detect --name demo --exist-ok # 输出目录结构 # runs/detect/demo/ # image1.jpg 带框的可视化图 # labels/image1.txt 每行: class cx cy w h conf(归一化坐标) ls runs/detect/demo head -3 runs/detect/demo/labels/*.txt

--save-txt存的是归一化后的中心点坐标和宽高,五列加上--save-conf的第六列置信度。这个格式和训练用的标注格式完全一致,所以可以直接拿它做二次筛选:把所有 conf 低于某个值的行删掉,再当伪标签用。坐标是相对原图宽高归一化到 0 到 1 的,反算像素坐标时记得乘回原图尺寸,不要乘成--img的值,这是另一个高频错误。

复核结果不能只看图好不好看。抽十张图,把labels/*.txt的行数和图上肉眼可见的目标数对一遍,差得多就说明阈值有问题;再挑一张小目标多的图,把--img从 640 改成 1280 重跑,如果小目标突然被检出来了,说明原来的输入尺寸就是瓶颈。

4. 自定义数据集训练:从标注 txt 到 mAP 曲线的完整链路

预训练模型能认八十类,但业务场景里的目标大概率不在里面。训练自定义数据集这条链路,卡人的地方几乎全在数据格式和配置对不上,而不是训练脚本本身。

4.1 标注格式:五列归一化坐标,一个都不能错

YOLO 的标注是一个图对应一个 txt,每行一个目标,五列:class cx cy w h。全部是归一化到 0 到 1 的浮点数,cx cy是框中心点,w h是宽高,都除以原图对应边长。

# 假设原图 1920x1080,一个类别 0 的目标框在 (400,300) 到 (800,700) # 中心点: ((400+800)/2/1920, (300+700)/2/1080) = (0.3125, 0.4630) # 宽高: ((800-400)/1920, (700-300)/1080) = (0.2083, 0.3704) 0 0.3125 0.4630 0.2083 0.3704

如果手上是 VOC 的 xml,需要转一道。下面这个脚本做的事很单一,但边界情况都处理了。

import os import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, classes, out_dir): """把单个 VOC xml 转成 YOLO txt,坐标做 clamp 防止越界""" tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") w = float(size.find("width").text) h = float(size.find("height").text) lines = [] for obj in root.iter("object"): name = obj.find("name").text.strip() if name not in classes: # 类别不在名单里直接跳过,不要静默当 0 类 continue cls_id = classes.index(name) bbox = obj.find("bndbox") x1 = float(bbox.find("xmin").text) y1 = float(bbox.find("ymin").text) x2 = float(bbox.find("xmax").text) y2 = float(bbox.find("ymax").text) # 标注越界是常见脏数据,先夹到图内再归一化 x1, x2 = max(0.0, x1), min(w, x2) y1, y2 = max(0.0, y1), min(h, y2) if x2 <= x1 or y2 <= y1: # 夹完之后退化成零面积,丢掉 continue cx = (x1 + x2) / 2.0 / w cy = (y1 + y2) / 2.0 / h bw = (x2 - x1) / w bh = (y2 - y1) / h lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") out_path = os.path.join(out_dir, os.path.splitext(os.path.basename(xml_path))[0] + ".txt") with open(out_path, "w", encoding="utf-8") as f: f.write("\n".join(lines))

脚本里两处判断值得单独讲。第一处是类别过滤,VOC 里的类别名和你的names列表必须严格一致,大小写和空格都算不同,对不上就跳过,不要顺手塞成 0 类,否则训练出来会把两种目标混成一类,而且 loss 曲线看起来还挺正常,属于最难查的一类问题。第二处是坐标 clamp,人工标注里出现xmax大于图片宽度是家常便饭,不夹的话归一化后会大于 1,训练时数据增强做随机裁剪会算出负面积,直接给 loss 送一个 nan 进去。

4.2 data.yaml 的四个字段与目录约定

# data/mydata.yaml path: ../datasets/mydata # 数据集根目录,相对这个 yaml 文件所在位置 train: images/train # 相对 path,可以是目录,也可以是 txt 列表文件 val: images/val nc: 3 # 类别数,必须等于 names 的长度 names: ['person', 'helmet', 'vest']

目录结构按约定摆好,脚本会自动去同级的labels/找标注:

datasets/mydata/ images/train/ a.jpg b.jpg ... images/val/ c.jpg ... labels/train/ a.txt b.txt ... # 文件名与图片同名,扩展名不同 labels/val/ c.txt ...

train和val写目录时,脚本会把目录下所有图片收进来,并按「把images替换成labels、扩展名替换成.txt」的规则去找标注。这个替换规则意味着路径里必须包含images这一段,如果你的目录叫imgs,它就找不到标注,而且不会报「找不到标注」,只会报标注数量为 0。这是新手最容易卡住的地方。

4.3 模型 yaml 的 nc 与 anchors

# 复制一份改,别直接改原文件,后面做对比还要用 cp models/yolov5s.yaml models/yolov5s_mydata.yaml # 只改类别数这一行 # 原: nc: 80 # 改: nc: 3

anchors那九组数值一般不用动。它们是 COCO 上聚类出来的先验框尺寸,换到新数据集上不是最优,但作为初始值够用,微调几十个 epoch 会自己收敛过去。只有当目标尺寸分布和自然图像差得特别远(比如全是几百像素的大目标,或者全是十几像素的小字)时,才值得重新聚类一遍 anchors。

--cfg和--weights一起给的时候有个细节:结构按--cfg重建,权重按--weights加载,加载时如果检测头部分的形状和当前nc对不上,那部分会被跳过并在日志里打印出来。这是预期行为,不是错误。所以「改了 nc 之后检测头的权重从零开始学」是正常的,不要因为看到 skip 日志就去改代码。

4.4 train.py 参数与显存估算

python train.py \ --img 640 \ --batch 16 \ --epochs 100 \ --data data/mydata.yaml \ --cfg models/yolov5s_mydata.yaml \ --weights yolov5s.pt \ --device 0 \ --workers 4 \ --cache ram \ --project runs/train --name exp1 --exist-ok
参数含义调整建议
--img训练输入边长小目标多就上调到 960 或 1280,显存吃紧先降 batch 再降 img
--batch单卡批大小8G 显存配 640 大概能到 8~16,从 8 试起
--epochs训练轮数小数据集 100 到 300,配合早停
--weights初始权重写yolov5s.pt是迁移学习,写空字符串是从头训
--cache缓存方式ram最快但吃内存,disk折中,数据集大就别开
--workers数据加载进程数Windows 上超过 0 容易出多进程问题,先设 0

显存这一块给个经验区间:yolov5s+img 640+batch 16,大约占 8GB 上下;换成yolov5m同配置大概翻倍。显存不够时的降级顺序是:先降batch到 8、4、2,再关掉--cache,最后才降--img。因为降输入尺寸对精度的伤害比降 batch 大得多,batch 太小会影响 BatchNorm 的统计量稳定性,但那个影响是可恢复的。

4.5 训练日志里的三个信号

训练起来之后,日志会刷得很快,重点盯三处。

第一处是三个 loss:box_loss、obj_loss、cls_loss。正常形态是前 10 到 20 个 epoch 快速下降,然后进入缓慢下降。box_loss长期不降说明框回归没学到东西,先怀疑标注坐标算错;obj_loss不降说明模型分不清前景背景,通常是负样本太多或者数据集里空标注图片占比过高;cls_loss在单类别任务里本来就接近 0,不用管。

第二处是验证集的mAP@0.5和mAP@0.5:0.95。前者宽松,后者严格,两个都涨才说明真的在变好。只涨前者不涨后者,通常是把框放大了换召回,实际用起来框会偏松。

第三处是学习率。默认的 cosine 调度会从0.01量级一路降到接近 0。如果 loss 在中途突然炸开,看时间点是不是正好在 warmup 结束、学习率跳到峰值的那个 epoch,是的话把--warmup-epochs调大或者初始 lr 调小。

5. 踩坑排查:五类高频翻车现场的现象、原因与解决

这些坑按出现频率排,每条都按「看到什么—为什么—怎么修」写。修的时候一次只动一个变量,别三处一起改,否则修好了也不知道是哪一处起的作用。

5.1 torch.cuda.is_available() 返回 False

现象:装完 torch,python -c "import torch; print(torch.cuda.is_available())"输出 False,显卡在nvidia-smi里明明看得见。原因:三种可能,按概率排。装的是 CPU 版轮子(torch.version.cuda为 None);驱动版本低于轮子要求的 CUDA 上限;在 Windows 上用了默认源安装,默认给的就是 CPU 版。解决:先打印torch.__version__和torch.version.cuda,确认后缀是+cuXXX。是 CPU 版就卸掉重装,注意卸载要用pip uninstall torch torchvision两个一起卸,只卸一个会留下不匹配的依赖。确认轮子没问题再看nvidia-smi的驱动版本和 CUDA 上限,低于轮子要求就升级驱动。这三步走完还是 False,检查是不是在容器里跑但没挂--gpus。

5.2 detect.py 报权重加载失败或结果全空

现象:torch.load抛UnpicklingError,或者跑完没有任何报错,但输出目录里一张带框的图都没有,labels 全是空文件。原因:权重文件下载不完整是第一种,尤其是自动下载被中断过。第二种是传给--weights的路径相对的是仓库根目录而不是 shell 当前目录,实际加载到了另一个同名文件或者压根没加载上。第三种是推理时的类别和权重不匹配,但这个会报形状错误而不是空结果,可以排除。解决:先看文件大小是否在合理量级,再用 3.2 那段代码把文件读一遍,能读出model键就说明文件完好。路径问题就统一改成绝对路径跑一次,能跑通说明是相对路径的基准搞错了。确认权重没问题但还是全空,把--conf-thres降到 0.1 重跑,如果这时候有框了,说明模型对这个场景本身就不敏感,不是环境问题,该走微调路线。

5.3 训练 loss 变 nan 或 obj_loss 完全不降

现象:前几个 epoch 正常,某个 epoch 之后 loss 打印成nan,后面全部失效;或者从头到尾 loss 都在一个值附近小幅波动,不下降。原因:nan 有九成是数据问题,标注坐标越界、宽高为 0、图片文件损坏读了半张;剩下一成是学习率过大导致梯度爆炸。obj_loss 不降则多半是正负样本失衡,数据集里背景图太多,或者标注框小到和 anchor 完全对不上。解决:nan 先跑一遍数据体检,把所有 txt 读出来检查每一行的五列是否都在 [0,1] 内、w h是否大于 0,图片用 PIL 逐张verify()一遍找出损坏文件。数据干净就把初始 lr 从 0.01 降到 0.001 试一个短周期。obj_loss 的问题则是先统计每张图的平均目标数,小于 1 的话要么补充目标图,要么把--hyp里的box和obj损失权重做个调整,常见做法是直接把背景图抽掉一部分。

5.4 mAP 长期为 0 或极低

现象:训练能跑完,loss 也在降,但val出来的 mAP 一直是 0,或者低到 0.01 量级。原因:最典型的是 data.yaml 里的路径配置和实际目录结构对不上,尤其是train/val写目录时,脚本按规则去找labels找不到,实际加载到的标注数是 0,模型学了个寂寞。其次是nc和names长度不一致,或者 txt 里的 class id 超出了nc - 1。还有一种隐蔽情况是图片和标注文件名大小写不一致,在 Linux 上就直接找不到了。解决:训练启动时日志第一段会打印数据集统计,包括图片数、目标数、每类实例数,先看这个数字对不对。目标数为 0 就是路径问题。数字对但 mAP 还是 0,把val.py单独跑一次,用--save-txt输出预测结果,和标注对比几个样本,通常一眼就能看出是类别全错还是框全偏。

5.5 显存 OOM 或 DataLoader 卡住不动

现象:训练启动几秒后抛CUDA out of memory,或者进度条长时间停在 0,CPU 占用拉满但 GPU 空转。原因:OOM 一般是--img和--batch的组合超过显存,或者开了--cache ram把内存和显存一起挤爆。卡住不动多半是--workers开太大,在 Windows 上尤其明显,多个进程抢同一个数据集句柄会死锁。解决:OOM 先把 batch 减半重试,还不行就关掉--cache,最后才降--img。另外训练前用nvidia-smi看有没有别的进程占着显存,残留的 python 进程不清掉会一直占着。卡住不动就把--workers设成 0 跑一次,能跑通说明是多进程问题,之后在 Linux 上逐步加到 4 或 8,在 Windows 上就老实待着 0。

6. 用 val.py 与 TTA 复核:把「看起来能用」变成「数据上能用」

训练完拿到best.pt,很多人看一眼验证集的图就交付了。图好看不代表指标可信,指标可信也不代表换了推理方式还成立。我一般会强制走两步复核,成本很低,但能挡掉大部分「线下好用线上崩」的情况。

第一步是用val.py重算一遍,并且开 TTA。

python val.py \ --weights runs/train/exp1/weights/best.pt \ --data data/mydata.yaml \ --img 640 \ --task val \ --augment \ --project runs/val --name tta --exist-ok

--task val表示只做验证不算推理速度,--augment打开 TTA,会把输入做翻转和多尺度后各跑一次再融合。TTA 通常能把 mAP@0.5 抬 1 到 2 个点,代价是推理耗时翻几倍。这一步的目的不是刷高分数,而是看「开了 TTA 之后涨多少」:涨得多说明模型对输入变化敏感、鲁棒性一般;几乎不涨说明模型已经比较稳。两种结果对应不同的上线策略,比单看一个绝对数值有用。

第二步是导出 ONNX,用另一个运行时跑同一张图,比对输出的形状和数量级。

python export.py \ --weights runs/train/exp1/weights/best.pt \ --include onnx \ --img 640 640 \ --batch 1 \ --opset 12
import numpy as np import onnxruntime as ort sess = ort.InferenceSession("best.onnx", providers=["CPUExecutionProvider"]) inp = sess.get_inputs()[0] print("input:", inp.name, inp.shape) # 期望 [1, 3, 640, 640] # 造一个同形状的输入,确认能跑通并看输出维度 x = np.random.randn(1, 3, 640, 640).astype(np.float32) outs = sess.run(None, {inp.name: x}) for o in outs: print("output:", o.shape) # 形如 (1, 25200, 5 + nc)

输出最后一维等于5 + nc,其中 5 是cx cy w h conf。这个数字对不上,说明导出时的--img和训练时不一致,或者nc没跟着改。ONNX 这条路的坑主要集中在预处理:PyTorch 那条链路里 letterbox 的填充值、RGB 顺序、归一化系数都是在代码里写死的,换到别的运行时如果忘了照抄,输出会看起来「有框但位置全偏」,比直接不出框更难查。所以我习惯在导出后固定拿同一张图,分别用detect.py和 ONNX 各跑一次,把两组框的坐标打印出来对比,差值在几个像素内才算对齐。

从那以后我每次训完一个模型,不管 deadline 多紧,val.py --augment和 ONNX 对齐这两步都强制走一遍,宁可多花十分钟,也不想在上线之后才发现验证集分数是虚的。希望帮到你。

本文还有配套的精品资源,点击获取