如果你正在做目标检测项目,每次模型推理完之后还要自己画框、写标签、做跟踪、统计人数、保存视频,roboflow / supervision 这个库值得认真研究一遍。它不是一个新检测模型,而是一套围绕检测结果设计的后处理和标注工具,核心价值是把不同模型的输出统一成 Detections 结构,然后提供画框、标签、目标跟踪、区域统计、视频写入等模块化组件。适合已经在用 YOLO 等模型、但不想每次重复写 OpenCV 后处理代码的人。最值得关注的是:模型输出一旦转成 Detections,后续所有处理都围绕同一个数据对象展开,单图、视频、批量任务可以复用同一条处理链路。下面我按实际落地顺序拆一遍,从环境、单图、视频、跟踪、批量到排查。
1. 先搞清楚 supervision 解决的是哪一段问题
1.1 目标检测流程里,推理完成后才是重复劳动开始
一个目标检测项目通常分为两段:模型加载和推理输出;推理结果的可视化、统计、落盘。前一段已经有 YOLO 系列、RT-DETR、OpenMMLab 等很多成熟方案,后一段却经常是每个人自己写一套。常见的做法是拿到模型输出的坐标、置信度、类别 ID,再用 OpenCV 的rectangle和putText逐帧画框。只处理一张图时,代码确实不多。但项目一旦涉及视频、多个类别、跟踪、区域人数统计、结果保存,重复代码会迅速膨胀:坐标要转成整数,类别 ID 要映射成名称,置信度要格式化成字符串,遮挡目标要处理,视频编码要调整参数。
supervision 把这一整段收敛成几个模块。Detections 负责统一数据结构,BoxAnnotator 画框,LabelAnnotator 画标签,ByteTrack 做目标跟踪,PolygonZone 和 LineZone 做区域统计,VideoSink 负责写视频。你可以按需组合,而不是引入一个重型框架。
1.2 相比裸写 OpenCV,它强在哪里
第一,统一数据流。来自不同检测框架的推理结果,都能转换成 Detections。后续操作不关心结果来自 YOLO、YOLOv5 还是 transformers。模型切换时,只需要改转换那一行,后面的画框、跟踪、统计代码基本不用动。
第二,组件可以组合。画框、标签、跟踪、区域统计都是独立对象,用哪个就实例化哪个,不会把所有功能耦合在一起。这种设计对调试很友好,出了问题能很快定位是哪一层。
第三,视频链路有封装。帧读取、视频信息、结果写入、逐帧标注由VideoInfo、get_video_frames_generator、VideoSink配合完成,比每次手动设置VideoWriter更省事,也少一些低级别参数错误。
1.3 边界也先说清楚
它不负责识别目标是什么,只负责处理已经识别出来的结果。如果你还没有一个能稳定输出检测框的模型,先解决模型问题,再回来用它。它也不是任务调度平台,适合作为图像和视频后处理层的工具。大规模分布式任务,仍然需要自己设计队列、重试、分布式存储。还有一点要特别注意:supervision 的 API 在版本迭代中会有调整,网上教程里的代码可能对应不同版本,跑不通时先检查你安装的版本,而不是怀疑代码抄错了。
2. 写代码前,先把环境和数据流转理清楚
2.1 运行环境与安装
最省事的方案是在 Python 3.9 以上的虚拟环境里安装。Windows、Linux、macOS 都能用,但我不建议直接装到系统 Python 里。视觉项目依赖很多,OpenCV、NumPy、模型框架之间容易互相影响,虚拟环境能省掉大量版本冲突问题。
python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install supervision如果你的检测模型来自 Ultralytics YOLO,还要同时安装:
pip install ultralytics安装完成后,先验证一下导入是否正常:
python -c "import supervision; print(supervision.__version__)"如果 import 失败,优先怀疑两个问题:是不是装到了另一个 Python 环境;是不是环境中已有老版本 OpenCV 导致接口冲突。不要急着重装系统,先看which python和pip list | grep supervision。
2.2 核心概念:Detections
Detections 是 supervision 的核心数据结构。它的本质是把一批检测结果封装起来,主要包含:
xyxy:边界框坐标,格式是左上角和右下角两个点。mask:语义分割或实例分割的掩膜。confidence:每个目标的置信度。class_id:类别索引。tracker_id:跟踪器分配的目标 ID。data:附加数据,用于向前传递其他信息。
后续的标注、跟踪、统计,都读取这些字段。最常见的转换方式是:
import supervision as sv detections = sv.Detections.from_ultralytics(result)如果你用的是其他框架,也有from_yolov5、from_transformers等转换入口。不同版本支持的来源可能不同,以你安装的版本为准。
2.3 为什么先理清数据流转
实际踩坑时,最容易出问题的不是 API 调用,而是没想清楚数据在三种格式之间怎么流动:模型原始输出,Detections,标注结果。很多人拿到一段代码直接改路径就跑,报错后只盯着 API 名字看,却忽略了自己的模型返回格式不一样。
我建议动手前先打印一下中间结果。比如用 YOLO 时,先看result.boxes里有多少检测框;转成 Detections 后再打印detections.xyxy、detections.class_id、detections.confidence。确认这些字段有值,再继续往下写。这一步能排除大量“看起来是标注问题,实际是模型输出问题”的情况。
3. 第一个可运行 Demo:单张图片的检测与可视化
3.1 最小代码流程
先跑通一张图,后面的视频和批量才有基础。下面是一段完整可参考的示例:
import cv2 import supervision as sv from ultralytics import YOLO model = YOLO("yolov8n.pt") image = cv2.imread("demo.jpg") result = model(image)[0] detections = sv.Detections.from_ultralytics(result) box_annotator = sv.BoxAnnotator() label_annotator = sv.LabelAnnotator() labels = [ f"{model.names[class_id]} {confidence:.2f}" for class_id, confidence in zip(detections.class_id, detections.confidence) ] annotated = box_annotator.annotate( scene=image.copy(), detections=detections ) annotated = label_annotator.annotate( scene=annotated, detections=detections, labels=labels ) sv.plot_image(annotated)这段代码做了四件事:加载模型、推理图片、把结果转成 Detections、画框和标签后展示。
3.2 关键参数说明
model.names是 Ultralytics 自带的类别名映射。如果你的模型来自其他框架,要自己准备一个id -> name的字典,不能照抄这段。
BoxAnnotator常用参数有thickness、color。color可以指定一个固定颜色,也可以不传,让标注器按类别自动分配。
LabelAnnotator常用参数有text_scale、text_thickness、text_color、text_padding。文字标签默认放在框的左上角附近,实际表现会受文本长度和边界框位置影响。
这里有一个容易忽略的细节:annotate方法的第一个参数是scene。我传入image.copy(),是因为标注器默认会在原图像上直接画。如果不复制,原图会被覆盖。后面如果还要用原图做其他处理,必须保留一份原始数据。
3.3 成功标准和验证
跑通代码不代表就结束了,还要检查三件事:
- 图片能不能正常显示。
- 框是否贴合目标,有没有明显偏移。
- 类别名称和置信度是否和画面内容对应。
如果图片显示出来颜色不对,大概率是 RGB 和 BGR 通道问题。supervision 按 OpenCV 的 BGR 惯例处理,sv.plot_image也按 BGR 显示。不要把其他地方读出来的 RGB 图直接传进去,否则蓝和红会互换。
如果画面里没有任何框,先看模型输出,不要怀疑标注器。你可以在转换前打印result.boxes,或者打印detections.xyxy,确认检测数量是否为零。很多时候不是代码的问题,而是这张图里确实没检测到目标,或者检测阈值太高。
4. 从单图到视频:循环、帧率与输出文件
4.1 视频入口和帧迭代
视频任务不是简单把图片循环放大。视频有帧率、尺寸、编码、时长这些额外信息,自己用cv2.VideoCapture和cv2.VideoWriter写也不是不行,但参数多,容易出错。supervision 把这一层封装成了三个组件:
VideoInfo.from_video_path:读取视频信息。get_video_frames_generator:逐帧读取视频。VideoSink:保存输出视频。
下面是一个完整的视频处理示例:
import cv2 import supervision as sv from ultralytics import YOLO model = YOLO("yolov8n.pt") video_info = sv.VideoInfo.from_video_path("input.mp4") generator = sv.get_video_frames_generator("input.mp4") box_annotator = sv.BoxAnnotator() label_annotator = sv.LabelAnnotator() with sv.VideoSink("output.mp4", video_info) as sink: for frame in generator: result = model(frame)[0] detections = sv.Detections.from_ultralytics(result) labels = [ f"{model.names[class_id]} {confidence:.2f}" for class_id, confidence in zip(detections.class_id, detections.confidence) ] annotated = box_annotator.annotate( scene=frame, detections=detections ) annotated = label_annotator.annotate( scene=annotated, detections=detections, labels=labels ) sink.write_frame(annotated)这里每一帧都做了一次完整推理。如果你的视频是 1080p、30FPS、10 分钟,实际要处理 18000 帧,耗时取决于模型推理速度,而不是 supervision 本身。
4.2 为什么用 VideoSink 而不是自己写 VideoWriter
自己写 OpenCV 的 VideoWriter 时,经常要手动指定cv2.VideoWriter_fourcc、帧率、宽高。漏一个或写错一个,输出文件就可能打不开,或者尺寸和源视频不一致。
VideoSink 的好处是能直接借用 VideoInfo 里的帧率、宽度、高度和编码信息,减少大部分低级错误。如果你想换编码或重新指定输出参数,可以先构造一个VideoInfo,改好字段后再传给 VideoSink。
有一点要注意:输出尺寸最好和源视频保持一致。如果模型输入尺寸和输出尺寸不一致,最终写出的视频尺寸仍会按 VideoInfo 走。强行改变宽高时,要么在画面上做缩放,要么注意是否导致文件异常。
4.3 视频任务先测帧率再跑全片
视频处理最容易翻车的就是一上来直接跑整段长视频。建议先取前 50 帧或前 10 秒测试,统计每帧耗时,再估算全片总时长。
判断标准很直接:如果单帧推理 100 毫秒,一秒钟大约处理 10 帧;一段 10 分钟的视频有 18000 帧,大约需要 1800 秒,也就是 30 分钟。如果你原本预期几分钟跑完,这个结果就不符合预期,需要先优化模型输入尺寸、推理后端或硬件配置。
实时摄像头任务更严格。30FPS 视频每一帧间隔约 33 毫秒,如果单帧处理超过这个时间,就没法做到实时。低配机器能跑,不代表能按实时帧率跑,这是两件事。
显存和内存也要盯。用 GPU 推理时,可以用nvidia-smi看显存占用;用 CPU 时,看内存和 CPU 占用。如果视频处理速度越来越慢,往往不是模型问题,而是前面帧没有被释放,或者内存不足触发了大量换页。
5. 目标跟踪与区域统计:从画框到业务指标
5.1 用 ByteTrack 给目标分配稳定 ID
画框只能表示当前帧有哪些目标。很多业务需要知道同一个目标是否持续存在,比如人流量统计、车辆逗留时间、越界报警。这时要用目标跟踪器。
supervision 内置了 ByteTrack。用法很简单:
tracker = sv.ByteTrack() tracked_detections = tracker.update_with_detections(detections) labels = [ f"#{tracker_id} {model.names[class_id]} {confidence:.2f}" for tracker_id, class_id, confidence in zip( tracked_detections.tracker_id, tracked_detections.class_id, tracked_detections.confidence ) ]注意顺序:必须先转成 Detections,再交给 tracker。如果你把模型原始输出直接传进去,会报错。跟踪是在已经检测到的目标之间做关联,它不能替代检测。
5.2 区域统计和越线计数
人数统计和区域巡检是常见需求。如果你的业务是判断某个多边形区域内有没有目标、有多少目标,可以用 PolygonZone。
import numpy as np zone = sv.PolygonZone( polygon=np.array([ [100, 100], [500, 100], [500, 400], [100, 400] ]), triggering_anchors=sv.Position.CENTER ) zone.trigger(detections=detections) count = zone.current_count如果要统计一条线两侧的进出数量,可以用 LineZone。它根据目标中心点相对一条线的位置变化,判断目标是从左边进入还是从右边出去,适合出入口计数。
line_zone = sv.LineZone( start=sv.Point(0, 400), end=sv.Point(1280, 400) ) line_zone.trigger(detections=detections) in_count = line_zone.in_count out_count = line_zone.out_count这里有一个关键参数:triggering_anchors。它决定用目标上的哪个点作为判断依据。默认用中心点比较稳妥。但如果目标很大,中心点还没进入区域,边缘已经先到了,判断就会延迟。这时可以换成BOTTOM_CENTER或TOP_CENTER等锚点,具体选择要看目标和业务方向。
5.3 实际业务里的参数取舍
检测置信度阈值不要开太低。区域统计对误检非常敏感,一个误检框可能让计数多跳很多次。建议先过滤低置信度结果,再进入跟踪和统计。你可以用Detections的过滤方法,也可以在做区域判断前手动筛一遍。
跟踪器也有参数。比如目标丢失多少帧后删除轨迹。默认值适合大多数场景,但如果是高遮挡环境,目标经常被临时挡住,可以把丢失帧数调大;如果是目标快速移动,前后帧位置变化大,可能需要调整匹配阈值。没有万能参数,先跑一段小样本看 ID 稳定性。
判断跟踪和统计效果,可以固定一个目标,观察三件事:
- 目标在区域内来回走动时,计数是否只按预期增加。
- 两个目标交错后,ID 是否发生互换。
- 目标离开后,计数是否会停留过久。
这些都要通过可视化标注加日志一起观察,只看最终数字很难定位问题。
6. 批量处理与功能封装:从能跑到敢跑
6.1 批量之前想清楚三件事
测试阶段跑一张图、一段视频,问题通常不大。一旦进入批量,很多预想不到的问题会冒出来。我建议批量处理前先把这三件事定下来:
- 输入怎么组织:图片放在一个目录,视频路径写在 txt 文件里,还是从接口逐个接收。
- 输出怎么命名:如何避免覆盖已有文件,失败文件如何处理。
- 稳定性怎么保证:一批任务中某个文件失败,是跳过、重试,还是整个流程退出。
很多人一上来就写一个巨大的循环,感觉能用就行,结果跑到第 30 个文件时崩溃,前面 29 个白跑。批量任务不能只看能不能跑,还要看失败重试、队列、日志和输出一致性。
6.2 把处理流程封装成函数
不管是单图还是视频,尽量把核心逻辑封装成一个函数。函数的好处是单文件容易测试,出错时可以单独调用,也方便以后接入接口或并行处理。
def process_image(image_path, output_path, model, box_annotator, label_annotator): image = cv2.imread(image_path) if image is None: raise ValueError(f"cannot read image: {image_path}") result = model(image)[0] detections = sv.Detections.from_ultralytics(result) labels = [ f"{model.names[class_id]} {confidence:.2f}" for class_id, confidence in zip(detections.class_id, detections.confidence) ] annotated = box_annotator.annotate( scene=image.copy(), detections=detections ) annotated = label_annotator.annotate( scene=annotated, detections=detections, labels=labels ) cv2.imwrite(output_path, annotated) return len(detections)封装之后,批量循环就变成简单的遍历。函数内部单文件失败时,外层能捕获异常,不会让整个批量任务中断。
6.3 日志、失败重试和输出命名
批量场景要记录每个文件的处理结果。建议至少记录四样信息:文件路径、成功还是失败、检测数量、耗时。你可以把它们写到 CSV,也可以写到日志文件,后面排查时能直接看到哪个文件在什么阶段出问题。
失败重试不能省。很多任务失败是暂时性的,比如文件被其他进程占用、输出目录不存在、显存瞬间不足。建议在循环里用try/except捕获异常,把失败文件记录下来,最后统一重试。不要碰到一个失败就退出整个流程。
重试后仍然失败的任务,要看日志。常见原因不是模型判断错误,而是输入路径不存在、图片损坏、输出目录没有写权限。
批量输出命名可以用“源文件名加后缀”的方式,例如name_detected.jpg。如果输出目录里已经存在同名文件,先确认是否允许覆盖。不允许覆盖时,加上时间戳或序号。
7. 我实际踩过的坑和排查顺序
7.1 启动阶段最常见的问题
启动阶段最容易出问题的地方,不是核心逻辑,而是环境。
如果你安装后 import 失败,先不要重装系统,检查两件事:
- 当前 Python 是不是你安装时用的同一个环境。
- 是否有多个 OpenCV 版本在干扰。
你可以运行:
python -c "import supervision; print(supervision.__version__)"如果这个能通过,但你的项目代码里 import 失败,那就是项目解释器和命令行解释器不一致。
另外一个常见问题是转换方法报错。比如Detections.from_ultralytics不存在,多半是 version 差异。supervision 版本更新比较频繁,不同版本对模型来源支持不完全一样,先看安装版本的帮助文档,再看代码。
如果图片显示出来了,但框上没有类别文字,检查labels是否传给了LabelAnnotator,以及labels的数量是否和detections数量一致。长度对不上时,很多版本会直接报错,有些版本则安静地不显示文字。
7.2 运行中卡住、无输出、速度慢的排查链路
程序运行中卡住或没有输出,排查顺序很重要。我习惯这样看:
- 先看现象。是卡住不动,还是一直在跑但没结果。这两种情况处理方式完全不同。
- 再看输入。图片能不能正常读取,视频路径是否存在,帧生成器是不是返回了空。
- 再看日志。程序有没有在关键节点输出信息,有没有异常被吞掉。
- 再看资源。用
nvidia-smi看显存,用top或任务管理器看内存和 CPU。 - 再看参数。检测阈值、批量大小、视频输出尺寸、标注器厚度,有没有明显异常。
- 最后看版本。supervision API 在更新中可能变化,老教程代码跑不通很正常。
不要一上来就改模型参数。很多问题看起来像模型能力不够,实际是输入文件或环境问题。
7.3 别把工具限制当成项目 bug
有些现象看起来像 bug,其实是使用边界。
比如 LabelAnnotator 对中文字体支持默认不好。如果你要画中文标签,大概率需要自己处理字体,或者先用类别 ID 替换。这不是项目报错,而是工具的设计边界。
再比如 VideoSink 保存的 mp4 在某些播放器里打不开。先换一个播放器试试,再判断是不是编码器问题。OpenCV 自带的编码支持在不同系统上有差异,这是常见情况。
目标检测框偶尔闪烁也正常。逐帧检测本身没有利用时序信息,没有加跟踪时,同一目标可能在某几帧漏检,导致框闪烁。这不是 supervision 的 bug,而是检测模型的问题。加上 ByteTrack 后通常会稳定很多。
最后留几个我会优先排查的点:检测结果是不是为空;Detections 里字段长度是否正常;标注器是否收到了正确参数;输出目录有没有写权限。很多复杂问题说到底,只是某一层的小失误。