简介:这是一份面向计算机视觉开发者的roLabelImg旋转标注工具完整源码包。该工具在普通目标检测标注基础上增加旋转矩形框能力,解决常规标注框无法贴合旋转目标的问题,适用于遥感影像、OCR文字区域、工业缺陷检测等需要倾斜框标注的场景。压缩包共94个文件,以Python源码(.py)、界面图标与示例截图(.png/.svg)、编译缓存(.pyc)为主,辅以XML配置、Shell构建脚本、Qt资源文件(.qrc)、Makefile及测试用例等,整体约16.44MB,完整保留原始项目目录结构,便于按模块查阅和二次开发。目前已有630人学习下载。资源内含可运行的标注器主程序,以及Pascal VOC格式读写、画布交互、标签管理、工具栏与颜色对话框等模块代码,附带demo图片和演示gif,可帮助开发者快速理解旋转框标注的实现思路,并据此扩展自定义功能,很适合需要深入定制标注工具的算法工程师和研究人员。
1. 旋转标注为什么是专门一件事:roLabelImg 解决的痛点
第一次做遥感图像的旋转目标检测时,我顺手拿 LabelImg 开始标船、标飞机。标到三百张回头检查训练样本,正框框住的大半是海面和停机坪背景,模型的损失一直降不下去。后来换成 roLabelImg,同样是开源标注工具,差别在它能画带角度的矩形、保存时把角度写进 XML。这篇围绕旋转标注源码,拆三件事:旋转框的数据结构与 theta 约定、源码怎么编译启动、核心绘制逻辑怎么读;最后给一份真实标注流程里踩过的坑清单。适合三类人:算法工程师要给 OBB 模型找顺手的标注工具;标注组长要评估 roLabelImg 能不能进产线;开发者想把它改造成带自动保存和批量自检的流水线工具。
2. 旋转框的数据结构与 theta 约定:先把这层搞懂再动代码
改 roLabelImg 之前,我建议先搞清楚一个旋转框在它内部到底是怎么描述的。很多人在界面上点得顺手,一写导出脚本就开始翻车,root cause 几乎都落在表示法上:同一个框,用中心点加角度表达,和用四个角点表达,写进文件、喂给模型、画在画布上是三套完全不同的逻辑。这章先把数据约定定死,后面读源码才不会懵。
2.1 旋转框的两种主流表示法:五参数与四点各管一段
旋转框最常见有两种记法。第一种是中心点五参数:cx, cy, w, h, theta,即中心坐标、宽、高、旋转角度。OpenCV 的cv2.minAreaRect、很多检测头输出都用它。第二种是四点坐标(x1,y1) (x2,y2) (x3,y3) (x4,y4),DOTA 系列任务和部分部署代码习惯用这种,显式、免去三角函数,但要自己保证点的顺序和凸性。
| 表示法 | 典型字段 | 优点 | 缺点 |
|---|---|---|---|
| 五参数 | cx, cy, w, h, theta | 紧凑,几何直观,编辑框好实现 | theta 方向约定极易搞错 |
| 四点坐标 | x1,y1,...,x4,y4 | 可直接写入训练样本,无需计算 | 冗余,顺序/凸性要额外校验 |
roLabelImg 的落盘格式走的是五参数路线,内存里维护的却是四点坐标。这个“外存五参数、内存四点”的双轨设计,是后面几乎所有格式坑的来源。读源码时你会看到libs/shape.py里Shape对象保存的是四个QPointF,同时挂着angle字段;而libs/labelFile.py落盘时又把四点换算回cx, cy, w, h, theta。两头各算一次,方向约定不一致就必然出偏差。
2.2 robndbox 的 XML 结构:在 Pascal VOC 上做的最小侵入扩展
roLabelImg 保存的 XML 沿用 Pascal VOC 的根结构,但把物体的框从<bndbox>换成了<robndbox>,这是它对标注文件做的关键扩展。一份典型样本长这样:
<annotation verified="yes"> <folder>JPEGImages</folder> <filename>000321.jpg</filename> <path>/data/000321.jpg</path> <source> <database>Unknown</database> </source> <size> <width>1024</width> <height>768</height> <depth>3</depth> </size> <segmented>0</segmented> <object> <name>ship</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <robndbox> <cx>512.00</cx> <cy>384.00</cy> <w>120.00</w> <h>60.00</h> <theta>30</theta> </robndbox> </object> </annotation>theta单位是度,不是弧度,这一点大部分人第一次写转换脚本都会看漏。cx, cy是旋转框中心的绝对像素坐标,w, h是未旋转时矩形的宽高。之所以说“最小侵入扩展”,是因为object的层级、name、difficult这些字段都保留了,只是把框描述替换掉。这样任何按 Pascal VOC 结构解析的老代码不会解析崩溃,最多是拿不到框坐标而已。源码里labelFile.py读取时会先找robndbox,找不到再退回bndbox,这个回退逻辑保持了对历史正框标注的兼容。
2.3 从 theta 换算四个角点:方向约定比想象的更坑
读源码前建议先把五参数到四点的换算写熟。下面的函数是各种导出脚本的公共底座:
import math def rot_rect_to_corners(cx, cy, w, h, theta_deg): """把中心点表示法转成四个角点,返回顺序按原始矩形边展开""" t = math.radians(theta_deg) cos_t, sin_t = math.cos(t), math.sin(t) corners = [] for dx, dy in [(-w/2, -h/2), (w/2, -h/2), (w/2, h/2), (-w/2, h/2)]: x = cx + dx * cos_t - dy * sin_t y = cy + dx * sin_t + dy * cos_t corners.append((round(x, 2), round(y, 2))) return corners这段代码唯一的争议点就是theta的符号。图像坐标系里 y 轴向下,数学里正的旋转角是逆时针,但在屏幕上看会变成顺时针。同一份 XML,训练脚本用 OpenCV 画出来顺时针,用它自己的可视化代码画出来逆时针,这种“一个标注两幅画”的冲突我见过不止一次。解法是统一约定:要么全部按 roLabelImg 界面上展示的视觉方向为准,要么在导出脚本里对 theta 取负号,二选一后固定下来。
反向换算一般用cv2.minAreaRect偷懒,但注意minAreaRect返回的角度范围是[-90, 0),和 roLabelImg 的[-180, 180)并不一致,直接用会得到转过的等效框,宽度高度也会互换。我一般会在换算后把角度归一化到(-90, 90],再校验角点重算结果是否和原始角点大致重合。把这段校验写进导出脚本,能拦下大部分 theta 符号的坑。
3. 从源码编译到启动:环境搭配、资源编译与入口流程
拿到源码第一步不是读代码,是先让它跑起来。roLabelImg 本质是一个 PyQt5 应用,依赖不复杂,但版本一旦配错,表现出的症状非常迷惑:窗口能开、图片能加载、一按快捷键就崩。这一章把环境、资源编译、入口流程一次讲完,照着做十分钟内能打开标注界面。
3.1 环境版本怎么配:Python、PyQt5、lxml 的三者匹配
| 组件 | 建议版本 | 说明 |
|---|---|---|
| Python | 3.8 ~ 3.9 | 兼容性最稳,3.10 以上也能跑,但个别 lxml 轮子要自己编译 |
| PyQt5 | 5.15.7 | 5.15 系列 API 稳定,pyrcc 行为一致 |
| lxml | 4.9.x | XML 读写和资源编译都用得到 |
Python 版本我建议直接装 3.9。太新的 Python 加上太老的 PyQt5,会出现QApplication初始化时 xcb 插件加载失败的怪问题,尤其是在纯净 Linux 服务器上。Windows 反而简单,pip 装完基本能跑,后面第五章节会说一个 Windows 下常见的界面坑。
依赖安装就三条命令,顺序别乱:
python -m pip install PyQt5==5.15.7 python -m pip install lxml==4.9.3 python -m pip install PyQt5-toolsPyQt5-tools 不是运行时依赖,它只为了提供pyrcc5命令行工具。如果你用的是 Linux 发行版自带的 PyQt5,pyrcc5通常已经在了;Windows 下 pip 装的 PyQt5 不带这个命令,必须靠 PyQt5-tools 补上。没有这一步,后面资源编译就卡住。
3.2 拉源码并编译 resources:pyrcc5 决定了图标和快捷键
克隆源码后先不要急着运行,优先处理resources.qrc:
git clone 你找到的 roLabelImg 仓库地址 cd roLabelImg python -m PyQt5.pyrcc_main -o libs/resources.py resources.qrc python labelImg.py第一行是拉代码,仓库入口在 GitHub 上搜项目名就能找到,这里不替你把地址写死。第二行进入目录后,第三行是关键:resources.qrc是 Qt 的资源清单文件,里面登记了图标、样式表和启动图,pyrcc_main把它编译成 Python 模块libs/resources.py。
很多仓库自带了编译好的resources.py,但那个文件经常和当前源码里的资源清单对不上,表现就是图标缺失、快捷键没反应、菜单项文字变方块。我拿到任何 fork 版本的第一件事就是删掉旧的resources.py重新编译。labelImg.py模块顶部会有import libs.resources,这一行没有对应的resources.py时程序会直接 ImportError,如果报了这个错,十有八九是编译没做或路径不对。注意运行命令必须在仓库根目录执行,源码里对data/predefined_classes.txt、图标路径用的是相对路径,换个目录启动就会找不到预置类别文件。
3.3 启动流程拆解:从 main 到主窗口初始化做了什么
入口文件labelImg.py的main函数很短,但干了三件影响使用的事:
def main(argv): app = QApplication(argv) win = LabelImgWindow() win.show() return app.exec_() if __name__ == '__main__': main(sys.argv)看起来平淡,实际LabelImgWindow的构造函数里做了这些初始化:读取settings.ini恢复上次打开目录、加载data/predefined_classes.txt填充类别列表、创建Canvas并把它装进滚动区域、注册全部快捷键动作。所以你在命令行可以传目录参数直接打开一个图片文件夹:
python labelImg.py /data/images传了目录参数后,窗口会直接加载该目录的第一张图并启用前进后退快捷键。这个参数用法在二次开发时很实用——你完全可以在自己的调度程序里把图集目录传给 roLabelImg,让它做纯标注终端。预置类别文件是纯文本,每行一个类别名,首次启动前改好它,能省掉标注员每次手动输入的重复劳动。
4. 核心源码逐段拆解:绘制、旋转与落盘的完整链路
跑起来之后,重点看三条代码链路:鼠标如何画出一个旋转框、Shape 对象如何维护角度和位置、落盘时又如何写进 XML。这三段读明白,roLabelImg 的二次开发就完成了八成。
4.1 canvas.py 的鼠标事件流:一个旋转框是怎么被画出来的
libs/canvas.py里Canvas继承QWidget,核心是三个鼠标事件。按下时记录起点,移动时更新当前绘制形状,松开时把形状提交到 shapes 列表。简化后的逻辑:
def mousePressEvent(self, ev): pos = ev.pos() if ev.button() == Qt.LeftButton: if self.createMode: self.current = Shape() self.current.addPoint(self.snapToGrid(pos)) self.drawingShape = True else: self.setEditingShape(pos) def mouseMoveEvent(self, ev): pos = ev.pos() if self.drawingShape and self.current is not None: self.current.rotateTo(pos) # 旋转框:移动的是角度 self.update() def mouseReleaseEvent(self, ev): if self.drawingShape: self.shapes.append(self.current) self.current = None self.drawingShape = False注意rotateTo而不是常规的setLastPoint,这是 roLabelImg 与 LabelImg 最大的行为差异。正框工具是“按下、拖拽、松开”确定对角线;旋转框工具是“按下定中心、拖拽定角度和尺寸”,松开后才把框固化。也就是说,你按下第一点时它先把一个过大的初始框放在屏幕上,拖拽过程中同时修正长宽和角度。
createMode这个标志决定了当前是画框还是编辑已有框。界面上按 W 进正框模式、按 E 进旋转框模式、按 D 进编辑模式,本质都是切换Canvas内部这个标志。读这段代码时重点看snapToGrid:如果网格吸附开启,所有坐标会被取整到设定步长,这在大图标注时会引入像素级偏差,产线要求高时建议关掉。
4.2 Shape 的旋转与拖动:rotateTo、moveBy 背后的坐标变换
libs/shape.py里的Shape类维护四个点和一个角度。旋转的核心是rotateTo,做法是不断用鼠标当前位置相对中心的方位角更新角度:
def rotateTo(self, point): if self.center is None: return dx = point.x() - self.center.x() dy = point.y() - self.center.y() self.angle = math.degrees(math.atan2(dy, dx)) self.updatePointsFromCenter() def updatePointsFromCenter(self): t = math.radians(self.angle) cos_t, sin_t = math.cos(t), math.sin(t) for i, (dx, dy) in enumerate(self._offsets): self.points[i].setX(self.center.x() + dx * cos_t - dy * sin_t) self.points[i].setY(self.center.y() + dx * sin_t + dy * cos_t)atan2(dy, dx)返回的是[-180, 180)的角度,这解释了为什么源码里 theta 会出现负值。_offsets是当前宽高的一半组合,就是第 2 章那个换算表。这里有个隐性约定:宽高在旋转过程中是不变的,变的只有角度。所以你想实现“拖拽一条边同时改宽高和角度”,光改rotateTo不够,得先算鼠标所在边是哪条,再更新对应_offsets分量。社区不少 fork 加了这个能力,但原版没有。
moveBy就朴素得多,给四个点同时加偏移量:
def moveBy(self, dx, dy): for p in self.points: p.setX(p.x() + dx) p.setY(p.y() + dy)它不更新center,因为center是从points均值实时算的。读源码时注意这两类更新路径的区分:一个改角度不改中心,一个改坐标不改角度,二者都靠update()触发重绘。这段逻辑后面加自动保存时不需要动,但如果你要自定义旋转手柄的绘制,必须在paintEvent里找到手柄的绘制分支。
4.3 labelFile.py 的读写链路:从内存 Shape 到 XML 落盘
落盘和读取都在libs/labelFile.py。保存的核心是遍历当前画布的 shapes,逐个写<object>子节点:
def savePascalVocFormat(self, filename, shapes, imagePath): root = ET.Element('annotation') size = ET.SubElement(root, 'size') ET.SubElement(size, 'width').text = str(self.imageWidth) ET.SubElement(size, 'height').text = str(self.imageHeight) for shape in shapes: obj = ET.SubElement(root, 'object') ET.SubElement(obj, 'name').text = shape.label rb = ET.SubElement(obj, 'robndbox') ET.SubElement(rb, 'cx').text = f"{shape.center.x():.2f}" ET.SubElement(rb, 'cy').text = f"{shape.center.y():.2f}" ET.SubElement(rb, 'w').text = f"{shape.width():.2f}" ET.SubElement(rb, 'h').text = f"{shape.height():.2f}" ET.SubElement(rb, 'theta').text = f"{shape.angle:.2f}" tree = ET.ElementTree(root) tree.write(filename, encoding='utf-8', xml_declaration=True)这里有几个值得注意的实现细节。第一,数值全部格式化到小数点后两位,这是源码避免 XML 体积膨胀的做法,但代价是精度截断,超大图上反复加载保存会累积像素漂移。第二,写的是shape.center.x(),而center是从 four points 实时算均值,如果上一个操作刚好把四点拖得不完整,落盘的cx, cy就会偏离真实中心。第三,labelFile.py读取时兼容了bndbox和robndbox两种节点,读到普通正框时会把角度初始化为 0,框的中心取正框对角线中点。
读取方向反着来一遍:解析robndbox后构造成一个Shape,再调用updatePointsFromCenter生成四个点,画布上就有框了。如果解析失败,源码会抛异常并把这条 XML 标记为损坏,界面上表现为“图片打开成功但没有任何框”。遇到这种情况先手动检查 XML 里的theta字段是否为空或非数字,这是最常见的损坏原因。
5. roLabelImg 高频问题排查:五个必踩的坑与对应解法
这章按“现象 → 原因 → 解决”记录我在标注产线和二次开发里真实撞过的五个坑。每条都先说症状,再给原因,最后给可操作的解法,你遇到时可以直接按图索骥。
5.1 坑一:保存后再打开,旋转框的角度方向反了
现象:标注时把船头朝向设为 30 度,保存后重新打开 XML,画布上框变成 -30 度,可视化脚本画出来也跟标注时的截图完全镜像。
原因:theta的符号在写入和读取之间没有做坐标系转换。界面上你看到的角度是屏幕坐标(y 向下),而atan2算出的角度是数学坐标(y 向上),两者差一个负号。部分 fork 在读取时做了取反,部分没有,跨版本套用同样的 XML 就炸了。
解决:写一个统一的角度归一化函数,在落盘和读取两侧各调用一次:
def normalize_theta(theta_deg): while theta_deg <= -90: theta_deg += 180 while theta_deg > 90: theta_deg -= 180 return round(theta_deg, 2)同时把你的可视化脚本和 roLabelImg 用同一套正负约定。我的习惯是以读取脚本为准:先打印一批样本的角度直方图,确认正数角度在图像上表现为顺时针还是逆时针,再决定要不要给 theta 加负号。不要想当然。
5.2 坑二:标注超大遥感图时拖动卡顿到没法用
现象:打开一张几千乘几千像素的 TIF,鼠标移动时框和图像都明显掉帧,旋转一个框要等半秒。
原因:Canvas.paintEvent每次鼠标移动都会重绘整个画布上的所有 shape。图越大,QImage绘制开销越高,shape 一多就是 O(N·M) 的重绘。再加上源码默认把整张图读进内存,超大图连加载都要好几秒。
解决:给paintEvent加上脏矩形限制,只重绘鼠标影响到的区域:
def paintEvent(self, ev): painter = QPainter(self) painter.setClipRect(ev.rect()) # 只绘制与 ev.rect() 相交的 shape for shape in self.shapes: if shape.boundingRect().intersects(ev.rect()): self.drawShape(painter, shape)如果图实在太大,更实用的办法是标注前先做瓦片切分,把一张大图切成 1024×1024 的小块再逐块标注,这样既解决卡顿,也天然贴合训练时的输入尺寸。源码级优化能缓解,但解决不了“整图加载”的根本问题。
5.3 坑三:打开普通 VOC 正框 XML 后,旋转工具对旧框失效
现象:用 roLabelImg 打开一份只有<bndbox>的老标注,框正常显示,但切到 E 模式拖拽它,框不旋转,只有位置能移动。
原因:labelFile.py读取bndbox时构建的Shape没有初始化angle和_offsets,rotateTo拿到的是默认 0 度,而且因为四点不是从旋转模型算出来的,updatePointsFromCenter会把四个点重排回正框。旧框本质上是“残缺的旋转框”,跟旋转相关的字段是空的。
解决:读取时主动补齐等价旋转框参数。对每个bndbox,计算中心、宽高,角度设 0,再构建旋转框:
def bndbox_to_robndbox(x1, y1, x2, y2): cx = (x1 + x2) / 2.0 cy = (y1 + y2) / 2.0 w = x2 - x1 h = y2 - y1 theta = 0.0 return cx, cy, w, h, theta改完后旧的框在界面上就能正常旋转。这个转换建议在数据导入阶段就做,不要在标注流水线里运行时转换,否则标注员一保存,全部旧框都会被改写成robndbox结构,回不到正框格式,下游依赖正框的模块会静默失效。
5.4 坑四:导出 DOTA 或训练样本时角点顺序错乱
现象:把 XML 转成四点格式喂给模型,训练可视化时框出现自相交、对角线翻转,或者同一个目标在增强后被裁掉一半。
原因:第 2 章那个换算函数输出的四点顺序是“按原始矩形的边展开”,但 DOTA 等格式要求顺时针顺序且起点任意。你直接拿展开顺序写入训练样本,模型读取时按自己的顺序重组多边形,就会得到翻转或自交的框。
解决:转换时做一次凸包排序,并强制起点为最接近左上角的点:
import numpy as np def order_corners(corners): cx = sum(p[0] for p in corners) / 4.0 cy = sum(p[1] for p in corners) / 4.0 angles = [math.atan2(p[1] - cy, p[0] - cx) for p in corners] ordered = [p for _, p in sorted(zip(angles, corners), key=lambda x: x[0])] start = min(range(4), key=lambda i: (ordered[i][0] + ordered[i][1])) return ordered[start:] + ordered[:start]按角度排序得到的是逆时针或顺时针,取决于坐标轴方向,接着用“起点最小化”保证同一目标多次转换结果一致。加了这个排序后,增强脚本和模型端读到的多边形顺序就统一了。这类错乱在测试阶段肉眼很难发现,建议转换后随机抽 50 张图,把转换结果画在图上和 roLabelImg 截图做对比。
5.5 坑五:PyQt5 升版后图标丢失、菜单文字变方框
现象:换了一台新机器,装最新版 PyQt5 后启动正常,但工具栏图标全空,菜单里的中文变成方块,快捷键 W/E/D 没反应。
原因:新装的 PyQt5 版本和仓库里旧的resources.py不匹配。资源文件里引用的图示资源编号在新版 Qt 里变了,图标加载失败;快捷键动作注册在菜单项上,菜单显示异常时动作也不触发。
解决:固定安装 5.15.7,并重新编译 resources:
python -m pip install PyQt5==5.15.7 python -m PyQt5.pyrcc_main -o libs/resources.py resources.qrc如果重编译后图标还空,检查resources.qrc里引用的图标路径是否存在于仓库,部分 fork 会把图标放在resources/子目录,漏拷整个资源目录就会这样。还有一个隐形坑:Windows 上中文路径会让 lxml 写 XML 失败,报错信息是乱码的编码异常。要么统一用英文路径,要么在labelFile.py写文件前把路径做一次 Unicode 编码处理。这个我在项目里见过不下三次,属于最容易被忽略的环境类问题。
6. 把 roLabelImg 改造成标注流水线:三个低成本扩展点
工具本身只是个起点,真正决定标注效率的是你对它的改造。下面三个扩展点都不改核心算法,改完不影响原有标注功能,适合按需接进自己的流水线。
6.1 加自动保存与断点恢复
标注员最怕标了一上午崩溃,全没了。roLabelImg 的保存是手动触发,加一个定时器即可:
from PyQt5.QtCore import QTimer self.autosave = QTimer(self) self.autosave.timeout.connect(self._autosave_slot) self.autosave.start(5 * 60 * 1000) # 每 5 分钟保存一次 def _autosave_slot(self): if self.current_file: self.saveFile()直接保存当前文件有个风险:如果正在编辑一个还没完成的框,保存会把半成品写进 XML。我一般改成保存到同目录的.autosave.xml临时文件,启动时检测到它就用对话框询问是否恢复,比直接覆盖安全得多。
6.2 预设类别与常用热键重映射
把data/predefined_classes.txt改成你的类别清单,一行一个类名,启动时自动加载。如果嫌 W/E/D 不够顺手,在labelImg.py里找到createShortcuts函数,修改QShortcut的按键字符串即可。改成双字母快捷键要小心:中文输入法打开时单字母快捷键会失效,产线上我会强制标注员切换英文输入法,比改快捷键更有效。
6.3 标注完成后跑一次角度自检
标注质量靠抽检不可控,我用一个脚本批量统计 theta 分布,快速发现系统性偏差:
import glob import xml.etree.ElementTree as ET thetas = [] for xml_file in glob.glob('Annotations/*.xml'): root = ET.parse(xml_file).getroot() for obj in root.findall('object'): theta = obj.findtext('robndbox/theta') if theta is not None: thetas.append(float(theta)) import numpy as np print(np.histogram(thetas, bins=36))如果某个标注员的 theta 集中在 0 和 90,说明他们把旋转框当正框用,或者是镜像方向没有统一。这个脚本每次标注批次完成后跑一遍,比逐张人工检查省几十个小时。
我的习惯是把上面三个改动合进一个 fork,固定版本后整个标注团队都用同一套环境。回看做旋转检测这一年多,最大的教训就是:旋转标注的坑九成不在画框,而在 theta 的方向约定和格式转换的一致性上;工具能跑通只是第一步,把约定写进转换脚本、把自检挂进流程,才是真正稳定下来的地方。希望帮到你。
本文还有配套的精品资源,点击获取