坐标格式、类别名绑定、置信度过滤:supervision 新手翻车率最高的三个细节
【免费下载链接】supervisionWe write your reusable computer vision tools. 💜项目地址: https://gitcode.com/GitHub_Trending/su/supervision
supervision 号称"帮你搞定模型之后的所有事",它把 YOLO、DETR、SAM、Transformers、各类 VLM 的异构输出统一收敛到同一个sv.Detections数据结构里,再往上叠加标注、跟踪、区域计数、指标评估等一整套工具链。正因为它把复杂度藏起来了,新手上手时反而容易在几个"看起来无所谓"的细节上栽跟头——画框画歪、标签错位、过滤后计数异常,最终定位到的问题往往不在模型,而在坐标语义、类别绑定和置信度过滤这三件事上。社区里关于 supervision 的实战文章也反复点名这三处"实操要点"(坐标格式转换、类别名绑定、置信度过滤),本文直接从仓库源码出发,把这三个高频翻车点一次讲透。
一、XYXY 与 XYWH:supervision 只有一个坐标系,其他都是"外来户"
sv.Detections的第一个字段就是xyxy,文档里写得明明白白:形状为(n, 4)的数组,坐标格式是[x1, y1, x2, y2],也就是左上角 + 右下角(核心数据结构)。这听起来简单,但问题在于:你喂给它的模型,几乎没有一个默认输出这种格式。
看几个典型的"外来户":
SAM 输出的是 XYWH。在from_sam的源码里,SAM 的mask["bbox"]是[x, y, width, height],supervision 拿到之后必须先经过xywh_to_xyxy转换才能构造Detections(from_sam 实现):
xywh = np.array([mask["bbox"] for mask in sorted_generated_masks]) # ... 若干行之后 xyxy = xywh_to_xyxy(xywh=xywh) return cls(xyxy=xyxy, mask=mask)转换逻辑本身不复杂(坐标转换工具):
def xywh_to_xyxy(xywh): xyxy = xywh.copy() xyxy[:, 2] = xywh[:, 0] + xywh[:, 2] # x2 = x + width xyxy[:, 3] = xywh[:, 1] + xywh[:, 3] # y2 = y + height return xyxy真正的坑在于:如果你绕过from_sam自己手动构造Detections,把 SAM 的 XYWH 原样塞进xyxy字段,宽高就被当成了右下角坐标,框会"缩小"甚至变成一条线。同样地,xcycwh_to_xyxy处理的是中心点格式(center_x, center_y, width, height),常用于目标跟踪的卡尔曼滤波测量空间;xyxy_to_xcycarh则把框转成(center_x, center_y, aspect_ratio, height)供 tracker 使用——这三个格式混在一起,是坐标翻车的第一大来源。
TensorFlow 输出的是[ymin, xmin, ymax, xmax],而且坐标是归一化的。from_tensorflow内部做了两件事:先按resolution_wh把归一化坐标还原成像素坐标,再把列顺序从ymin,xmin,ymax,xmax重排成xyxy(from_tensorflow 实现):
boxes[:, [0, 2]] *= resolution_wh[1] # y 轴按高度缩放 boxes[:, [1, 3]] *= resolution_wh[0] # x 轴按宽度缩放 boxes = boxes[:, [1, 0, 3, 2]] # 重排为 xmin, ymin, xmax, ymax注意源码里特意写了.copy():TensorFlow 的detection_boxes可能与源张量共享内存,原地缩放会二次缩放、污染调用方结果。这类"尺寸语义 + 通道顺序 + 是否归一化"的组合差异,在每个适配器里都被悄悄抹平了——这是便利,也是隐患:你一旦脱离适配器自己写转换,任何一个环节错了,框的位置、宽高、IoU 就全错。
角点顺序都可能颠倒。更隐蔽的是,有些模型(尤其 VLM 解析器)返回的坐标并不保证x1 <= x2、y1 <= y2。仓库里专门写了_sort_box_corners来兜底,并留下了一段非常值得读的注释:Detections.xyxy定义为(x_min, y_min, x_max, y_max),库内所有框运算都依赖这个顺序——box_iou_batch会把交叠宽度钳制为零,一对角点颠倒的框,哪怕和自己算 IoU 也是 0;而box_area反而会掩盖问题,因为两边同时取反乘积仍为正(角点排序实现)。这解释了为什么很多新手"框看着在,但计数、NMS、跟踪全不对"。
最后一个细节:mask_to_xyxy的 inclusive / exclusive 约定。从 mask 反推包围框时,x_max/y_max有两种语义:"inclusive"表示"最后一个被覆盖的像素"(旧版行为),"exclusive"表示半开区间(与面积和 IoU 算术严格对齐)。两者对单像素 mask 的宽高计算结果完全不同(mask_to_xyxy 实现)。如果你的下游指标是拿"框面积"和"mask 面积"对账的,这个约定不一致就是 1 像素级的系统性偏差。
反直觉的结论:supervision 从来不做"自动猜格式"这件事。它只认xyxy,其余全部靠适配器显式转换。自己拼接Detections时,先确认手头坐标是哪种语义,再调用对应的转换函数——xywh_to_xyxy、xcycwh_to_xyxy、denormalize_boxes、xyxyxyxy_to_xyxy,一个都不能省。
二、类别名与索引绑定:class_id是数字,class_name在data里
第二个高频翻车点:supervision 里"类别"有两个完全不同的载体。Detections.class_id是(n,)的整数数组,是类别索引;Detections.data是个字典,类别名字符串放在data["class_name"]里(常量定义见 config.py:CLASS_NAME_DATA_FIELD = "class_name")。
适配器之间的行为差异是踩坑重灾区:
Ultralytics 会"顺手"帮你绑定。from_ultralytics在构造时会把class_id对应的名字查表写进data(from_ultralytics 实现):
class_id = ultralytics_results.boxes.cls.cpu().numpy().astype(int) class_names = np.array([ultralytics_results.names[i] for i in class_id]) return cls( xyxy=ultralytics_results.boxes.xyxy.cpu().numpy(), confidence=ultralytics_results.boxes.conf.cpu().numpy(), class_id=class_id, data={CLASS_NAME_DATA_FIELD: class_names}, )Transformers 必须手动传id2label。from_transformers的id2label参数默认为None,只有当它被传入时,class_name才会被写进data;否则你的Detections里只有一串class_id(append_class_names_to_data 实现):
if id2label is not None: class_names = np.array([id2label[class_id] for class_id in class_ids]) data[CLASS_NAME_DATA_FIELD] = class_names于是同样一段"标注"代码,用 YOLO 跑标签是person 0.85,换 DETR 跑就变成2 0.85——不是模型坏了,是id2label没传。这是社区里"换了个模型,标签全变数字"类问题的最常见根因。
标注器内部有一套降级链。LabelAnnotator的标签提取逻辑是:优先取data["class_name"];没有就用class_id;再没有就退化成检测索引字符串(标签提取逻辑)。也就是说,你看到框上的"数字标签",本身就意味着类别名字缺失——这是设计好的行为,不是 bug,但很容易被误读成"库的适配器坏了"。
对齐是硬约束,别手动改data。官方文档反复强调:class_name数组必须和xyxy严格按行对齐。get_labels_text里专门做了长度校验,data与检测数不一致直接抛ValueError,因为"调用方绕过对齐检查直接改data的情况太多了"(对齐校验):
if len(class_names) != len(detections): raise ValueError( f"'{CLASS_NAME_DATA_FIELD}' has {len(class_names)} entries " f"but detections has {len(detections)} - the two must stay aligned." )同理,select(切片/布尔索引)会通过get_data_item同步裁剪data里每个键(索引同步逻辑),而merge要求合并的多个Detections的data键完全一致(合并校验)。新手常见的"过滤完 class 之后标签对不上"、"合并后报All data dictionaries must have the same keys",根源都在这里——要么手动改了data破坏了行对齐,要么两个数据源一个带class_name一个不带。
正确姿势是全程用Detections自己的接口:用detections["class_name"](字符串键走__getitem__直接读data)拿名字,用布尔索引做过滤,过滤后的class_name由库自动保持对齐:
detections = sv.Detections.from_ultralytics(result) # 过滤 + 标签,全程走库内对齐 filtered = detections[detections.class_id == 0] labels = [ f"{class_name} {confidence:.2f}" for class_name, confidence in zip(filtered["class_name"], filtered.confidence) ]三、置信度过滤:模型侧 conf 与库侧布尔索引,是两套体系
第三个翻车点最隐蔽:置信度过滤发生在两个不同的层面,新手经常只做一半。
层面一:模型内部过滤。Ultralytics 的model(frame, conf=0.5)、Roboflow Inference 的model.infer(image, confidence=0.5),是在模型后处理阶段就按阈值砍掉低置信度框。仓库的官方示例(count_people_in_zone 示例)是这么写的:
results = model( frame, conf=confidence_threshold, iou=iou_threshold, imgsz=1280, verbose=False )[0] detections = sv.Detections.from_ultralytics(results) filter_by_class = detections.class_id == 0 filter_by_confidence = detections.confidence > confidence_threshold return detections[filter_by_class & filter_by_confidence]注意它做了双保险:模型侧conf过滤一遍,拿到Detections之后再用布尔索引过滤一遍。为什么?因为from_ultralytics只负责搬运,模型输出的置信度原样进Detections.confidence;而库内后续的 NMS、跟踪、计数、指标计算,依赖的是Detections里这份数据。只靠模型侧阈值,你拿到的Detections里可能还躺着低于阈值的框(比如某些推理服务默认返回 top-k 而不过滤)。
层面二:库内布尔索引。Detections本身就是个可索引容器,detections[detections.confidence > 0.5]这种写法是官方文档里的标准过滤姿势(索引过滤示例)。布尔掩码和class_id == 0这类条件可以&组合,返回的依然是结构完整的Detections,data同步裁剪——这正是上一节强调的对齐机制在起作用。
这里的"玄学"在于阈值语义。两个最容易被忽略的事实:
第一,过滤是严格大于。detections.confidence > confidence_threshold用的是>,恰好等于阈值的框会被丢掉。如果你的阈值设成 0.5 而模型输出一堆恰好 0.5 的框(某些 VLM 或量化模型很常见),计数结果会比直觉少一截。
第二,confidence可能是None,也可能全是 1。from_sam构造的Detections根本没有confidence字段(SAM 天生不输出置信度);更微妙的是from_vlm对 Qwen2.5-VL、Qwen3-VL 这类"解析器不报逐框分数"的模型,会用全 1 数组填充confidence,源码注释写得很直白:为了让字段保持有值(VLM 置信度处理):
_VLM_UNIT_CONFIDENCE: frozenset[VLM] = frozenset({VLM.QWEN_2_5_VL, VLM.QWEN_3_VL}) ... confidence = np.ones(len(xyxy), dtype=float) if vlm in _VLM_UNIT_CONFIDENCE else None对全 1 的confidence做> 0.5过滤,等于不过滤——这是"置信度过滤好像失效了"的一大来源。而对None做detections.confidence > 0.5,直接抛TypeError,因为 NumPy 不允许None参与比较。所以过滤之前先确认confidence is not None,并且想清楚"全 1"对业务意味着什么。
NMS 对置信度是硬依赖。with_nms的源码里,confidence is None直接raise ValueError("Detections confidence must be given for NMS to be executed.");同理,非 class-agnostic 的 NMS 还强制要求class_id存在(NMS 前置校验)。也就是说,对 SAM 或没传id2label的 DETR 结果直接调with_nms,会得到一场报错——这不是 bug,是库在明确告诉你:NMS 需要分数排序,没有置信度就没有排序依据。正确的顺序永远是:先确认字段齐备 → 过滤 → 再做 NMS 或跟踪。
结语:一条可以规避 90% 翻车的流水线
把三个翻车点串起来,你会发现它们指向同一条铁律:supervision 的Detections是一个"单一定义源",坐标、类别、置信度三者在行维度上严格对齐,所有工具都建立在这个不变量之上。只要遵循这套流水线——先搞清楚输入坐标的语义并显式转换到xyxy,再确认class_name与class_id的绑定关系(该传id2label就传),最后在库侧用布尔索引统一过滤并确认confidence的真实含义——绝大多数"框歪了、标签乱了、计数少了"的翻车都能在接入第一天就避免。模型的差异交给适配器,语义的一致性由你来守护。
【免费下载链接】supervisionWe write your reusable computer vision tools. 💜项目地址: https://gitcode.com/GitHub_Trending/su/supervision
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考