前段时间一直在啃YOLO源码,尤其盯着ultralytics.models.yolo.detect这个模块看了很久。很多人训练、验证、推理都直接调model.train()、model.val()、model.predict(),但一旦涉及自定义loss、改数据加载逻辑、或者想精确控制推理后处理,就不得不到这三段代码里翻找。这篇文章就围绕train.py、val.py、predict.py三个子模块,把它们各自的核心类、方法调用链和设计逻辑完整过一遍,顺便把我踩过的坑一起列出来。
1. 先搞清楚detect模块的整体定位
1.1 从import到运行,三个类各管一段
在ultralytics/models/yolo/detect/__init__.py里,只做了三件事:把训练、验证、预测三个类暴露出来。DetectionTrainer、DetectionValidator、DetectionPredictor分别对应YOLO检测任务的三条主流程。用下面的等价写法就能直观感受到它们的关系:
from ultralytics.models.yolo.detect import DetectionTrainer, DetectionValidator, DetectionPredictor # 等价于 from ultralytics.models.yolo.detect.train import DetectionTrainer from ultralytics.models.yolo.detect.val import DetectionValidator from ultralytics.models.yolo.detect.predict import DetectionPredictor从框架整体看,ultralytics包的所有任务(检测、分割、分类、姿态)共用一套BaseTrainer、BaseValidator、BasePredictor骨架,检测任务只是把关键方法重写掉。所以读detect模块的代码,本质上是在看“检测任务如何实现Base类里定义的抽象流程”。
这三条流程的职责可以用一张表概括:
| 类 | 继承来源 | 核心职责 | 主要入口方法 |
|---|---|---|---|
| DetectionTrainer | BaseTrainer | 构建模型、加载数据、执行训练循环、保存checkpoint | train() |
| DetectionValidator | BaseValidator | 构建验证数据、前向推理、计算指标、输出混淆矩阵 | val() |
| DetectionPredictor | BasePredictor | 加载模型权重、预处理输入、推理、后处理解码、结果可视化 | predict_cli() / stream_inference() |
读代码时我建议从predict.py入手,它的方法最少、链路最短,读完再回头读train.py会顺畅很多。
1.2 TaskType和模型工厂的映射关系
detect模块虽然独立,但它通过模型工厂暴露给了用户。ultralytics/models/__init__.py里根据task参数决定返回哪个TaskType:
class TaskType(Enum): DETECT = "detect" SEGMENT = "segment" CLASSIFY = "classify" POSE = "pose"DetectionTrainer等类不会直接在用户代码里被实例化,而是由model.train()内部根据task='detect'自动路由过去。训练时你传入的yolo11n.yaml等配置会同时决定模型结构和trainer类型,这条链路的起点在ultralytics/engine/model.py。
实际调试时可以手动实例化trainer,绕过model.train()高层封装,这样能直接看到每个环节做了什么:
trainer = DetectionTrainer(overrides={"model": "yolo11n.yaml", "data": "coco8.yaml"}) trainer.train()这里有个值得注意的设计:所有配置参数(如epochs、batch、imgsz)都通过overrides字典传入,由cfg机制统一管理。这样做的好处是命令行、Python API、配置文件三套入口共用一套参数校验逻辑。
2. DetectionTrainer训练核心流程拆解
2.1 get_model:从yaml到nn.Module的构建过程
DetectionTrainer中get_model负责把模型配置文件变成可训练的nn.Module。YOLO11的模型构建走的是“yaml解析 + 动态生成”路线:
def get_model(self, cfg=None, weights=None, verbose=True): model = DetectionModel(cfg, ch=3, nc=self.data["nc"], verbose=verbose) if weights: model.load(weights) return modelDetectionModel在解析yaml时,会把配置里的每一层结构先存成字典列表,再通过parse_model递归生成逐层网络结构。这部分代码初读时容易懵,因为它大量使用了globals()动态查找层类:
# 简化示意 for i, (f, n, m, args) in enumerate(d["backbone"] + d["head"]): m = eval(m) if isinstance(m, str) else m # ... layers.append(nn.Sequential(*[m(*args) for _ in range(n)]))这种动态构建方式的好处是,新增一个层只需要在nn.modules里定义类,然后在yaml里按配置写即可,模型结构完全和代码解耦。代价是IDE跳转时会比较痛苦,建议配合# type: ignore和pdb去看每一层的实际类型。
nc(类别数)会在模型配置和数据集配置之间做校验。如果yaml里写了nc: 80,而data配置的类别数是别的值,DetectionModel会直接用data的nc覆盖模型yaml里的定义。这就是为什么你改了数据集的类别数,模型输出层会自动调整的原因。
提示:自定义模型结构时,
detect头部的cv2、cv3分支数量必须和nc匹配。如果你看到shape mismatch错误,十有八九是模型yaml和数据集类别数没对齐。
2.2 set_model_attributes与加载预训练权重
set_model_attributes这个方法很朴素,核心逻辑是把数据集信息绑定到模型上:
def set_model_attributes(self): self.model.nc = self.data["nc"] self.model.names = self.data["names"] self.model.args = self.args这里的names是类别名列表,会在推理时用来渲染标签。args是训练超参数,会保存到checkpoint里,方便恢复训练时保持一致性。
预训练权重加载的逻辑也很直接:
if weights and weights.endswith(".pt"): ckpt = torch.load(weights, map_location="cpu") model.load_state_dict(ckpt["model"].float().state_dict())实际踩坑记录:如果你用model = DetectionModel(cfg, ch=3, nc=80)后,再调用load()加载COCO预训练权重,但自定义数据集只有两个类别(比如person和car),那么加载权重会直接报shape mismatch。解决办法是用官方的建议设置transfer=True,它会自动跳过shape不匹配的层,只加载能对齐的部分。
2.3 get_dataloader和build_dataset:训练数据流水线
build_dataset看名字简单,内部做了不少事。检测任务训练时的数据集构建主要有三个分支:
- 非矩形推理(
rect=False):普通YOLODataset,每张图独立缩放后拼batch - 矩形推理(
rect=True):同一个batch里的图片宽高比尽量一致,减少padding浪费 - 是否启用
mosaic增强:启用时每4张图拼成1张,再随机裁剪,相当于用显存换数据多样性
具体代码里关于mosaic的判断值得注意:
if self.args.rect and mf: # 矩形推理模式下若mosaic系数为0 dataset = YOLODataset( img_path=... augment=False, rect=self.args.rect, ... ) else: dataset = YOLODataset( img_path=... augment=True, rect=False, ... )读代码时我这里的理解是:矩形训练模式下,每张图的输入尺寸不固定,而mosaic增强需要先把多张图拼成固定尺寸再裁剪,两者逻辑冲突。所以框架在启动矩形训练时会强制关闭mosaic,这是很多人在自定义数据流程时忽略的前提条件。如果你中途打开rect=True但没注意这个分支,训练出的模型在验证时bbox偏移会很明显。
get_dataloader比较简单,就是包了一层DataLoader:
return self.dataset if self.args.v5loader else DataLoader(...)那行v5loader是历史遗留,老项目沿用v5的数据加载方式,新版默认走YOLODataset自己的__getitem__逻辑。
2.4 训练主循环:loss反向传播与老代码的差异
detect模块的train.py不直接写训练循环,继承自BaseTrainer。BaseTrainer里已经有一个完整的train方法,包含for epoch in range(start_epoch, epochs)循环、学习率调度、EMA更新、梯度裁剪、checkpoint保存等。DetectionTrainer主要在get_model、get_dataloader、build_dataset这些模板方法上和检测任务对齐,loss计算在DetectionModel的forward内部完成。
新版YOLO的loss计算已经内嵌到模型内部,不是传统的“模型输出y_pred,再在trainer里算loss”。模型forward返回一个Loss对象,内部包含分类loss和回归loss:
loss, loss_items = model(batch)loss_items是独立loss明细,会被记录到日志中,训练时你可以观察到box_loss、cls_loss、dfl_loss各自的变化趋势。这种设计的直接好处是,验证阶段模型前向输出的是preds,训练阶段输出的是loss,同一个forward入口通过training标志区分。
我调试时常用的一个方法是手动给loss加断点看分类分支和回归分支分配到的梯度比例。如果是类别极度不平衡的数据集(比如只有一个大类),“cls_loss”会一直降不下去,这时就需要调整cls和box在loss中的权重系数,粮食都在模型的__init__.py附近。
3. DetectionValidator验证环节实现解读
3.1 验证集的数据构建与训练集的差异
val.py里的build_dataset和get_dataloader与训练版本相比有本质区别——验证集不需要数据增强(这是铁律,加了增强指标就废了)。验证数据的关键是必须保证每张图都能被正确处理,填充方式要和训练保持一致。
def build_dataset(self, img_path, mode="val", batch=None): gs = max(int(de_parallel(self.model).stride.max()), 32) return YOLODataset( img_path=img_path, imgsz=self.args.imgsz, batch_size=batch, augment=False, rect=self.args.rect, stride=gs, pad=0.5, prefix=f"{mode}: ", ... )这里stride=gs的作用是把图片padding到模型最大stride的整数倍。比如模型最大stride是32,输入尺寸为640,图片会被padding到320或640的整数倍。这个细节直接关系到你验证时输入分辨率是否真的等于设置值。我遇到过自己预处理时没对齐stride,导致推理结果出现边框错位的情况。
3.2 模型前向与后处理解码
DetectionValidator里的postprocess把模型输出的原始prediction张量转换成可以评估的bbox格式。
def postprocess(self, preds): return ops.non_max_suppression( preds, self.args.conf, self.args.iou, labels=self.lb, multi_label=True, agnostic=self.args.single_cls, max_det=self.args.max_det, )non_max_suppression是推理链路的核心瓶颈之一,它内部会按置信度排序,计算IoU,递归消除重叠框。验证阶段用labels=self.lb传入GT框参与NMS计算,目的是让预测框尽量贴近GT框,评估指标会更接近真实部署场景。推理阶段则不会传labels。
后面还会看到一行关键调用:
preds = self.model(batch["img"])验证代码里没有显式先model.eval(),因为BaseValidator已经处理了。但如果你自己写评估脚本,记得手动执行model.eval()和torch.no_grad(),否则BN层的统计量会在验证集上被错误更新,指标看起来会很奇怪。
3.3 update_metrics与指标计算的隐藏逻辑
验证的核心指标计算全部在update_metrics方法中。这个方法接收模型输出preds和GT batch,通过匹配预测框和真实框来更新混淆矩阵、计算TP(True Positive)、FP(False Positive)、FN(False Negative)。这些状态存在self.metrics和self.jdict里。
def update_metrics(self, preds, batch): # 实现NMS后处理,与GT配对 # 更新混淆矩阵、AP/Target等统计 ...指标类型上,YOLO的验证会同时维护两类:
self.metrics:最终mAP结果,每张图预测结束后累计self.jdict:每个类别的AP统计明细
验证结束时finalize_metrics会把累计数据做平均计算,得到最终mAP50、mAP50-95等数值。
看源码时一定记住一个坑:update_metrics中使用的是pbatch即处理后的batch,其中“batch的img是预处理后(归一化、填充后)的张量”,真正评估处理时会把bbox的坐标缩放到原图尺寸,所以验证精度不受预处理影响。如果你准备自定义评估函数,这个坐标系变换是绕不开的。
4. DetectionPredictor推理链路解析
4.1 preprocess:从一张图到模型输入张量
predict.py的核心是DetectionPredictor类,继承自BasePredictor。推理前最重要的方法就是preprocess,它把输入图像转换成模型需要的张量格式:
def preprocess(self, img): # 如果没有resize,则保持原图尺寸但做letterbox填充 if not isinstance(img, torch.Tensor): # 输入是路径、numpy数组或PIL图像 img = torch.from_numpy(img).to(self.model.device) img = img.float() / 255.0 # 归一化到0~1 # 调整维度顺序,BCHW,并letterbox处理 return img这里我插一句:很多人以为YOLO推理时会resize,实际是letterbox(等比缩放加灰边)。preprocess中如果图片尺寸不是模型输入尺寸的整数倍,会做边缘padding。这个padding在你自己实现部署推断逻辑时极易漏掉,chartbox的偏移误差就直接出现了。
实际代码中,letterbox的实现通常会把变换矩阵保存下来,用于后处理时把bbox坐标映射回原图。所以preprocess不只是简单resize,它输出了一个可用于反变换的scale、pad信息,这是许多自研推理代码会忽视的地方。
模型的输出,直接就是NMS之前的原始prediction。所以你在用model.predict()的时候,其实框架已经内部完成了候选框解码、NMS、映射回原图坐标这一整套。但如果你在自己写推理循环,需要手动调用model.forward()拿原始输出,然后自己做non_max_suppression。
4.2 postprocess与结果展示
DetectionPredictor的postprocess主体逻辑是NMS:
def postprocess(self, preds, img, orig_imgs): preds = ops.non_max_suppression( preds, self.args.conf, self.args.iou, agnostic=self.args.agnostic_nms, max_det=self.args.max_det, classes=self.args.classes, ) # 再把坐标映射回原始图像尺寸 ... return predsorig_imgs参数是原始图像或者原始图像的尺寸信息,保留下来用于还原坐标。推理结果框的坐标坐标系发生了什么转化需要理清楚:模型输入坐标是在letterbox后的图上归一化的,输出是letterbox图上坐标,映射回原始图尺寸,靠postprocess里的反变换实现。
调用链上,BasePredictor.stream_inference是推理的实际执行函数,它循环遍历数据源,对每个batch图像调用inference(模型前向)和postprocess(后处理)。如果开了stream=True,推理时source可以是视频流、摄像头或迭代器,这样可以边读帧边处理,避免视频推理卡顿。
4.3 推理结果返回格式解析
现代版YOLO的统一推理入口是model.predict(),它返回一个Results对象列表。每个Results对象内部包含了:
boxes:检测框(xyxy格式、置信度、类别索引)masks:分割掩码(如果是分割模型)keypoints:关键点(如果是姿态模型)orig_img:原始图像数组names:预测类别对应的名称
你可能会问:如果直接看源码,postprocess里返回的是元组(preds, boxes, masks),为什么用户看到的是Results对象?关键在于BasePredictor在完事后调用了self.results = self._postprocess_after_inference之类的逻辑,把原始预测打包成了用户友好的Results对象。这部分在ultralytics/engine/predictor.py里。
5. 三个模块的联动与我最常遇到的坑
5.1 从Base到子类的调用链全览
虽然三个Python文件分别实现了train、val、predict,但它们在运行时是互相耦合的。训练开始前DetectionTrainer会做校验并构建DetectionValidator,训练中途每隔一段时间会自动跑一次验证,保存mAP最高的权重;推理加载模型时又会根据模型类型自动选择对应的DetectionPredictor。
# 训练中创建验证器的核心逻辑 validator = self.get_validator() validator(model=self.model, dataloader=self.get_dataloader(...), save_dir=self.save_dir)所以你在train.py里会看到get_mot_validator之类的方法名,本质上就是返回DetectionValidator。在val.py里也有get_model等手脚。这是经典的Template Method模式:Base类定义算法骨架,子类实现具体步骤。检测、分割、分类各任务互不干扰,新增任务只需要继承Base并实现对应方法即可。
调试时给个实用建议:如果在训练过程中想单独验证某个checkpoint,可以这样写:
model = YOLO("runs/detect/train/weights/best.pt") model.val(data="coco8.yaml", split="val", imgsz=640)这会自动走DetectionValidator流程,和你训练中途的验证完全一致。
5.2 高频报错与排查心得
先说一个我调试YOLO时踩得最深的坑:DataLoaderworker数量。如果你在get_dataloader里看到workers=self.args.workers,而训练机是Windows系统,workers设置过大时(比如8),经常出现假死或报BrokenPipeError。通常我建议Windows用户直接设workers=2或0,Linux可以按CPU核心数灵活设,但要避开机器IO瓶颈。
第二个坑在验证阶段:val.py计算mAP时会要求GT数据格式严格对齐,否则虽然不报错,但mAP值会低得离谱。常见问题是data配置里的names顺序和训练时不一致。YOLO的GT坐标格式是归一化xywh(中心点+宽高),输入图像尺寸、letterbox padding对坐标的影响已经体现在前面说的pad参数中。如果觉得验证框和GT对不上,优先检查这个。
第三个坑是predict.py里conf参数。默认conf=0.25拦掉了不少低置信度的框,如果你用同一个模型做高召回要求的业务(比如安全帽检测),单纯提高iou没用,要降低conf到0.05甚至0.01。代价是NMS计算量上升,因为待选框数量变多了。这里我实测下来,conf=0.1、iou=0.45在大多数场景下是召回和精度的良好平衡点。
| 问题现象 | 大概率原因 | 排查方法 |
|---|---|---|
| 训练时loss为NaN | 学习率过大 / 数据有异常标注(比如坐标超出图像范围) | 查看cfg里的lr参数;打印batch里的归一化坐标,检查是否在[0,1]区间 |
| 推理输出空列表 | conf阈值设置过高 / 模型未正确加载权重 | 将conf降到0.01,打印原始preds看是否存在高置信度框 |
| 验证mAP比训练mAP低很多 | 验证数据增强开启 / 图像尺寸设置不一致 | 检查val.py里augment是否为False,是否imgsz和训练时相同 |
| 内存溢出 | batch size过大 / 启用了mosaic但数据集中有超大图 | 调小batch、关闭mosaic(mosaic=0),或将图片预缩放到较小尺寸 |
5.3 自定义修改的建议路由
很多初学者会直接改train.py里的大段代码,我建议不要这样操作。Ultralytics的设计本来就支持通过继承和hook机制来做自定义修改:
- 想要改数据增强逻辑:继承
YOLODataset,重写__getitem__ - 想要改loss:继承
DetectionModel,在forward里替换loss模块 - 想要改训练循环的某个节点:继承
DetectionTrainer,重写对应hook方法
之前我改造某个两阶段检测任务时,就是直接继承DetectionTrainer,重写get_dataloader和build_dataset。这种做法的好处是不需要动官方包一行业代码,未来升级Ultralytics版本时直接无缝衔接。
在train.py的get_model中还有一个细节:如果使用cfg模式(yaml)创建模型,nc参数会直接用数据集类别数覆盖模型配置文件;如果直接传入weights模式(.pt),则会先构建模型再覆盖权重层,两种方式的加载顺序是不同的。这个差异在迁移学习场景下体现得很明显——直接传.pt作为模型时,nc会以模型文件里的为准,除非你显式设置data里的nc。
val.py里get_model通常都是空方法,因为验证时模型已经从checkpoint里恢复了,不需要重建。但分割模型有所不同,它需要在get_model里指定mask分支的通道数。
predict.py还有一个容易被忽略的stream_inference方法。这是推理的主循环,负责迭代source(可以是图片路径、视频文件、摄像头、PIL图像、numpy数组等),对每个对象做预处理、推理、后处理、结果记录。如果你要实现自定义推理服务,大部分情况重写preprocess和postprocess就够了,主循环逻辑用官方实现。
6. 三联表:三个文件的设计对比与性能提示
最后用一张表总结三个文件的设计差异(列表在快速阅读时真的有用):
| 对比维度 | train.py | val.py | predict.py |
|---|---|---|---|
| 核心类 | DetectionTrainer | DetectionValidator | DetectionPredictor |
| 关键hook | get_model / build_dataset / get_dataloader / set_model_attributes | build_dataset / get_dataloader / update_metrics / finalize_metrics | preprocess / inference / postprocess |
| 输入 | 数据配置 + 模型配置 + 超参数 | checkpoint + 数据配置 + 验证参数 | 任意图像源 + 模型权重 |
| 输出 | 权重文件 + 训练日志 + EMA模型 | mAP指标 + 混淆矩阵 + 预测可视化 | Results对象列表或保存的图片/视频 |
| 支持自定义的侧重点 | 数据流 / 网络结构 / 训练超参 | 指标计算方法 / 匹配逻辑 / 数据加载 | 输入格式 / 后处理策略 |
| 常见性能瓶颈 | mosaic数据增强 + 多worker加载 | NMS和GT匹配(大验证集时) | NMS + letterbox预处理 |
读这些代码时,我一直觉得最有意思的设计是让训练、验证、推理共用同一个DetectionModel,而不同阶段通过传入的training标志或环境变量来切换行为。模型内部会build出不同的分支,loss只在训练时计算,验证时只输出预测张量。这让你在做模型转换或量化感知训练时,可以只关注模型结构,而不用操心流程层次的切换。
最后分享一个我调试时的习惯:每拿到一个YOLO版本,我会先跑一遍三行代码,把所有hook的调用顺序和输入输出打印出来。
from ultralytics import YOLO model = YOLO("yolo11n.pt") print(model.predict(source="bus.jpg", conf=0.1, verbose=True)) model.train(data="coco8.yaml", epochs=1, imgsz=64, workers=0, verbose=True) model.val(data="coco8.yaml", imgsz=64, verbose=True)用verbose=True打开日志输出,观察训练流程的打印顺序,能快速定位自定义修改应该插在哪一步。这种“先跑通-再打断点-再改造”的路线,比一上来就啃每行代码的效率高很多,特别是对于初学者来说,强烈建议照搬这个习惯。