做目标检测的都知道坐标格式转换这事看着简单真上手时总会莫名其妙卡一下。尤其是新手用 LabelImg、X-AnyLabeling 或者网上下的开源数据集时经常遇到一种情况别人的工具导出来是xmin, ymin, width, height而你的训练脚本或模型预测逻辑里写死的是xmin, ymin, xmax, ymax不转就没法直接用。今天就把这个转换讲透原理、代码、批量处理、踩坑实录一次说清文末还附了一个可以直接抄走的 Python 工具箱。1. 标注格式全景先搞清楚你手上到底是谁家的数据1.1 三种主流坐标表示别搞混目标检测的框标注绕来绕去其实就三种基本表示法。第一种是xmin, ymin, xmax, ymax也就是左上角横坐标、左上角纵坐标、右下角横坐标、右下角纵坐标。这种格式最直观画框的人一眼能看懂VOC XML 标注和很多可视化工具都用它。第二种是xmin, ymin, width, height严格说叫左上角坐标加宽高。COCO 数据集的 bbox 字段就是这种表达很多标注工具导出的 JSON 里也是这个格式。它比 x1y1x2y2 多了一步隐式计算光看数字没法立刻在脑子里画出框的位置。第三种是cx, cy, width, height也就是中心点坐标加宽高。YOLO 系列训练用的 txt 标注就是这种只不过还做了归一化所有值都在 0 到 1 之间。很多人在第一步就栽了搞不清中心点格式和左上角格式的换算关系。这里顺手给一个速查表后续所有讨论都围绕这三种格式四个值含义常见场景是否归一化(xmin, ymin, xmax, ymax)左上角 右下角VOC XML、可视化通常不归一化(xmin, ymin, width, height)左上角 宽高COCO JSON、部分标注工具通常不归一化(cx, cy, width, height)中心点 宽高YOLO txt通常归一化到 [0,1]1.2 xmin,ymin,width,height 到底来自哪标题里的xmin, ymin, width, height格式我在实际项目里见到的来源主要有三类。第一类是 COCO 格式的 JSON 标注文件annotations数组里每条数据都有bbox: [x, y, width, height]这里的 x、y 就是框左上角相对于原图的坐标。第二类是某些标注工具导出的通用 JSON。比如 X-AnyLabeling 导出的自定义格式如果你没选 VOC 或 YOLO 输出选项默认可能就是这个样子。第三类是网上很多开源数据集为了压缩存储或统一接口直接给你左上角加宽高不帮你算右下角。还有一个容易被忽略的来源有些检测框架的中间结果。比如你用某些 Anchor-Based 模型的输出解析脚本网络回归的是偏移量和宽高代码里先还原成xmin, ymin, width, height后面再交给 NMS 前才需要转成xmin, ymin, xmax, ymax。这类场景在工程上非常常见不是只有数据集处理才用到。1.3 为什么要做这个转换理论上不管存哪种格式只要逻辑一致训练都能跑。但现实是生态分裂VOC 系脚本认 x1y1x2y2COCO 系脚本认 x1y1whYOLO 系脚本认 cxcywh。你的数据从标注工具出来是 A 格式训练脚本读的是 B 格式两者之间必然得有一座桥。更重要的是人在调试时最容易理解的是xmin, ymin, xmax, ymax。画图、算 IoU、检查标签是否越界用这个格式一眼就能看出框到底画在哪、大小是否合理。如果只有左上角和宽高你还得心算右下角批量看标签时效率极低。另外很多数据增强库、评估脚本的输入接口就是 x1y1x2y2。不先把数据转到这个统一格式后面每一步都别扭。所以把这个转换做好等于给自己后续流程铺了一条顺路。2. 转换原理不就是加个宽高但边界情况得留意2.1 数学关系拆解转换本身没有技术含量就是两行加法xmax xmin width ymax ymin height反过来也一样简单width xmax - xmin height ymax - ymin为什么会这样因为框是矩形且边和图像坐标轴对齐所以从左上角往右走 width 个像素就是右下角的横坐标往下走 height 个像素就是右下角的纵坐标。这就是 Axis-Aligned Bounding Box简称 AABB目标检测里绝大多数框都是这种形式。但你别小看这两行加法。很多工程 bug 恰恰出在这里数据源头给的 width 或 height 可能是小数可能是负数可能是 0也可能是字符串。直接拿去加轻则精度漂移重则下标越界模型训练直接崩掉。所以核心不是公式而是公式前的数据清洗和公式后的边界校验。2.2 坐标偏移与像素对齐问题其实没你想的那么玄一个经常被问到的细节是从 xmin 到 xmax到底算不算含端点width 到底是xmax - xmin还是xmax - xmin 1这里最靠谱的做法是看标注工具自己怎么定义。OpenCV 的rectangle函数、Matplotlib 的Rectangle画框时都是把坐标当连续值处理默认不含端点直接用 xmin width 得到 xmax视觉上完全吻合。PIL 的ImageDraw.rectangle是含端点的但它的参数本身就是坐标对不涉及宽高换算。所以你在做 xmin,ymin,wh 到 xmin,ymin,xmax,ymax 转换时直接用加法就行不用关心那个正负 1。如果你在做数据增强、裁剪这类像素级操作才需要额外小心。比如你用 PIL 裁剪crop((xmin, ymin, xmax, ymax))PIL 的坐标系是含左不含右的恰好和 xmin 加 width 的定义一致转完拷过去没有任何问题。用 OpenCV 切片img[ymin:ymax, xmin:xmax]同理。这一块没有坑放心用。2.3 边界裁剪与异常值真正需要警惕的是越界。标注工具里手滑拉过头或者缩放增强后坐标超出图像范围都是常见事故。转换后必须检查xmax、ymax是否落在[0, width-1]和[0, height-1]范围内。还有一种情况更隐蔽原标注的 width 或 height 本身就是负数。这说明标注时左上角比右下角还靠外数据是坏的。转换前最好加一个校验碰到非法框直接报错或者过滤掉别让它流到训练流程里。我个人的习惯是在转换函数里内置一个合法性子检查坐标都是有限数、width 和 height 大于 0、转换后的 xmax 不小于 xmin、ymax 不小于 ymin。任何一条不满足就抛异常宁可中断也别静默处理。因为多数情况下这种坏数据量很少修好源头比在后续流程里排查更省事。3. 代码实现从单条做到全数据集批量处理3.1 单条转换与数据结构设计先写一个基本的转换函数足够应对单条数据def xywh_to_xyxy(box): 将 [xmin, ymin, width, height] 转为 [xmin, ymin, xmax, ymax] xmin, ymin, width, height box xmax xmin width ymax ymin height return [xmin, ymin, xmax, ymax]这是最朴素的写法。但工程上你往往需要连续转换几千上万条这时候就要考虑数据怎么组织。我建议用一个统一的中间结构Box存坐标Label存类别Sample存图像路径加若干 Label一条样本一个对象。这样一个列表能同时适配 JSON、XML、txt 各种源头的解析结果后续要输出成任何格式都方便。class Box: __slots__ (xmin, ymin, xmax, ymax) def __init__(self, xmin, ymin, xmax, ymax): self.xmin xmin self.ymin ymin self.xmax xmax self.ymax ymax classmethod def from_xywh(cls, xmin, ymin, width, height): return cls(xmin, ymin, xmin width, ymin height) property def width(self): return self.xmax - self.xmin property def height(self): return self.ymax - self.ymin3.2 批量处理 VOC XML 文件VOC 格式的 XML 文件长这样bndbox节点里存的就是 xmin、ymin、xmax、ymax并不需要转换。但有一种特殊情况你手上的 XML 是半成品工具生成的bndbox里存了 width 和 height那就得转一下。object namecat/name bndbox xmin100/xmin ymin150/ymin xmax200/xmax ymax250/ymax /bndbox /object用xml.etree.ElementTree解析再回写即可import xml.etree.ElementTree as ET def convert_xml_wh_to_xyxy(xml_path): tree ET.parse(xml_path) root tree.getroot() for obj in root.iter(object): bndbox obj.find(bndbox) if bndbox is None: continue if bndbox.find(width) is not None and bndbox.find(xmax) is None: xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) width float(bndbox.find(width).text) height float(bndbox.find(height).text) # 删除旧节点写入新的 xmax/ymax bndbox.remove(bndbox.find(width)) bndbox.remove(bndbox.find(height)) ET.SubElement(bndbox, xmax).text str(int(xmin width)) ET.SubElement(bndbox, ymax).text str(int(ymin height)) tree.write(xml_path, encodingutf-8, xml_declarationTrue)这里有个细节解析时先判断xmax节点是否存在如果存在说明已经是目标格式直接跳过避免重复转换把数据搞乱。如果 width 是浮点数最后回写时用int()取整避免 XML 里出现奇奇怪怪的精度位。3.3 批量处理 COCO JSON 文件COCO 格式的 JSON 是更常见的来源。它的结构是images数组加annotations数组annotations每条有一个bbox字段就是[x, y, width, height]。你要把它转成 x1y1x2y2 格式最省事的办法是直接改 bbox 字段内容让[x, y, w, h]变成[x, y, xw, yh]。这样下游那些按索引取值、但不关心字段语义的脚本都能直接用。但如果你下游是按 COCO API 读的它有自己的一套coco.loadImgs、coco.loadAnns接口你改了 bbox 语义也没关系COCO API 本来就是按 bbox 的前四个数来用的只是你在可视化时得按 x1y1x2y2 去解析。import json def convert_coco_bbox_inplace(json_path, out_path): with open(json_path, r, encodingutf-8) as f: coco json.load(f) for ann in coco[annotations]: x, y, w, h ann[bbox] ann[bbox] [x, y, x w, y h] with open(out_path, w, encodingutf-8) as f: json.dump(coco, f, ensure_asciiFalse, indent2)注意一点COCO 的bbox值通常保留多位小数。你转成 x1y1x2y2 后如果下游画图用cv2.rectangle它要求坐标是 int 或支持整数运算的否则会报类型错误。所以在转换时我习惯保留原始数值类型但后续可视化前统一int()不要在这里提前取整。提前取整可能丢掉像素精度影响检测框评估结果尤其是小目标少 1 个像素 IoU 都会掉一点。3.4 转完以后务必做一次可视化验证代码写完了最怕的是逻辑看着对、实际跑歪。我的习惯是转完一批数据后随机抽 20 张图把转换后的框画在原图上肉眼过一遍。主要看三点框是否包住目标、是否超出图像边界、是否出现错位。可视化代码不复杂基于 OpenCV 几行就够import cv2 def draw_boxes(image_path, boxes, labelsNone, out_pathdebug.jpg): img cv2.imread(image_path) for i, box in enumerate(boxes): xmin, ymin, xmax, ymax [int(v) for v in box] label labels[i] if labels else str(i) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (0, 255, 0), 2) cv2.putText(img, label, (xmin, max(0, ymin - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) cv2.imwrite(out_path, img)看到框错位、飘在目标外先别怀疑转换公式大概率是原始标注就没画准。这时候回去检查源数据别在转换层硬调。如果只是个别图边界溢出那才是转换后缺裁剪回来补边界处理。4. 嵌进训练流程别忘了与 YOLO/COCO 格式之间的联动4.1 和其他格式的互通关系这个转换并不是孤立存在的实际项目里经常要一条链路全打通xmin,ymin,wh先转成xmin,ymin,xmax,ymax再转成 YOLO 的cx,cy,w,h归一化格式。很多人在这里又卡住了。YOLO 的标签格式是每行一个目标五个数类别id、中心点x、中心点y、宽w、高h全部除以图片宽高做归一化。要从xmin, ymin, xmax, ymax转过去公式是xc (xmin xmax) / 2 / img_width yc (ymin ymax) / 2 / img_height w (xmax - xmin) / img_width h (ymax - ymin) / img_height反过来从 YOLO 的cx, cy, w, h转回 x1y1x2y2 是xmin (xc - w / 2) * img_width ymin (yc - h / 2) * img_height xmax (xc w / 2) * img_width ymax (yc h / 2) * img_height注意这里的 img_width 和 img_height 必须是这张图实际解码后的宽高不能拿数据集配置里的默认值代替。不同图的尺寸不一样尤其在检测数据集里很常见。如果拿错尺寸框位置会整体飘移而且不同图飘的方向还不一样非常难排查。4.2 归一化坐标的坑YOLO 格式的归一化坐标是我见过引发 bug 最多的地方。很多人从网上下了一套 txt 标注发现坐标全是 0.1、0.3 这种小数转回来时忘了乘回图像宽高导致画框全缩在左上角一小块区域。还有一个隐蔽问题归一化坐标可能存在极小误差比如真实中心点是 0.3333333标注文件里写的是 0.333333乘回宽度后差个零点几像素。对小目标来说这个误差可能直接导致框和目标重叠率下降。处理方式是在使用归一化坐标前先乘宽高再用 round 而不是 int 截断减少误差累积。xmin round((xc - w / 2) * img_width) ymin round((yc - h / 2) * img_height)4.3 一个顺手的工具箱函数整合一下我项目里长期放着一段工具函数遇到格式问题直接调用def box_xywh_to_yolo(xmin, ymin, width, height, img_width, img_height): xc (xmin width / 2) / img_width yc (ymin height / 2) / img_height w width / img_width h height / img_height return [xc, yc, w, h] def box_yolo_to_xyxy(xc, yc, w, h, img_width, img_height): xmin round((xc - w / 2) * img_width) ymin round((yc - h / 2) * img_height) xmax round((xc w / 2) * img_width) ymax round((yc h / 2) * img_height) return [xmin, ymin, xmax, ymax] def box_xyxy_to_coco(xmin, ymin, xmax, ymax): return [xmin, ymin, xmax - xmin, ymax - ymin]这段代码是我从十多个项目里不停地加加减减沉淀下来的。每做一个新数据集先跑一遍这些函数把标注格式统一后续训练验证再也不用来回改脚本。5. 常见问题与排查实录5.1 转完坐标出现负数转换后xmin或ymin变成负数几乎可以断定是原始标注越界了。标注工具有些允许你拉出图像边界生成的标注里坐标是负的但用户看不到提示。处理思路是先统计一下有多少框越界如果只有零星几个直接裁剪到[0, 图像宽高)范围内xmin max(0, xmin) ymin max(0, ymin) xmax min(img_width - 1, xmax) ymax min(img_height - 1, ymax)但这样会导致框的实际面积变小如果裁剪后框宽度为 0 或高度为 0这个框必须丢掉。另一种做法是直接过滤这些样本回到标注工具里重新标注。我倾向于后者因为少数几个坏框裁剪后会破坏训练数据的完整性。5.2 数值精度导致的框偏移当图片很大、坐标数值上百甚至上千时浮点数加法的精度误差几乎可以忽略。但如果你从 COCO JSON 读出来的 bbox 是[123.456, 78.910, 45.123, 32.456]直接用float相加再转 int四舍五入的误差可能让框边缘偏移 1 像素。小目标检测场景对这类误差特别敏感。几个像素的偏移就可能把 IoU 从 0.8 拉到 0.6。解决办法是转换全程保持浮点运算只在最后输出时用round并且可视化评测时也用同样的舍入规则保证评价标准一致。5.3 考古题标签文件里混着多种格式最让人头疼的不是某种格式不会转而是同一个数据集里既有 x1y1x2y2又有 x1y1wh还有 cxcywh来源不同拼在一起。这种情况我建议先写一个探测函数自动识别每行属于哪种格式。识别依据很简单如果四个值中前两个小于 1、后两个也小于 1且整行数值都在 0-1 之间大概率是归一化的 cxcywh。如果四个值都是大于 1 的正整数那么需要看第四个和第三个的关系如果第四个明显小于第三个加一个偏移量大概率是 x1y1wh如果第四个比第三个大大概率是 x1y1x2y2。不过这只是启发式判断最终还得靠可视化验证。def guess_format(box, img_width, img_height, threshold1.0): x0, y0, x1, y1 box if x0 threshold and y0 threshold and x1 threshold and y1 threshold: return yolo if x1 x0 and y1 y0: return xyxy if x1 0 and y1 0: return xywh return unknown这段代码实测够用但它不是万能的。遇到比较极端的分布最好的办法是随机抽几张图的标签拿原始图像对照一下确认格式后再批量处理。5.4 转完后图片尺寸变了坐标也得跟着变很多人处理数据会把图片缩放一下比如统一到 640x640 或 1280x1280。转换前的坐标是针对原始尺寸的如果缩放图片后不更新标注全部都会错位。处理这个问题核心是记住等比缩放的比例。原理很简单标签跟着图片走所有坐标乘以相同的缩放系数。scale_x new_width / original_width scale_y new_height / original_height # 假设是等比缩放scale_x scale_y new_xmin xmin * scale_x new_ymin ymin * scale_y new_xmax xmax * scale_x new_ymax ymax * scale_y如果是 letterbox 那种加了灰边的缩放方式额外的偏移量也要加进去# pad 灰边补充量单边 new_xmin xmin * scale pad_x new_ymin ymin * scale pad_y这个操作顺序有讲究先转成 x1y1x2y2 再缩放或者先缩放宽高再转回来结果是一样的。但如果你处理的是 YOLO 归一化坐标缩放后归一化坐标数值不变所以很多人会先把所有标注统一成 YOLO 格式再做缩放增广最后才转回 x1y1x2y2。这也是一种避免比例换算的好办法。6. 最后说几句实操体会坐标转换这个东西看起来简单但几乎每个做目标检测的人都会在某一天被它绊倒一次。我的建议是项目开始第一步就把标注格式定成一种全流程统一走它不要中途来回切。我习惯把所有数据先转成xmin, ymin, width, height存一份原始备份然后代码层统一输出成xmin, ymin, xmax, ymax供可视化、评估使用训练时再转成 YOLO 格式。这样原始数据不会被破坏出问题时随时可以回溯。另外一个多年养成的习惯是转换完一定做一轮可视化抽查不要只看数字。很多你以为转对的数据画出来才是真相。你在代码里省下的十分钟可能会在训练出 NaN 或者评估指标离谱时花更多时间还回去。这篇文章里的代码片段都是从实际项目里直接抽出来的可以直接复制到你的脚本里试试。有问题欢迎在评论区交流尤其是那些奇奇怪怪的数据集格式说不定你遇到的情况我早期也踩过。