1. 跑通Demo之后,真正难的是把预测结果用起来
上一篇把 Ultralytics 库的安装和第一个预测 Demo 讲完了,当时有位读者的留言我印象很深:"框是画出来了,但把代码往自己项目里一搬,就不知道从哪里下手。"这句话其实点破了大多数人学习 YOLO 的瓶颈:模型会跑了,但预测结果怎么拿、参数怎么调、视频怎么处理、批量推理怎么做,这些才是从"跑通"走向"能用"的关键。
先说一个很常见的误解。很多人以为model("bus.jpg")返回的是一张画好框的图片,其实它返回的是一个Results列表,里面装的是结构化数据:坐标、置信度、类别索引、推理耗时全都在里面,"画框"只是顺手提供的一个可视化能力。换句话说,YOLO 的输出不是"一张图",而是一堆"可以被程序消费的数字"。你真正要学的,是怎么把这堆数字变成业务需要的格式。
这篇续集的内容围绕几条主线展开:predict 参数的真实用法、Results 对象怎么拆解、不同任务模型(检测、分割、姿态、分类、旋转框)的输出差异、视频流的工程处理,以及如何把预测结果做成服务。全文基于 Python 3.10 以上版本、ultralytics 8.3.x 的接口来写,2024 年底之后的版本基本通用,个别参数差异我会单独标出来。
1.1 预测接口的两种写法
Ultralytics 的模型调用有两种等价写法:
from ultralytics import YOLO model = YOLO("yolo11n.pt") # 会自动下载预训练权重 # 写法一:显式调用 predict 方法 results = model.predict(source="bus.jpg", conf=0.4, iou=0.5) # 写法二:直接调用模型实例 results = model("bus.jpg", conf=0.4, iou=0.5)两种方式最终走的是同一条推理链路,区别只是写法二更简洁,写法一在传参、加注释、做封装时更直观。我个人在工程代码里偏向model.predict(),因为关键字一目了然,别人接手代码时不需要猜。有一点要注意:不管哪种写法,results都是一个list,即使source只给了一张图片,列表里也只有一个元素。忘了这一层,后面遍历或者取下标时就容易出问题。
1.2 source 参数到底能接哪些输入
source的灵活度比很多人想象的大,整理一下常用的输入类型:
| 输入类型 | 示例 | 说明 |
|---|---|---|
| 本地图片 | "images/bus.jpg" | 最基础的用法 |
| 本地目录 | "images/" | 自动遍历目录下所有支持的图片格式 |
| 视频文件 | "video.mp4" | 逐帧推理,配合 stream 参数使用 |
| 摄像头/网络流 | 0/"rtsp://..." | 传整数是本地摄像头索引,传 URL 是 RTSP 流 |
| numpy 数组 | 单张np.ndarray或列表 | 适合接 OpenCV 读取的帧 |
| PIL 图片 | Image.open(...) | 直接传 PIL 对象 |
| 图片列表 | ["a.jpg", "b.jpg"] | 可以混文件路径和数组 |
这里有一个实际中很推荐的做法:在接收 OpenCV 读出来的帧时,把若干帧组成列表再传给模型,比直接把视频文件路径丢给source更好控制:
import cv2 cap = cv2.VideoCapture("video.mp4") frames = [] while True: ret, frame = cap.read() if not ret: break frames.append(frame) if len(frames) == 16: results = model(frames) # 批量推理,GPU 利用率更高 frames = [] cap.release()手动攒 batch 能让 GPU 吞吐量明显提升,尤其是画面内容不复杂、单帧本身很小的视频。我实测过,单帧推理和 4 帧 batch 推理的耗时并不是 4 倍关系,batch 化之后单帧平均耗时常常能降不少。这个习惯对后面做离线批量任务也很有帮助。
2. predict 参数里藏着大量性能开关
很多人用model.predict()只填一个conf就完事,其实它的参数有二十多个,其中相当一部分直接影响预测结果的形态和速度。这里挑几个对日常使用影响最大的讲清楚。
2.1 conf 和 iou:一对容易搞混的阈值
conf是置信度阈值,过滤掉置信度低于它的检测框,默认 0.25。iou是 NMS 阶段用的 IoU 阈值,默认 0.7。两者作用在不同的处理阶段:conf先筛一遍候选框,iou再把重叠度过高的框合并掉。
为什么要分开调?因为它们影响的方向不一样。conf调高,框变少,漏检变多;iou调高,相邻的框更容易被保留下来,适合密集小目标场景。我做过一个货架商品检测的项目,货架上的同类商品紧挨着,默认的 IoU 阈值会把相邻的两个框合并成一个,导致数量统计永远偏少。改成conf=0.3、iou=0.5之后,计数准确率立刻上来了。
调参的时候别只看效果,还要看结果里的耗时分布:
print(results[0].speed) # 输出类似 {'preprocess': 5.2, 'inference': 18.7, 'postprocess': 2.1}如果发现postprocess耗时异常高,多半是单帧检测框数量太多,可以调高conf或者调小max_det来限制。
2.2 imgsz、device、half:在速度和精度之间找平衡
imgsz是推理输入尺寸,默认 640。它不是输出分辨率,但直接决定推理耗时和显存占用。同一张图,imgsz=640推理耗时 20ms 的时候,imgsz=1280通常要 3 倍以上,而精度提升并不成比例。追求实时性的场景可以先降到 480 试试;做大图切块检测或者精细小目标识别的,再考虑升到 1280。
device参数负责选择计算设备:
# CPU 推理 results = model("bus.jpg", device="cpu") # 指定第一张 GPU results = model("bus.jpg", device=0) # 多 GPU 并行 results = model("bus.jpg", device=[0, 1]) # Apple Silicon 使用 MPS results = model("bus.jpg", device="mps")新环境里可以先跑一句torch.cuda.is_available()或者torch.backends.mps.is_available()确认设备可用,别一上来就写死device=0,换台机器直接崩。
half=True开启 FP16 推理,显存占用基本减半,较新的 GPU 上速度通常也能提升百分之二三十。代价是部分老显卡和 CPU 上不可用,小目标检测精度也可能略降。我的做法是:先用 FP32 跑出基准效果,再开half在同一批测试图上对比指标,下降在接受范围内就长期开启。
2.3 几个容易被忽略的工程参数
classes: 只保留指定类别的检测结果,例如classes=[0]只检测"人"。这个参数不只是过滤结果,还会跳过其他类别的后处理计算,在多类别模型上能省一点时间。max_det: 限制单张图的最大检测框数量,默认 300。车流、密集人群场景如果发现后处理很慢,直接调小它。vid_stride: 视频推理时的跳帧间隔,vid_stride=2表示每隔 2 帧检测一帧。不需要逐帧检测的场景,这是性价比最高的优化。verbose: 设为 False 可以关掉命令行里不断刷新检测日志的输出,做服务部署时非常有必要,否则日志会被推理记录刷屏。augment: 开启测试时增强(TTA),会做多尺度推理再合并结果,精度略升但速度明显变慢,一般只用于离线评测。
3. Results 对象的结构与数据提取
新手拿到results时最容易懵:这是一个列表,里面每个元素打印出来是一堆框、置信度、类别的文本,就是不知道怎么转成自己想要的数据结构。其实拆开来看并不复杂。
拿单张图片为例:
from ultralytics import YOLO model = YOLO("yolo11n.pt") results = model("bus.jpg") result = results[0] # 一张图对应一个 Results 对象result上挂了几个重要的属性:
result.boxes:检测框对象,包含坐标、置信度、类别等核心信息result.masks:分割模型的掩码对象,实例分割任务才有result.keypoints:姿态估计模型的关键点,姿态任务才有result.probs:分类模型的类别概率,分类任务才有result.names:类别索引到名称的映射字典,例如{0: 'person', 1: 'bicycle', ...}result.orig_shape:原图的宽高(H, W),坐标回映射到原图时需要它
3.1 把检测框转成数组
最常用的提取方式是这样:
boxes = result.boxes # xyxy 格式:左上角 x, 左上角 y, 右下角 x, 右下角 y xyxy = boxes.xyxy.cpu().numpy() # shape 形如 (N, 4) # xywh 格式:中心 x, 中心 y, 宽, 高 xywh = boxes.xywh.cpu().numpy() # 置信度 conf = boxes.conf.cpu().numpy() # 类别索引 cls = boxes.cls.cpu().numpy() # 直接拿全部数据:xyxy + conf + cls,形状 (N, 6) data = boxes.data.cpu().numpy()注意.cpu().numpy()这一步。boxes.xyxy默认存放在模型推理所用的设备上(比如 GPU),直接.numpy()在 PyTorch 2.x 上会报错,必须先.cpu()再转。这是新手高频报错点,论坛里常看到类似 "can't convert cuda tensor to numpy" 的提问,都是栽在这里。
3.2 结合 names 拼成业务结构
工程里通常不会直接要裸数组,而是转换成带类别名的记录列表:
import numpy as np names = model.names # {0: 'person', 1: 'bicycle', ...} records = [] for *xyxy, conf, cls_id in boxes.data.cpu().numpy(): x1, y1, x2, y2 = [float(v) for v in xyxy] records.append({ "class": names[int(cls_id)], "confidence": float(conf), "bbox": [x1, y1, x2, y2] }) print(records)这种结构可以直接塞进 JSON 返回给前端,也可以存成数据库字段。我习惯封装一个to_records(results)小函数,项目之间复制着用,省得每次重写。不同项目之间唯一要改的就是字段名和输出格式。
3.3 结果可视化与文件导出
除了一行result.plot()画框之外,Ultralytics 还内置了几种导出方式:
# 保存画完框的图片 result.save("output.jpg") # 保存 YOLO 格式的 txt 标签文件 result.save_txt("labels.txt") # 保存每个检测目标的裁剪图 result.save_crop("crops/")如果只是保存可视化结果,可以直接用带参数的方式:
results = model("bus.jpg", save=True, project="runs/predict", name="exp1", exist_ok=True)save=True会在project/name目录下生成推理后的图片。exist_ok=True表示如果上次已经跑过这个目录,直接覆盖而不是自动生成exp2、exp3继续累积。反复跑实验的人应该都懂,这个参数是福音,不然换几个参数跑几次,目录就堆成山。
一个小提示:result.plot()返回的是 OpenCV BGR 格式的图像数组,如果要在 Jupyter 里用plt.imshow显示,记得先cv2.cvtColor(img, cv2.COLOR_BGR2RGB)转成 RGB,不然颜色就会红蓝互换,看着非常奇怪。
4. 不同任务模型的输出差异:seg、pose、cls、obb 一次讲清
YOLO 不只有目标检测。同一条安装命令,可以下载分割、姿态、分类、旋转框四类任务权重。很多人以为换权重就能跑,但拿到结果后不知道去哪里取数据,或者拿错了属性。这一节把差异理清楚。
4.1 分割模型(-seg)
model = YOLO("yolo11n-seg.pt") results = model("bus.jpg") result = results[0] if result.masks is not None: masks = result.masks.data.cpu().numpy() # shape 形如 (N, H, W),每个目标一张掩码 segmented = result.plot() # 画出来带掩码效果masks.data是掩码矩阵,每个像素表示该目标在当前检测框内的分割结果。做后续分析(比如统计目标面积、提取前景像素)时,注意它和原图之间可能是等比缩放过的,需要结合orig_shape和masks.orig_shape做坐标换算。
4.2 姿态模型(-pose)
model = YOLO("yolo11n-pose.pt") results = model("person.jpg") result = results[0] if result.keypoints is not None: kps = result.keypoints.data.cpu().numpy() # shape 形如 (N, 17, 3) kps_conf = result.keypoints.conf.cpu().numpy() # 每个关键点的置信度kps每个关键点的三个值是(x, y, 置信度)。不同模型的骨架定义不同,COCO 17 个关键点有固定的索引顺序,比如第 0 号通常是鼻子,第 5、6 号是左右肩。写业务逻辑前先查一下官方文档里的骨架定义表,不然索引对不上,姿态画出来是歪的。
4.3 分类模型(-cls)与旋转框模型(-obb)
分类模型的输出里没有 boxes,而是概率向量:
model = YOLO("yolo11n-cls.pt") results = model("cat.jpg") result = results[0] print(result.probs.top1) # 最高概率类别的索引 print(result.probs.top1conf) # 最高概率值旋转框模型-obb在检测框基础上增加了旋转角度,输出在result.obb上,坐标是xywhr格式(中心点、宽高、角度):
model = YOLO("yolo11n-obb.pt") results = model("aerial.jpg") result = results[0] obb = result.obb.xywhr.cpu().numpy() # 每行 5 个值这类模型适合航拍、卫星图、文档扫描这类目标本身带旋转的场景,普通俯视摄像头场景用不太上。选择任务类型的逻辑很简单:只要框位置用检测,要目标轮廓用分割,要看人体动作和姿态用姿态,物体没有方向之分用分类,物体有明显方向性(车辆、文字)再考虑 OBB。
4.4 一段代码兼容多种任务
工程里如果要做一套服务支持多类模型,可以根据结果属性判断当前任务类型:
result = results[0] if result.boxes is not None: task = "detect" elif result.masks is not None: task = "segment" elif result.keypoints is not None: task = "pose" elif result.probs is not None: task = "classify"这段逻辑很朴素但很实用。我之前在做一个多模型推理网关时就是用这个方式做任务路由,后面对接的业务方只需要拿到统一的输出结构,不需要关心底层是哪个任务模型。
5. 视频流与摄像头:从"逐帧硬跑"到"流畅可用"
很多人把视频预测写成了model("video.mp4", save=True)——确实能跑,但放在实时摄像头场景完全不适用,因为你需要持续在新的帧上推理,还要对结果做业务判断。真正落地时要转向流式调用。
5.1 stream=True 的生成器模式
from ultralytics import YOLO import cv2 model = YOLO("yolo11n.pt") cap = cv2.VideoCapture(0) # 0 表示第一个本地摄像头 results = model.predict(source=0, stream=True, show=False, verbose=False) for result in results: frame = result.orig_img # 对应的原始帧 boxes = result.boxes if boxes is not None and len(boxes) > 0: for *xyxy, conf, cls_id in boxes.data.cpu().numpy(): x1, y1, x2, y2 = [int(v) for v in xyxy] cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imshow("demo", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break关键就在stream=True。开了这个参数,model.predict()返回生成器,边读视频边推理边输出,不会把整个视频的所有帧一次性加载到内存。处理长视频时这是必须的,否则一个 2 小时的视频堆在内存里直接 OOM。另外result.orig_img就是原始输入帧,省去你自己保存帧的麻烦。
5.2 帧率上不去?先做这三件事
视频推理最常见的抱怨是"卡"。我的排查顺序永远是这三步:
- 确认设备是否真的用了 GPU。见过不少项目,GPU 版 torch 装好了,但代码里没传
device,某些环境默认走了 CPU。打印model.predictor.device确认一下。 - 看
vid_stride有没有设。不需要逐帧检测的场景,跳帧是性价比最高的优化。vid_stride=2配合业务上的插值处理,人眼几乎看不出差异。 - 检查是不是反复在 resize。如果你自己用 OpenCV 读了帧再传给
source,而帧分辨率又远高于imgsz,每次都要从大图缩到小图,不如在读取阶段就统一缩放到imgsz附近。
实测过一台普通配置的笔记本,用 CPU 跑yolo11n.pt,把vid_stride=2、imgsz=480、verbose=False三件套配合起来,视频处理速度从接近 5 倍慢放提升到接近实时体验。
5.3 摄像头断流与多线程处理
摄像头场景还有一个隐藏问题:推理慢的时候,cap.read()会被拖慢,摄像头缓冲区堆底,看到的画面滞后,甚至出现"撕裂"。处理方式是把读取和推理拆到两个线程:读取线程只管把帧塞进队列,推理线程从队列取帧,摄像头一直保持读最新帧。
import queue import threading frame_queue = queue.Queue(maxsize=8) def reader(cap): while True: ret, frame = cap.read() if not ret: break if frame_queue.full(): try: frame_queue.get_nowait() # 队列满了就丢最老的帧 except queue.Empty: pass frame_queue.put(frame) threading.Thread(target=reader, args=(cap,), daemon=True).start()这个模式同样适用于 RTSP 网络流在弱网环境下的抖动问题。队列的maxsize一定要控制,见过有人不设上限,跑一个晚上内存直接吃满,进程被系统杀掉。
6. 批量推理、设备调度与结果对接:把预测做成服务
最后一个大块是工程化。单张图片跑通只是第一步,真实项目里你大概率会遇到这三种情况:离线跑一批图片、机器有多张卡、结果需要落库或者返回给前端。
6.1 批量推理的正确姿势
Ultralytics 对目录输入本身就会逐个处理,但这是串行。想提高吞吐量,把图片路径组成列表一次性传:
image_paths = [f"dataset/{i}.jpg" for i in range(200)] results = model.predict(source=image_paths, batch=32, device=0)batch参数决定内部如何组合这批图,默认-1表示自动根据显存挑选。离线批量跑图时,batch=16或32通常是显存和速度之间不错的平衡点。一个经验:批量跑之前先用一两张图预热(warmup),因为 torch 的 CUDA 初始化在第一次推理时会触发额外开销,预热之后再跑,整体耗时更稳定。
6.2 多设备并行和 MPS 场景
多卡机器可以用device=[0, 1]让模型在多卡上并行推理。注意这时数据会按 batch 自动拆分,所以只有一张图时其实用不到多卡,批量推理才有收益。单卡显存不够但总卡数多的场景,这个方案比把单卡imgsz调小更划算。
Apple Silicon 用户优先考虑device="mps"。实测 M 系列芯片上 MPS 比 CPU 快不少,内存占用也更低。但有个坑:部分第三方算子对 MPS 的支持不完整,如果报算子错误,退回 CPU 反而最省事。这类问题通常和模型版本、torch 版本强相关,升级一下依赖往往能解决。
6.3 把结果转成 JSON 供业务消费
写一个可复用的转换函数:
def results_to_json(results): json_results = [] for result in results: records = [] if result.boxes is not None: for *xyxy, conf, cls_id in result.boxes.data.cpu().numpy(): records.append({ "class": result.names[int(cls_id)], "confidence": float(conf), "bbox_xyxy": [round(float(v), 2) for v in xyxy] }) elif result.probs is not None: records.append({ "top1_class": result.names[result.probs.top1], "confidence": float(result.probs.top1conf) }) json_results.append(records) return json_results这个函数不依赖具体任务类型,检测、分类都能兼容。配合 FastAPI 或 Flask,一个最小的推理服务核心逻辑也就几十行。我在服务里还会额外加一层输入校验:检查上传图片能否被正确解码、尺寸是否超限、批次大小是否合理。宁可多写几个 if,也不要让一个坏输入把整个推理进程打崩。
7. 续篇的落点:三个我一直在用的调优经验
写到这里,基础调用和工程化链路基本串起来了。最后分享三个最近项目里反复验证过的小经验,它们都不在官方文档的显眼位置,但实际价值很高。
第一个是模型实例的线程安全。多个请求并发预测时,很多人把模型对象做成全局单例,结果偶发报错。Ultralytics 官方不保证同一个模型实例跨线程绝对安全,稳妥做法是每个线程用自己的模型实例,或者用锁保护推理调用。模型权重加载很耗时,所以实践里更推荐"模型池":每次推理从池子里取一个实例,用完归还,既避免重复加载又规避并发冲突。
第二个是自定义数据集的权重命名问题。很多人从网上下载别人训练好的.pt文件来跑,发现result.names和预期不一致。要知道names是写进权重文件里的,换个权重就等于换了一套类别映射。排查"检测出来了但类别名不对"的问题,第一步永远是打印model.names确认映射关系,而不是回训练脚本里翻代码。
第三个是可以试试model.track()而不是只停留在predict。它基于预测结果为每个目标生成稳定的 ID,做视频目标计数、进出统计这类需求时,直接用它比自己写帧间 IoU 匹配省事太多:
results = model.track("video.mp4", tracker="bytetrack.yaml", persist=True)persist=True表示跨帧保持同一组 ID,这是很多人第一次用track时漏掉的参数。第一次跑的时候没加它,会看到每个目标每帧都被分配一个新 ID,计数完全没法用,排查了半天才发现是这个参数的问题。
这篇续篇就到这儿。如果你已经能跑通基础预测,按这个顺序把参数、结果提取、任务模型、视频流和批量推理逐项过一遍,基本就能把 YOLO 从"能跑"推进到"能用"。接下来可以往自己数据集上做导出和微调,再把这套预测逻辑包成 API,整条链路就算真正跑通了。