做目标检测开发这两年我发现自己最常干的不是改网络结构而是跟推理管道打交道。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(sourcetest.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 为什么核心逻辑放在基类而不是每个模型各自写一套我最开始有个疑惑检测和分类的推理差别那么大为什么还要硬塞进同一个基类后来把代码读完整才明白这些任务的推理管道高度重合。管道的骨架永远是“读输入 - 预处理 - 模型前向 - 后处理 - 可视化/保存”不同任务真正差异化的地方基本只有两个口子预处理口子分类任务需要ToTensorNormalize检测任务需要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 配置合并里那个被悄悄改掉的confBasePredictor.__init__接收两个参数cfg和overrides。第一次读源码时我以为cfg就是默认配置overrides就是用户自定配置两者合并一下就完事。实际上Ultralytics项目用的是get_cfg(cfg, overrides)它会经历“内置默认DEFAULT_CFG - 配置文件 - 用户传参”三层合并。cfg通常是DEFAULT_CFG它是一个SimpleNamespace对象里面装着conf、iou、imgsz、save、show、project、name等几十个字段。有一个细节非常容易被忽略如果用户没有传confself.args.conf就是None随后代码会判断“如果conf是None就强制改成0.25”。意思是默认的置信度阈值根本不是“不设置”而是固定回到0.25。很多人以为自己不指定conf就表示全部保留结果检测结果里没有低置信度目标就是这个原因。如果你想跑出一个尽量多框的结果需要显式写conf0.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的listtorch.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逻辑其中摄像头和流媒体强制batch1的逻辑很多人不理解。原因很简单视频流是逐帧连续的如果你一次性读8帧组成一个大batch再推理那摄像头画面会累积出明显延迟连续视频场景根本没法用。图片模式下batch可以设成8因为读的是静态文件多读几张内存压力也不大。3.2 视频源的vid_stride与预读首帧视频推理还有一个参数vid_stride我最早以为它只影响视频播放速度后来看代码才明白它是视频抽帧步长。vid_stride1是逐帧推理vid_stride3就是每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收进列表再返回。但如果你调用时传了streamTrue行为会变成返回一个生成器每次yield一帧的结果# streamTrue 的用户视角 for result in model.predict(sourcevideo.mp4, streamTrue): print(result.boxes.xyxy)这个功能背后是怎么实现的看predictor.py会发现在predict方法里有一个分支如果self.dataset.mode stream就会把stream_inference放到后台线程里跑主线程用生成器逐帧接收结果。这样做的好处是视频流的采集和推理在后台持续进行调用者不用等全部跑完就能拿到第一帧结果。但是这里有一个我印象很深的坑如果你用streamTrue遍历到一半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_nmsTrue会明显压制密集小目标因为它把不同类别的邻近框也视为重复框。默认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_jsonTrue会额外生成JSON格式的检测结果里面每个目标包含类别、置信度、bbox坐标。如果是分割模型JSON里还会带分割点。这个JSON格式是为了方便对接其他工具链我一般会用它做后处理脚本的输入。控制台统计则比较简单用Counter统计每个类别的目标数量然后拼成一行文字。你每次跑预测看到类似2 persons, 1 car的输出就是这里打印的。5.3 save_predicted_image与可视化save_predicted_image负责在图像上画框、画标签、画mask然后保存。这里有几个细节值得单独说。颜色来自colors(cls, True)Ultralytics项目内置了一张颜色表对person这类常见类别有专门指定颜色。画笔宽度的默认值是根据图像尺寸估算的小图上字体和线宽会自动缩大图上会放大。当plotsTrue时还会额外生成带统计信息的预览图这个图会画在单独的窗口里如果你在无显示环境的服务器上跑要记得把showFalse否则可能报错。保存路径的生成逻辑也很讲究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到255preprocess里通常不会再帮你归一到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大得多。