159 lines
4.7 KiB
Python
159 lines
4.7 KiB
Python
|
|
# yolo_obb_to_cvat_full.py
|
|||
|
|
import os
|
|||
|
|
import math
|
|||
|
|
import xml.etree.ElementTree as ET
|
|||
|
|
from pathlib import Path
|
|||
|
|
import cv2
|
|||
|
|
|
|||
|
|
|
|||
|
|
def recover_rotated_box(points):
|
|||
|
|
"""
|
|||
|
|
YOLO OBB 四点 → 中心点、宽高、旋转角度
|
|||
|
|
点顺序为:p1 p2 p3 p4,逆时针/顺时针均可接受
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
cx = sum(p[0] for p in points) / 4
|
|||
|
|
cy = sum(p[1] for p in points) / 4
|
|||
|
|
|
|||
|
|
# 点1→点2 作为宽方向
|
|||
|
|
vx = points[1][0] - points[0][0]
|
|||
|
|
vy = points[1][1] - points[0][1]
|
|||
|
|
|
|||
|
|
w = math.sqrt(vx*vx + vy*vy)
|
|||
|
|
|
|||
|
|
# 点2→点3 作为高方向
|
|||
|
|
vx2 = points[2][0] - points[1][0]
|
|||
|
|
vy2 = points[2][1] - points[1][1]
|
|||
|
|
|
|||
|
|
h = math.sqrt(vx2*vx2 + vy2*vy2)
|
|||
|
|
|
|||
|
|
# 角度 = 边1 与水平线夹角
|
|||
|
|
angle_rad = math.atan2(vy, vx)
|
|||
|
|
angle_deg = math.degrees(angle_rad)
|
|||
|
|
|
|||
|
|
# CVAT 角度必须是 0~360
|
|||
|
|
if angle_deg < 0:
|
|||
|
|
angle_deg += 360
|
|||
|
|
|
|||
|
|
return cx, cy, w, h, angle_deg
|
|||
|
|
|
|||
|
|
|
|||
|
|
def yolo_obb_to_cvat_xml(label_dir, image_dir, class_id_to_name, output_xml):
|
|||
|
|
|
|||
|
|
label_dir = Path(label_dir)
|
|||
|
|
image_dir = Path(image_dir)
|
|||
|
|
|
|||
|
|
# ======== 构建基本 XML 结构(与你发的一模一样) ========
|
|||
|
|
root = ET.Element("annotations")
|
|||
|
|
|
|||
|
|
ET.SubElement(root, "version").text = "1.1"
|
|||
|
|
|
|||
|
|
# meta/task
|
|||
|
|
meta = ET.SubElement(root, "meta")
|
|||
|
|
task = ET.SubElement(meta, "task")
|
|||
|
|
|
|||
|
|
ET.SubElement(task, "id").text = "1"
|
|||
|
|
ET.SubElement(task, "name").text = "yolo_obb_import"
|
|||
|
|
ET.SubElement(task, "size").text = str(len(list(label_dir.glob("*.txt"))))
|
|||
|
|
ET.SubElement(task, "mode").text = "annotation"
|
|||
|
|
ET.SubElement(task, "overlap").text = "0"
|
|||
|
|
ET.SubElement(task, "bugtracker").text = ""
|
|||
|
|
ET.SubElement(task, "created").text = ""
|
|||
|
|
ET.SubElement(task, "updated").text = ""
|
|||
|
|
ET.SubElement(task, "subset").text = "default"
|
|||
|
|
ET.SubElement(task, "start_frame").text = "0"
|
|||
|
|
ET.SubElement(task, "stop_frame").text = str(len(list(label_dir.glob("*.txt"))) - 1)
|
|||
|
|
ET.SubElement(task, "frame_filter").text = ""
|
|||
|
|
|
|||
|
|
# labels
|
|||
|
|
labels = ET.SubElement(task, "labels")
|
|||
|
|
for name in class_id_to_name.values():
|
|||
|
|
lab = ET.SubElement(labels, "label")
|
|||
|
|
ET.SubElement(lab, "name").text = name
|
|||
|
|
ET.SubElement(lab, "color").text = "#ffffff"
|
|||
|
|
ET.SubElement(lab, "type").text = "any"
|
|||
|
|
ET.SubElement(lab, "attributes")
|
|||
|
|
|
|||
|
|
ET.SubElement(meta, "dumped").text = ""
|
|||
|
|
|
|||
|
|
# ======== 处理每张图片 ========
|
|||
|
|
for idx, txt_file in enumerate(sorted(label_dir.glob("*.txt"))):
|
|||
|
|
stem = txt_file.stem
|
|||
|
|
img_file = None
|
|||
|
|
|
|||
|
|
# 自动匹配图片
|
|||
|
|
for ext in ["jpg", "png", "jpeg", "bmp", "webp"]:
|
|||
|
|
p = image_dir / f"{stem}.{ext}"
|
|||
|
|
if p.exists():
|
|||
|
|
img_file = p
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
if img_file is None:
|
|||
|
|
print(f"⚠ 找不到图片: {stem}")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
img = cv2.imread(str(img_file))
|
|||
|
|
H, W = img.shape[:2]
|
|||
|
|
|
|||
|
|
# image tag
|
|||
|
|
image_elem = ET.SubElement(root, "image", {
|
|||
|
|
"id": str(idx),
|
|||
|
|
"name": img_file.name,
|
|||
|
|
"width": str(W),
|
|||
|
|
"height": str(H)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# 读取 txt
|
|||
|
|
with open(txt_file, "r") as f:
|
|||
|
|
for line in f.readlines():
|
|||
|
|
parts = line.strip().split()
|
|||
|
|
cls_id = int(parts[0])
|
|||
|
|
coords = list(map(float, parts[1:]))
|
|||
|
|
|
|||
|
|
pts = []
|
|||
|
|
for i in range(0, 8, 2):
|
|||
|
|
x = coords[i] * W
|
|||
|
|
y = coords[i+1] * H
|
|||
|
|
pts.append((x, y))
|
|||
|
|
|
|||
|
|
# 四点 → 框参数
|
|||
|
|
cx, cy, bw, bh, ang = recover_rotated_box(pts)
|
|||
|
|
|
|||
|
|
xtl = cx - bw / 2
|
|||
|
|
ytl = cy - bh / 2
|
|||
|
|
xbr = cx + bw / 2
|
|||
|
|
ybr = cy + bh / 2
|
|||
|
|
|
|||
|
|
# box 标签(完全跟你的一样)
|
|||
|
|
ET.SubElement(image_elem, "box", {
|
|||
|
|
"label": class_id_to_name[cls_id],
|
|||
|
|
"source": "manual",
|
|||
|
|
"occluded": "0",
|
|||
|
|
"xtl": f"{xtl:.2f}",
|
|||
|
|
"ytl": f"{ytl:.2f}",
|
|||
|
|
"xbr": f"{xbr:.2f}",
|
|||
|
|
"ybr": f"{ybr:.2f}",
|
|||
|
|
"rotation": f"{ang:.2f}",
|
|||
|
|
"z_order": "0"
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
print(f"✔ 处理 {img_file.name}")
|
|||
|
|
|
|||
|
|
# 保存 XML
|
|||
|
|
tree = ET.ElementTree(root)
|
|||
|
|
tree.write(output_xml, encoding="utf-8", xml_declaration=True)
|
|||
|
|
print(f"\n🎉 已生成 XML:{output_xml}")
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
CLASS_MAP = {
|
|||
|
|
0: "clamp1",
|
|||
|
|
1: "clamp0",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
yolo_obb_to_cvat_xml(
|
|||
|
|
label_dir="/media/hx/04e879fa-d697-4b02-ac7e-a4148876ebb0/dataset/1/222",
|
|||
|
|
image_dir="/media/hx/04e879fa-d697-4b02-ac7e-a4148876ebb0/dataset/1/111",
|
|||
|
|
class_id_to_name=CLASS_MAP,
|
|||
|
|
output_xml="converted_annotations.xml"
|
|||
|
|
)
|