搞目标检测的兄弟,应该都有过这种经历:从网上扒下来的数据集,标注格式是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 XML | xmin, ymin, xmax, ymax | 基于左上角和右下角坐标 |
| COCO JSON | [x, y, width, height] | 注意是左上角加宽高,不是中心点 |
| YOLO txt | class, x_center, y_center, width, height | 全部归一化到 0~1 |
| OpenCV Rectangle | pt1(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 标签处理脚本会在中心点和角点之间反复横跳:
- 从中心点转
xyxy:xmin = cx - w/2,ymin = cy - h/2,xmax = cx + w/2,ymax = cy + h/2 - 从
xyxy转中心点:cx = (xmin + xmax) / 2,cy = (ymin + ymax) / 2,w = xmax - xmin,h = ymax - ymin
2.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, dtype=np.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(dim=1) xmax = xmin + w ymax = ymin + h out = torch.stack([xmin, ymin, xmax, ymax], dim=1) if cls is not None: out = torch.cat([cls, out], dim=1) 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,某个框的xmin=550, width=100,那xmax=650,已经超出边界了。如果直接送给模型训练,大部分数据加载器会报错,或者产生 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。比如框的xmin=0, xmax=0,表示一个像素宽的框,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), thickness=2): 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'], desc="Converting"): 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, indent=2)如果你要处理的是检测头输出的 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(dim=1) 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], dim=1)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_format="xywh"): 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 秒内把你捞回来。