ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Ultralytics YOLO推理源码解析:BasePredictor核心机制与二次开发

Ultralytics YOLO推理源码解析:BasePredictor核心机制与二次开发

做目标检测开发这两年,我发现自己最常干的不是改网络结构,而是跟推理管道打交道。model.predict(source=...)一行代码后面,隐藏着从图像读取、预处理、模型前向、后处理再到结果保存的一整套流程。而把持这套流程的核心,就是ultralytics/engine/predictor.py里的BasePredictor。这篇博客就是我对这个子模块的代码阅读笔记,按真正的执行顺序拆,尽量说清楚每一段代码为什么这么写、实际运行中踩过哪些坑,适合想对YOLO系列做二次开发、或者单纯想搞懂Results对象从哪来的同学。

1. 先定位:predictor.py在一条推理链路中的位置

1.1 从一行model.predict()到BasePredictor

很多人第一次接触Ultralytics项目,都是从一个很简单的调用开始的:

from ultralytics import YOLO model = YOLO("yolov8n.pt") results = model.predict(source="test.jpg")

这行代码看起来简单,背后的调用链却相当长。我当初查源码时追了好几层才找到真正干活的地方。大体流程是这样的:

# 简化后的调用链 YOLO.__call__(source=...) # model(source=...) 实际走这里 └─> YOLO.predict(source=...) # 预测入口 └─> self.predictor = DetectionPredictor(cfg=..., overrides=...) └─> self.predictor.predict_cli(source=...) # 命令行场景 └─> self.predictor.predict(source=...) └─> self.predictor.stream_inference() └─> self.predictor.postprocess(preds, ...)

DetectionPredictor在ultralytics/models/yolo/detect/predict.py里,它继承的是ultralytics/engine/predictor.py中的BasePredictor。换句话说,predictor.py是整个推理框架的“公共服务层”,检测、分割、分类、姿态估计全都建立在这套基座上。DetectionPredictor只负责把模型输出的原始张量解码成人类能看懂的检测框和类别,剩下读数据、造batch、跑前向、存结果这些事,都是BasePredictor在做。

1.2 为什么核心逻辑放在基类而不是每个模型各自写一套

我最开始有个疑惑:检测和分类的推理差别那么大,为什么还要硬塞进同一个基类?后来把代码读完整才明白,这些任务的推理管道高度重合。管道的骨架永远是“读输入 -> 预处理 -> 模型前向 -> 后处理 -> 可视化/保存”,不同任务真正差异化的地方基本只有两个口子:

  • 预处理口子:分类任务需要ToTensor+Normalize,检测任务需要LetterBox等比缩放;
  • 后处理口子:检测要做NMS和坐标还原,分类只输出top1/top5,分割还要把mask恢复到原图尺寸。

所以BasePredictor设计成了模板方法模式的样子:主循环stream_inference把骨架写好,把preprocess和postprocess留成“虚位”,子类按需覆写。BasePredictor基类里的postprocess基本是个空壳,ClassificationPredictor就直接在postprocess里对模型输出做softmax,然后组装成Results。

我整理了一张表,方便快速看清每个方法的职责和子类覆盖情况:

方法名职责是否常被子类重写
__init__合并配置、初始化各类状态属性、创建保存目录基本不重写
setup_source解析用户传入的source,生成可迭代的dataset基本不重写
preprocess把原始图像转成模型输入张量分类任务会重写
stream_inference主循环,串起预处理、前向、后处理、保存极少数情况重写
postprocess把模型输出变成Results对象检测/分割/姿态都重写
write_results写txt/json、打印统计、可视化标注偶有重写
teardown释放视频写对象、摄像头等资源基本不重写

读懂这张表,你再看predictor.py就不会迷路。接下来我按代码执行顺序,把每一段重点啃一遍。

2. __init__里的默认值陷阱与属性设计

2.1 配置合并里那个被悄悄改掉的conf

BasePredictor.__init__接收两个参数:cfg和overrides。第一次读源码时,我以为cfg就是默认配置,overrides就是用户自定配置,两者合并一下就完事。实际上Ultralytics项目用的是get_cfg(cfg, overrides),它会经历“内置默认DEFAULT_CFG -> 配置文件 -> 用户传参”三层合并。cfg通常是DEFAULT_CFG,它是一个SimpleNamespace对象,里面装着conf、iou、imgsz、save、show、project、name等几十个字段。

有一个细节非常容易被忽略:如果用户没有传conf,self.args.conf就是None,随后代码会判断“如果conf是None,就强制改成0.25”。意思是,默认的置信度阈值根本不是“不设置”,而是固定回到0.25。很多人以为自己不指定conf就表示全部保留,结果检测结果里没有低置信度目标,就是这个原因。如果你想跑出一个尽量多框的结果,需要显式写conf=0.001这种极低值,而不是不传。

__init__里还调用了get_save_dir(self.args),它会根据project和name决定输出目录。默认情况是runs/detect/predict,如果这个目录已经存在,就自动往后编号变成predict2、predict3。这个“自动改名”的逻辑在训练、验证、预测模块里是通用的,好处是用户不用手动清理历史结果,坏处是脚本里如果依赖固定输出路径,就可能拿到旧文件。

2.2 海量“先None后赋值”的属性

BasePredictor.__init__的后半段是我见过最“无聊”又最“关键”的代码:几十行都是在把属性初始化成None。

self.model = None self.device = None self.dataset = None self.vid_path = None self.vid_writer = None self.annotator = None self.data_path = None self.batch = None self.predictions = None self.callbacks = _callbacks or {} self.done_warmup = False

这些属性不是一次性初始化完就不动了,而是分散在setup_source、preprocess、stream_inference这些方法里按需填充。这种写法的核心思路是“把predictor对象当作一个状态容器”。方法之间不靠参数传递数据,而是你写self.batch、我读self.batch,整个对象内部共享同一份状态。

这种设计让每个方法签名都很简洁,但也提高了阅读门槛。我读stream_inference时经常要回头翻__init__和setup_source确认某个self.xxx是从哪来的。给你的阅读建议是:把__init__里的属性表当作“字典索引”,遇到不认识的self.xxx,先在__init__搜一遍,再在setup_source和preprocess里搜赋值点,基本就能锁定来源。别指望从上到下一次读懂。比如self.vid_cap在__init__里没有,是在setup_source里赋值为self.dataset.vid_cap的。

3. setup_source:把图片、视频、摄像头、Tensor全部收编

3.1 load_inference_source的类型判定逻辑

setup_source的核心是调用load_inference_source(source)。我第一次读到这里时,最大的震撼是Ultralytics项目对输入源的支持居然这么全。一个source参数可以是:

  • 图片文件路径:test.jpg
  • 文件夹路径:./images/,会扫描目录下所有支持格式的图片
  • glob模式:./images/*.jpg
  • 视频文件路径:test.mp4
  • 网络流地址:RTSP、RTMP等
  • 摄像头编号:0代表第一个摄像头
  • PIL.Image对象
  • numpy.ndarray数组,可以是一张图,也可以是一个batch的list
  • torch.Tensor张量
  • 已经创建好的cv2.VideoCapture对象
  • 迭代器/生成器

load_inference_source会返回一个SourceTypes命名元组,里面带有dataset、imgsz、vid_stride等信息。最关键的是它会区分数据模式,常见有image、video、stream等。我把几种常用source类型和注意事项整理成了表:

source类型返回模式注意事项
图片文件/文件夹/globimage / image_batch自动收集所有图像,按batch大小分批返回
视频文件video按vid_stride跳帧读取,默认一帧不跳
摄像头编号streambatch会被强制设为1,避免延迟
RTSP/RTMP流stream网络不稳定时容易卡在读取上
numpy.ndarrayimage默认要HWC uint8格式,BGR通道
torch.Tensortensor默认假设已经做过预处理,少走letterbox逻辑

其中摄像头和流媒体强制batch=1的逻辑,很多人不理解。原因很简单:视频流是逐帧连续的,如果你一次性读8帧组成一个大batch再推理,那摄像头画面会累积出明显延迟,连续视频场景根本没法用。图片模式下batch可以设成8,因为读的是静态文件,多读几张内存压力也不大。

3.2 视频源的vid_stride与预读首帧

视频推理还有一个参数vid_stride,我最早以为它只影响视频播放速度,后来看代码才明白它是视频抽帧步长。vid_stride=1是逐帧推理,vid_stride=3就是每3帧取1帧推理。这个参数在长视频分析里特别实用,因为视频相邻帧内容高度相似,跳帧可以省大量计算,代价是可能漏掉中间帧的瞬时变化。

setup_source还有一个容易忽略的细节:它会先启动warmup。这不是大模型的预热,而是指在正式循环前先预读一帧视频,确保视频源是可读的,同时把第一帧的shape拿出来做后续预处理尺寸对齐。如果视频文件损坏或者摄像头没连上,这个阶段就会报错,而不是拖到主循环里才崩溃。从这个角度看,setup_source其实承担了一部分“前置校验”的任务。

3.3 setup_source与self.dataset的迭代约定

self.dataset在__init__里是None,在setup_source里被赋值为可迭代对象。它设计的接口是:每次next(self.dataset)返回一批原始图像。在图片模式下,这个“一批”可能包含多张图;在视频流模式下,永远只有一帧。这个约定非常重要,因为后续preprocess和stream_inference都假设拿到的self.batch是一个list或tensor。

我以前在自定义source时踩过一个坑:直接把source设成一个生成一个numpy数组的生成器,结果load_inference_source不认识这个类型,或者识别成“单张图像模式”后把生成器对象当成图像处理,直接报错。后面我学乖了,严格按它认识的类型来,要么传入PIL.Image,要么传入list of np.ndarray,不要传裸生成器。如果你确实想传迭代器,就去看load_inference_source源码里有没有对应的分支,而不是凭感觉猜。

4. stream_inference:推理主循环的节奏控制

4.1 主干循环到底做了什么

stream_inference是整个predictor.py里最核心也最长的方法。主循环的骨骼大概长这样,我简化了异常处理和分支细节,只留主干:

# 示意代码,非逐行精确还原 def stream_inference(self): for self.batch in self.dataset: # 1. 预处理 self.batch = self.preprocess(self.batch) # 2. 模型前向 self.predictions = self.model(self.batch) # 3. 后处理,得到 Results 列表 self.results = self.postprocess(self.predictions, self.batch, self.batch.shape) # 4. 按配置决定保存/显示/写txt for result in self.results: if self.args.save or self.args.show: self.save_predicted_image(result) self.write_results(result) # 5. 打印进度和类别统计 self.print_results()

先看第1步“预处理”。self.preprocess接收的是原始图像列表,内部做LetterBox缩放、BGR转RGB、HWC转CHW、归一化到0-1,最后堆叠成模型输入张量,再搬到对应设备和半精度格式。注意这里的self.batch被重新赋值了,前一步还是原始图像,后一步就变成了模型输入张量。所以你在postprocess里要访问“当前这批输入图像”时,拿到的是预处理后的张量。而原始的、未缩放的图片,需要另外从dataset里取,或者通过self.batch之外的机制传。

第2步“模型前向”看似简单,实际也很关键。self.model不是普通的PyTorch模型,而是AutoBackend封装后的对象,能跑PyTorch、ONNX、TensorRT、OpenVINO等多种后端。它的调用方式和普通nn.Module类似,但内部会自动处理不同后端的差异。TensorRT导出的模型甚至要求输入batch固定,如果你改动了imgsz或batch大小,它会报错。这些坑不在predictor.py内,但主循环是触发它们的地方。

第3步“后处理”得到self.results,是一个Results对象列表。每个Results保存了原图、检测框、类别置信度、分割mask、关键点、耗时等数据。后续所有保存、打印、可视化都从Results取值。

从这段循环能看到一个明显特征:self.batch、self.predictions、self.results在每次迭代中都被覆写。好处是内存天然复用,不会越积越多;坏处是如果你想保留上一帧结果,必须自己在循环外复制一份,否则下一帧就覆盖了。

4.2 流式推理的线程与生成器设计

stream_inference在默认非流式模式下会一口气跑完整个dataset,把所有Results收进列表再返回。但如果你调用时传了stream=True,行为会变成返回一个生成器,每次yield一帧的结果:

# stream=True 的用户视角 for result in model.predict(source="video.mp4", stream=True): print(result.boxes.xyxy)

这个功能背后是怎么实现的?看predictor.py会发现在predict方法里有一个分支:如果self.dataset.mode == 'stream',就会把stream_inference放到后台线程里跑,主线程用生成器逐帧接收结果。这样做的好处是,视频流的采集和推理在后台持续进行,调用者不用等全部跑完就能拿到第一帧结果。

但是这里有一个我印象很深的坑:如果你用stream=True遍历到一半break了,后台线程和视频捕获对象很可能不会被立即释放。这个循环没有自动的try/finally保洁机制,程序退出时容易卡在视频资源释放上。所以用流式推理时,要么让循环自然结束,要么显式调用清理逻辑,千万不要在长视频中间随意break,否则下一次打开摄像头时会提示占用。

4.3 性能计时与warmup的隐藏逻辑

主循环里还有一部分是性能计时。代码中会有dt这样的字典,分别记录预处理耗时、模型前向耗时、后处理耗时,最后写入Results.speed。很多人在分析推理瓶颈时只看模型前向时间,其实在视频流场景里预处理和后处理经常占到30%以上,尤其是后处理里的NMS和坐标还原,加上可视化绘制,开销并不比模型推理小多少。这也是为什么Ultralytics的stream_inference会专门分出计时字段,方便用户定位瓶颈。

另一个值得说的是warmup。在predict真正进入主循环前,会判断self.done_warmup是否为False,如果是就构造一个全0的假张量跑一次前向。这个假前向的作用是触发CUDA上下文初始化、加载算子kernel、分配显存。如果你没有warmup,第一次真实推理会因为CUDA初始化而明显卡顿,且这个延迟会被算进首帧耗时。Ultralytics把warmup放在主循环前面,本质上是把一次性开销“前置摊销”。我测试过,加了warmup后首帧延迟能降不少,用户体验差别挺明显的。

5. postprocess与write_results:从张量到人眼可读结果

5.1 DetectionPredictor.postprocess里的坐标还原

BasePredictor里的postprocess几乎是空壳,真正干活的是各任务子类。以DetectionPredictor为例,它做的事情可以分成几步:NMS、坐标还原、组装Results。

NMS这一步会用到ops.non_max_suppression,参数包括conf、iou、agnostic_nms、max_det、classes。agnostic_nms如果设为True,所有类别一起做NMS,而不是每个类别单独做。我之前在类别不平衡的数据集上试过,agnostic_nms=True会明显压制密集小目标,因为它把不同类别的邻近框也视为重复框。默认False就够了,除非你的场景里不同类别不应该重叠。

坐标还原是我认为整个后处理里最容易出错的地方。模型输入前,图像被LetterBox缩放并填充到了正方形,模型预测出的框坐标也是在这个正方形坐标系里的。要得到原图坐标,必须调用ops.scale_boxes,参数要传预处理后的shape和原图shape。如果你在自定义preprocess里改了缩放方式,却没同步修改postprocess里的还原逻辑,最典型的现象就是“检测框位置整体偏移”或“框比目标大一圈”。

5.2 write_results里的格式细节

write_results这个方法是保存逻辑的核心,我第一次读它时被一大段if/else绕晕了。它主要处理三种输出:txt标签文件、JSON结果、控制台统计。

txt标签文件默认保存在save_dir/labels/xxx.txt。格式是归一化的坐标值,也就是cls x_center y_center width height。这里有个坑是,不同数据集标注格式不一样,有的用xyxy,有的用xywh。Ultralytics项目在readme和代码注释里反复强调“labels are normalized xywh”,但实际使用时如果你拿COCO格式的标注直接喂进去,坐标就对不上。我早期做项目时就是因为没注意归一化和xywh这两点,训练出来的模型mAP看起来正常,但可视化框全是歪的。

如果设置save_json=True,会额外生成JSON格式的检测结果,里面每个目标包含类别、置信度、bbox坐标。如果是分割模型,JSON里还会带分割点。这个JSON格式是为了方便对接其他工具链,我一般会用它做后处理脚本的输入。

控制台统计则比较简单:用Counter统计每个类别的目标数量,然后拼成一行文字。你每次跑预测看到类似2 persons, 1 car的输出,就是这里打印的。

5.3 save_predicted_image与可视化

save_predicted_image负责在图像上画框、画标签、画mask,然后保存。这里有几个细节值得单独说。

颜色来自colors(cls, True),Ultralytics项目内置了一张颜色表,对person这类常见类别有专门指定颜色。画笔宽度的默认值是根据图像尺寸估算的,小图上字体和线宽会自动缩,大图上会放大。当plots=True时还会额外生成带统计信息的预览图,这个图会画在单独的窗口里,如果你在无显示环境的服务器上跑,要记得把show=False,否则可能报错。

保存路径的生成逻辑也很讲究:self.save_dir在__init__里就已经确定,save_predicted_image每次保存前会检查目录是否存在,不存在就创建。文件名默认取原图文件的stem,也就是不带扩展名的部分,再补上_detected之类的后缀。如果你传入的source是摄像头或者numpy数组,没有文件名可用,系统会自动生成时间戳名称。

在实际项目里,我经常想把save_predicted_image的输出格式改成数字编号,或者把多个模型的检测结果画在同一张图上。这时候不要硬改基类,而是继承DetectionPredictor覆写save_predicted_image,或者直接在拿到results列表后自己用Annotator重新绘制。

6. 读源码后的三条实战忠告

6.1 二次开发别改主循环,覆写子方法

读完predictor.py,很多人第一个冲动是“我要把stream_inference改成我想要的样子”。我强烈建议你不要这么做。主循环承载了预处理、前向、后处理、保存、打印、回调、线程管理、性能统计这么多职责,一旦改动,后续升级版本时几乎必然冲突。

更稳妥的二开路径是这样:要改输入逻辑就覆写preprocess,要改解码逻辑就覆写postprocess,要改保存格式就覆写write_results。这样改动范围最小,而且能复用基类里成熟的流程控制。我自己的一个项目需要给检测结果加一个自定义跟踪ID,就是只覆写了DetectionPredictor.postprocess,在组装Results前把跟踪ID存到result的自定义属性里,改动不到一百行,效果很好。

6.2 自定义source时最容易踩的类型问题

如果你在脚本里直接传numpy数组给model.predict(),注意以下几条约定,否则报错率极高:

  • 默认按OpenCV方式读图,也就是HWC布局、BGR通道顺序、uint8类型,shape类似(480, 640, 3);
  • 如果传的是float数据,记得范围是0到1还是0到255,preprocess里通常不会再帮你归一到0到1之外的事;
  • 如果传多个图,不要直接传list嵌套到底,最安全的是[np.ndarray, np.ndarray]这样的list,每个元素是单张图;
  • 如果传torch.Tensor,通常走的是“已经预处理过”的分支,就跳过letterbox这类操作了。

我第一次传入float32的0到255图像时,结果完全没有框,后来发现是normalize那步出了偏差。从那以后我就养成习惯:自定义输入前先看一眼preprocess实现,确认它每一步对输入格式的假设。

6.3 显存、线程与资源释放的个人经验

最后聊聊资源管理。predictor.py在一整条推理链路里会占用显存、文件句柄、摄像头资源。长时间跑视频流或摄像头时,如果你发现显存占用线性上涨,通常是某个环节把每一帧的Results保存到了列表里,比如你自己在循环里做了all_results.append(result)。Results对象本身带有原图,一张1080P图就是几MB,积累几千帧后内存自然是天文数字。

需要用线程时也要小心:stream_inference把主循环放在后台线程后,主线程和后台线程共享self.results这些属性,存在数据竞争的可能。虽然Ultralytics项目用了一些技巧来规避,但你在自己项目里最好不要在生成器外部再去访问predictor.results,因为你不知道哪一帧会被下一条yield覆盖。

还有一点是摄像头释放。如果你试过在Jupyter里多次运行模型预测,然后系统提示摄像头被占用,多半是上一次的vid_cap没有释放。Ultralytics项目的teardown方法里有释放逻辑,但在异常退出或break的情况下不一定会被执行。稳妥做法是在自己的代码里显式del predictor,必要时再torch.cuda.empty_cache()清一下显存缓存。

读这段代码,我最大的体会是:一个成熟的推理框架不只是在“调模型”,而是在处理数据读取、资源管理、流控、格式兼容这些脏活累活。predictor.py看起来平平无奇,但它把目标检测从“能跑”变成了“好用”。如果你也正在啃这份源码,建议不要光看分支,多问问“为什么这个循环要这样设计”,收获会比单纯记API大得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进