ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

目标检测边界框坐标转换:xywh与xyxy互转及避坑指南

目标检测边界框坐标转换:xywh与xyxy互转及避坑指南 搞目标检测的兄弟应该都有过这种经历从网上扒下来的数据集标注格式是xmin,ymin,width,height比如[100, 150, 200, 300]但你的训练代码里DataLoader读进来却要求xmin,ymin,xmax,ymax也就是[100, 150, 300, 450]。这种坐标表示法的错位轻则训练时 loss 不收敛重则画框画到图片外面去定位结果完全没法看。今天就把这个转换聊透顺便把里面那些文档里不会写的坑一并填了。先明确一个概念目标检测里边界框的表示方法本质上是同一块矩形区域用不同参数去描述。xmin,ymin,width,height通常被叫做xywh左上角坐标加宽高而xmin,ymin,xmax,ymax被叫做xyxy左上角和右下角坐标。前者常见于 YOLO 系列的标签文件虽然 YOLO 用的是归一化后的中心点坐标但很多脚本在处理时也会转成这种形式、部分标注工具导出结果、以及 OpenCV 的cv2.rectangle所需参数后者则是 Pascal VOC 的 XML 标注格式、COCO 的bbox字段COCO 虽然是[x,y,width,height]但很多代码库内部会先转成xyxy以及 Detectron2、MMDetection 等框架默认的框格式。很多人觉得这个转换太简单不就是xmax xmin width嘛。但真正在项目里跑一轮就会发现问题往往出在细节上浮点数精度、整型截断、坐标越界、归一化坐标混用、甚至宽高为负数的情况。下面从原理到实战一步步拆开讲。1. 两类框表示法为什么同一个框要有两套坐标1.1 从几何直觉和计算效率看两种格式先看几何直觉。xywh的表示非常符合人的自然认知我告诉你一个矩形的左上角在哪儿然后告诉你它有多宽多高你脑海里就能浮现出这个框。这种表示对于“生成”框特别友好比如目标检测的回归头直接输出(tx, ty, tw, th)天生就是中心点加宽高的形式方便做尺度归一化和 anchor 匹配。而xyxy的表示更贴近“标注结果”标注人员点出左上角再点出右下角得到的自然就是两个点坐标。这种表示在计算 IoU 时非常直观因为交集区域的左上角就是两个框左上角的最大值右下角就是两个框右下角的最小值直接算就行。从计算效率上讲xyxy在做 NMS非极大值抑制时更高效因为计算重叠面积不需要额外转换而xywh在做数据增强比如随机裁剪、缩放时更方便因为宽高可以直接乘缩放系数中心点坐标可以直接做平移变换。这也就解释了为什么不同框架会选择不同的内部表示检测头输出用xywh而后处理 NMS 阶段用xyxy中间必然存在一次转换。1.2 常见开源框架里的格式“方言”不同工具和框架的默认格式是新手最容易搞混的地方。我列一张表大家对照着看工具/框架边框表示说明Pascal VOC XMLxmin, ymin, xmax, ymax基于左上角和右下角坐标COCO JSON[x, y, width, height]注意是左上角加宽高不是中心点YOLO txtclass, x_center, y_center, width, height全部归一化到 0~1OpenCV Rectanglept1(xmin,ymin), pt2(xmax,ymax)需要整数坐标MMDetection默认xyxy但支持xywh等通过bbox_coder配置切换Detectron2默认BoxMode.XYXY_ABS提供多种 BoxMode这里有个典型的坑COCO 的bbox字段是[x, y, width, height]很多人误以为它是中心点坐标实际上它是左上角坐标加宽高。如果你从 COCO 数据集转成 VOC 时直接套用x_center x width/2那就全错了。所以第一步先确认你手里的数据到底是哪种“方言”再动手转换。2. 核心转换公式与代码实现从手算到一行函数2.1 公式推导四种互换关系先给结论。假设我们有左上角坐标(xmin, ymin)以及width和height那么xmax xmin widthymax ymin height反过来如果已知xmin, ymin, xmax, ymax则width xmax - xminheight ymax - ymin就这么简单是的几何上就这么简单。但代码落地时还要考虑数据类型、是否含类别标签、是否归一化等问题。再看中心点表示法(cx, cy, w, h)与xyxy的互换因为很多 YOLO 标签处理脚本会在中心点和角点之间反复横跳从中心点转xyxyxmin cx - w/2ymin cy - h/2xmax cx w/2ymax cy h/2从xyxy转中心点cx (xmin xmax) / 2cy (ymin ymax) / 2w xmax - xminh ymax - ymin2.2 Python 实现单框转换与向量化批量转换单框转换直接写个函数def xywh_to_xyxy(box): 将 xmin, ymin, width, height 转为 xmin, ymin, xmax, ymax box: [xmin, ymin, width, height] 或 [class_id, xmin, ymin, width, height] if len(box) 4: xmin, ymin, w, h box elif len(box) 5: _, xmin, ymin, w, h box else: raise ValueError(box length must be 4 or 5) xmax xmin w ymax ymin h if len(box) 4: return [xmin, ymin, xmax, ymax] else: return [box[0], xmin, ymin, xmax, ymax]实际处理数据集时往往有几千上万个框用循环会慢得离谱。这时候用 NumPy 做向量化转换import numpy as np def batch_xywh_to_xyxy(boxes): boxes: (N, 4) 或 (N, 5)最后一列为 class_id可选 返回 (N, 4) 或 (N, 5) boxes np.asarray(boxes, dtypenp.float32) if boxes.shape[1] 4: out np.zeros_like(boxes) out[:, 0] boxes[:, 0] # xmin out[:, 1] boxes[:, 1] # ymin out[:, 2] boxes[:, 0] boxes[:, 2] # xmax xmin width out[:, 3] boxes[:, 1] boxes[:, 3] # ymax ymin height return out elif boxes.shape[1] 5: out np.zeros_like(boxes) out[:, 0] boxes[:, 0] # class_id out[:, 1] boxes[:, 1] # xmin out[:, 2] boxes[:, 2] # ymin out[:, 3] boxes[:, 1] boxes[:, 3] # xmax out[:, 4] boxes[:, 2] boxes[:, 4] # ymax return out else: raise ValueError(boxes shape must be (N,4) or (N,5))如果是 PyTorch 环境强烈建议用张量操作并且注意保持梯度路径如果框坐标参与了可微计算def xywh_to_xyxy_torch(boxes): boxes: Tensor[N, 4] or Tensor[N, 5] if boxes.size(1) 5: cls boxes[:, 0:1] boxes boxes[:, 1:] else: cls None xmin, ymin, w, h boxes.unbind(dim1) xmax xmin w ymax ymin h out torch.stack([xmin, ymin, xmax, ymax], dim1) if cls is not None: out torch.cat([cls, out], dim1) return out上面这个torch版本有个细节unbind是按列拆然后stack拼回去整个过程都是张量运算不会破坏 autograd 图。如果你的框坐标是从网络输出层直接算出来的比如端到端检测模型里的自定义 loss 需要用到xyxy形式的预测框那就必须用这种可微的写法。3. 实际项目中的常见坑整型截断、越界、归一化、宽高为负3.1 整型截断肉眼看不见但会让 mAP 掉点我们标注工具导出的坐标往往是整数比如[100, 150, 300, 450]宽高就是200, 300。但很多检测模型尤其是带数据增强的要求坐标是浮点数因为在随机缩放、旋转、马赛克增强时会产生小数坐标。如果你的转换代码里用了int()或np.int32直接强转就会把小数的结果截断导致框的位置偏移 1~2 个像素。单看一个框没什么但整个数据集累积下来对 mAP 的影响可能在 0.1~0.3 之间。我见过一个更隐蔽的坑用 OpenCV 画框可视化时cv2.rectangle只接受整数坐标很多人就在转成xyxy之后立刻astype(np.int32)。后续又拿这个被截断的框去算 IoU结果差距被放大了。正确做法是转换函数里始终保留浮点类型只在可视化或写入特定格式时再转整型。3.2 坐标越界宽高算出来超过图片边界xmax xmin width在数学上没问题但实际标注数据里经常出现右下角超出图片宽高的情况。比如图片宽度是 600某个框的xmin550, width100那xmax650已经超出边界了。如果直接送给模型训练大部分数据加载器会报错或者产生 NaN loss。这种越界框怎么处理几种思路在转换后做一次裁剪xmax min(xmax, img_width - 1)ymax min(ymax, img_height - 1)。如果框大部分在图片外比如交并比小于某个阈值直接丢弃。在数据加载时做边界限制但不修改原始标注。我个人建议如果只是做训练前的预处理应该在转换时同时完成裁剪并且用clip操作def xywh_to_xyxy_clip(box, img_width, img_height): xmin, ymin, w, h box[:4] xmax min(xmin w, img_width - 1) # 注意是 img_width - 1 ymax min(ymin h, img_height - 1) return [xmin, ymin, xmax, ymax]为什么要img_width - 1而不是img_width因为像素坐标从 0 开始如果图片宽度是 600那么列索引范围是 0~599边界是 599。当然有些框架里用img_width作为开区间的右边界这取决于你用的是闭区间还是半开区间表示。这会引出一个非常重要的问题坐标边界约定。3.3 边界约定闭区间还是半开区间这是转换中最大的隐性坑。在 Pascal VOC 和很多标注工具里xmax表示的是框的最右侧像素的坐标xmin是最左侧像素的坐标所以xmax是包含在框内的属于闭区间[xmin, xmax]。此时width xmax - xmin 1。比如框的xmin0, xmax0表示一个像素宽的框width 应是 1。但很多公式直接写成width xmax - xmin也就是 0这显然不对。不过在 COCO 数据集里bbox的width和height是直接给定的且标注规范中x、y是左上角像素坐标width和height是包含在框内的像素数量即xmax x width - 1。但很多框架如 Detectron2在内部实现时使用半开区间[xmin, xmax)也就是xmax x width此时xmax并不表示最后一个像素的索引而是边界外一个像素的坐标。这个问题在转换时非常致命。假如你从 COCO 转 VOC如果直接套xmax x width得到的是一个半开区间的xmax写进 VOC XML 会让可视化时框多一个像素反过来如果从 VOC 转 COCO直接width xmax - xmin会少一个像素如果 VOC 是闭区间的话。我的建议是在代码里明确约定使用半开区间[xmin, xmax)并且统一用xmax xmin width。理由有两点第一Python 的range就是半开区间切片image[y0:y1, x0:x1]也是半开区间这样不会出现索引越界第二主流检测框架如 Detectron2 的BoxMode.XYXY_ABS默认使用半开区间算面积时(w * h)不需要额外1。如果你要跟 VOC 那种闭区间格式对接在导出时再做xmax_闭 xmax_半 - 1的调整。3.4 宽高为负数数据处理时不可放过有些标注工具或自动标注脚本会生成width 0或height 0的记录这通常是因为人工标错了顺序或者自动标注时把右下角当成了左上角。转换后你会得到xmax xmin或ymax ymin这种框在计算 IoU 时面积为负或 0非常影响训练。这种问题建议在转换函数里加一个合法性校验def validate_xyxy(box): xmin, ymin, xmax, ymax box[:4] if xmax xmin or ymax ymin: return False return True批量处理时直接过滤掉非法框valid_mask (boxes[:, 2] boxes[:, 0]) (boxes[:, 3] boxes[:, 1]) boxes boxes[valid_mask]注意是还是。如果允许宽度为 0比如某些关键点检测场景可以保留但目标检测中没有任何意义建议过滤掉。4. 从数据集处理视角看转换VOC、COCO、YOLO 标签互转实战4.1 VOC XML 与 COCO JSON 之间的转换步骤VOC XML 中每个目标是一个bndbox节点里面是xmin, ymin, xmax, ymax闭区间。COCO JSON 中每个标注是一个字典bbox字段是[x, y, width, height]。从 VOC 转 COCO 时必须先明确 VOC 的xmax到底是闭还是半开。按前面说的实际很多 VOC 文件里xmax是闭区间即最后一个像素索引。于是就有了一个关键决策如果你要在项目里统一使用半开区间那么读入 VOC 后立刻转成半开区间即def voc_to_half_open(xmin, ymin, xmax, ymax): # 假设 VOC 是闭区间 [xmin, xmax] return xmin, ymin, xmax 1, ymax 1然后存储为xyxy半开区间。当需要输出 COCO 的bbox时def xyxy_half_to_coco_bbox(xmin, ymin, xmax, ymax): # 半开区间转 COCO bbox width xmax - xmin height ymax - ymin return [xmin, ymin, width, height]这样转出来的width不含有1与 COCO 语义一致。如果直接将 VOC 闭区间转 COCO就必须width xmax - xmin 1否则会丢一个像素。所以关键不是记公式而是先规定统一格式。再看 YOLO 的 txt 格式每行是class x_center y_center width height这四个值都是相对于图片宽高的归一化数值范围在 0~1 之间。从xyxy半开区间像素坐标转 YOLO 格式def xyxy_to_yolo(xmin, ymin, xmax, ymax, img_w, img_h): x_center (xmin xmax) / 2 / img_w y_center (ymin ymax) / 2 / img_h width (xmax - xmin) / img_w height (ymax - ymin) / img_h return [x_center, y_center, width, height]反过来YOLO 转xyxy像素坐标def yolo_to_xyxy(x_center, y_center, width_norm, height_norm, img_w, img_h): xmin (x_center - width_norm / 2) * img_w ymin (y_center - height_norm / 2) * img_h xmax (x_center width_norm / 2) * img_w ymax (y_center height_norm / 2) * img_h return [xmin, ymin, xmax, ymax]这里要注意归一化的计算方式。有人习惯用xmin x_center * img_w - width_norm * img_w / 2跟上面等价。但有一个坑如果x_center和width_norm是相对原始图片尺寸归一化的而加载时图片被resize过就不能直接乘新图片的宽高。必须先还原到原始尺寸或者用原始尺寸的img_w, img_h参与计算。4.2 验证转换结果的必备手段画框对比转换做完之后一定要可视化验证。我通常在数据集上随机抽 20 张图画上转换前后的框对比是否重合。import cv2 def draw_boxes(img_path, boxes, color(0, 255, 0), thickness2): img cv2.imread(img_path) for box in boxes: xmin, ymin, xmax, ymax [int(v) for v in box[:4]] cv2.rectangle(img, (xmin, ymin), (xmax, ymax), color, thickness) return img # 假设图片路径与标注 img_path demo.jpg boxes_xywh [[100, 150, 200, 300]] boxes_xyxy batch_xywh_to_xyxy(np.array(boxes_xywh)) img1 draw_boxes(img_path, boxes_xywh) img2 draw_boxes(img_path, boxes_xyxy) # 两张图应该完全一样注意坐标边界约定是否一致如果两张图显示的位置有 1 像素偏移那就要检查是不是闭区间/半开区间的约定问题。如果偏移明显可能是公式写错了。这种可视化验证虽然笨但是最可靠。我在实际处理几十万张图的数据集时靠的就是这招提前发现了几处粗心错误。4.3 大量数据集的批处理脚本模板如果你的数据集是文件夹模式比如images/和labels/对应需要将一种格式统一成另一种格式可以写一个小脚本。以xywh像素坐标转xyxy像素坐标并保存为 JSON 为例import os import json import numpy as np from tqdm import tqdm def convert_annotations(src_json_path, dst_json_path): with open(src_json_path, r) as f: data json.load(f) converted [] for ann in tqdm(data[annotations], descConverting): box_xywh ann[bbox] # [x, y, width, height] box_xyxy xywh_to_xyxy(box_xywh) # 用之前写的函数 new_ann ann.copy() new_ann[bbox] box_xyxy # 注意现在字段名可能不再叫 bbox # 也可以改成 segmentation 之外的 bbox_xyxy converted.append(new_ann) data[annotations] converted with open(dst_json_path, w) as f: json.dump(data, f, indent2)如果你要处理的是检测头输出的 tensors比如在验证阶段把模型的[cx, cy, w, h]输出转成[xmin, ymin, xmax, ymax]再和 GT 计算 mAP那转换函数应该写在模型后处理里并且要保证在 GPU 上直接完成def decode_pred_boxes(pred, img_w, img_h): pred: Tensor[N, 4] 且为 [cx, cy, w, h] 的归一化值 cx, cy, w, h pred.unbind(dim1) xmin (cx - w / 2) * img_w ymin (cy - h / 2) * img_h xmax (cx w / 2) * img_w ymax (cy h / 2) * img_h return torch.stack([xmin, ymin, xmax, ymax], dim1)5. 批量转换与验证技巧确保你的转换万无一失5.1 随机抽检与几何一致性检查批量转换后除了画图还可以做数值一致性检查。比如对同一个数据集先xyxy - xywh - xyxy做一次往返转换看看还原后的坐标与原始坐标的误差是否为零def roundtrip_check(boxes_xyxy): boxes_xywh xyxy_to_xywh(boxes_xyxy) boxes_round xywh_to_xyxy(boxes_xywh) diff np.abs(boxes_round - boxes_xyxy).max() return diff如果diff不为 0说明代码里有非可逆操作比如取整、越界裁剪、或者闭开区间混用。当然如果在转换过程中做了clip往返就不可能完全一致这是预期行为。所以要做“可逆检查”必须在转换函数里避免任何不可逆操作。5.2 面积与纵横比的合理性检查转换后检查每个框的面积和宽高比是否在合理范围内。比如框的面积不能超过原图面积的正负误差范围不能出现宽度为负数或接近 0 的框。如果发现有大量框的面积异常先检查是不是坐标系没有对齐。可以这样统计def check_boxes(boxes_xyxy, img_w, img_h): widths boxes_xyxy[:, 2] - boxes_xyxy[:, 0] heights boxes_xyxy[:, 3] - boxes_xyxy[:, 1] areas widths * heights print(Width stats: min{:.2f}, max{:.2f}, mean{:.2f}.format( widths.min(), widths.max(), widths.mean())) print(Height stats: min{:.2f}, max{:.2f}, mean{:.2f}.format( heights.min(), heights.max(), heights.mean())) print(Area stats: min{:.2f}, max{:.2f}, mean{:.2f}.format( areas.min(), areas.max(), areas.mean())) invalid (widths 0) | (heights 0) | (boxes_xyxy[:, 0] 0) | (boxes_xyxy[:, 1] 0) | (boxes_xyxy[:, 2] img_w) | (boxes_xyxy[:, 3] img_h) print(Invalid box count:, invalid.sum())这个方法能快速揪出坐标取反、单位错误等问题。有一次我从某个数据集转标签突然发现所有框的面积都变成了两倍检查一下才发现是自己把width和height写反了导致xmax用了ymin计算。这种低级错误单靠肉眼画图不一定看得出来但面积统计一看就露馅了。5.3 实际训练中的兜底策略在 Dataset 内做格式归一最后讲一个实战中非常实用的经验不要依赖“外部标签一定是某一种格式”的假设而是要在Dataset加载阶段做统一的格式归一。也就是说数据读取时无论原始标签是xywh还是xyxy都在__getitem__里先转成模型需要的内部统一格式并加上边界检查、类型检查。class DetDataset(Dataset): def __init__(self, img_dir, ann_path, box_formatxywh): self.img_dir img_dir self.box_format box_format self.annotations load_annotations(ann_path) def __getitem__(self, idx): ann self.annotations[idx] img cv2.imread(...) boxes ann[boxes] # 假设是 N x 4 # 转换为统一 xyxy 半开区间 if self.box_format xywh: boxes batch_xywh_to_xyxy(boxes) elif self.box_format yolo: h, w img.shape[:2] boxes yolo_batch_to_xyxy(boxes, w, h) # 若是 xyxy 就跳过 # 裁剪到图像边界针对半开区间用 w 而不是 w-1 boxes[:, [0, 2]] boxes[:, [0, 2]].clip(0, w) boxes[:, [1, 3]] boxes[:, [1, 3]].clip(0, h) # 过滤掉非法框 valid (boxes[:, 2] - boxes[:, 0] 0) (boxes[:, 3] - boxes[:, 1] 0) boxes boxes[valid] return torch.from_numpy(boxes), ...这样做的好处是以后换数据集、换标注格式只需要改box_format这个参数数据加载和训练逻辑完全不用动。在我的经验里这个Dataset内格式归一的策略比在预处理脚本里改原始文件更稳。因为原始数据是整个项目的地基轻易不要改动它而加载时做转换每次运行都能得到相同结果而且不容易污染原始标注。另外提醒一下如果你使用多进程数据加载转换函数里不要依赖任何全局变量或随机状态保持纯函数否则会出现不同 epoch 结果不一致的诡异问题。我之前吃过这个亏后来把所有转换函数都写成了无副作用的纯函数问题就消失了。突然想起一个补充细节有的数据集中框的宽高可能是小数比如[100.5, 200.2, 300.8, 400.4]。这种情况下保持 float32 精度是最安全的。你在可视化时用int()截断但内部计算损失时不要转整型。尤其要注意不要因为某组数据看起来像整数就默认它是int还是那句话明确约定、固定使用浮点、在边界处显式转换。最后分享一个我自己的小习惯每次写完转换函数我都会顺手写一个if __name__ __main__:的简单测试用例包含一个已知结果的示例比如xywh_to_xyxy([10, 20, 30, 40])应该返回[10, 20, 40, 60]。这样以后改代码时python xxx.py跑一遍就知道有没有破坏原有逻辑。你别小看这个习惯很多项目的坐标转换 bug 都是改着改着把公式弄反了而这个单测能在 5 秒内把你捞回来。
返回列表