首页 / 资讯中心 / 文章详情

labelImg源码拆解:PyQt5图像标注工具实战与二次开发

labelImg源码拆解:PyQt5图像标注工具实战与二次开发 ★ FEATURED ARTICLE
简介LabelImg是一款开源的可视化图像标注工具广泛应用于目标检测领域的前期数据准备工作主要服务需要制作Faster R-CNN、YOLO、SSD等网络训练数据集的算法工程师、学生与研究者。它支持人工框选图片中的目标可将标注结果保存为遵循公开数据集规范的XML标注文件是构建目标检测数据集时常用的基础辅助软件。该压缩包内含完整源码共127个文件、约6.39MB其中以44个Python源码文件为主体同时配有界面图标、Shell脚本、文本说明和多种工程配置文件方便在Python与lxml环境下直接运行也便于在此基础上调整界面样式或扩展标注功能。目前已有357人学习下载源码可离线获取尤其适合因网络原因无法访问GitHub的开发者解压后即可查看全部代码结构快速开展标注工具的本地部署与二次开发也可作为学习图像标注工具内部实现的参考案例。1. 图像标注工具中的常青树labelImg 为什么到今天还有人翻出来用做过目标检测的人基本都绕不开这一步把几千张图片里的物体用矩形框框出来再存成算法能读的格式。市面上标注工具年年换新从网页版到桌面版一堆选择但很多团队最后还是会翻出 labelImg 这个老项目来用。原因很直接它足够轻、足够快双击就能跑标注结果直接落到 PascalVOC 的 XML 和 YOLO 的 txt 两个主流格式省掉中间转换的功夫。这份源码包不是给人泛读的是一份实打实能跑、能改的标注工具完整工程。它基于 PyQt5 构建核心逻辑集中在画布交互和文件解析两层数据结构清晰到可以当课本来学。无论你是要快速搭一套数据集生产流水线还是想理解 desktop GUI 工具的事件分发和坐标换算这份代码都值得拆开看一遍。接下来从架构一路讲到改造把它讲透。2. 技术栈与目录结构PyQt5 画布交互和文件解析是怎么分层的2.1 技术选型为什么是 PyQt5 而不是 Electron 或 Web 方案标注工具的核心是响应速度和绘制精度鼠标按下要立刻出框拖动过程不能有可感知的延迟。PyQt5 的 QWidget 直接走 native 事件循环paintEvent 里重绘矩形框的开销极小在小数据集场景下的体验比网页版顺滑得多。相比 Electron 那套 Chromium 壳PyQt5 的安装包体积和内存占用也小一个量级一台普通办公电脑就能带得动。项目的 Python 版本要求不高PyQt5 的兼容性从 5.9 到 5.15 都能跑绑定的 Qt 库版本不同只影响高分屏适配的细节不影响核心标注逻辑。它之所以没有做成浏览器版一个关键原因是大量数据集标注发生在离线环境内网机器往往不允许开额外的服务端口桌面应用反而是最省事的选择。2.2 目录结构拆解三个核心文件撑起整个应用labelImg-master/ ├── labelImg.py # 主窗口菜单、快捷键、文件列表、状态管理 ├── canvas.py # 画布矩形绘制、缩放平移、坐标换算 ├── labelFile.py # 标注文件读写XML / YOLO 格式解析 ├── libs/ │ ├── qt5_compat.py # Qt4/Qt5 兼容层 │ ├── shape.py # Shape 数据结构矩形、标签、颜色 │ ├── color.py # 标签颜色管理 │ ├── stringBundle.py # 国际化字符串 │ └── zoomWidget.py # 缩放控件 ├── data/ │ ├── predefined_classes.txt # 预定义类别列表 │ └── translations/ # 多语言文件 ├── resources.py # 图标资源编译产物 ├── setup.py └── requirements.txt整个工程没有用复杂的模块分层labelImg.py 作为控制器协调 canvas.py 和 labelFile.py 两端的交互。这个设计的妙处在于标注的实时操作和文件持久化彻底分离——画布不知道文件怎么存文件解析不关心画布怎么画。二次开发时改任何一端都动不到另一端出错的概率低很多。2.3 数据结构的核心Shape 类如何保存一个标注框class Shape: def __init__(self, label, line_colorNone, difficultFalse): self.label label self.points [] # 边界点列表矩形就是两个对角点 self.fill False self.selected False # 是否被选中可拖动调整 self.difficult False # 是否标注为难例 self.line_color line_color or DEFAULT_LINE_COLOR self.shape_type rectangle # 扩展支持多边形时用 def add_point(self, point): self.points.append(point) def bounds(self): x_min min(p.x() for p in self.points) y_min min(p.y() for p in self.points) x_max max(p.x() for p in self.points) y_max max(p.y() for p in self.points) return x_min, y_min, x_max, y_max def paint(self, painter): # 绘制逻辑选中态画控制点普通态只画边框 rect self.bounds() painter.drawRect(rect) if self.selected: self.draw_vertex(painter)Shape 类不依赖任何 Qt 组件只存坐标点和标签属性绘制逻辑通过传入 painter 对象完成。这样设计的好处是 Shape 可以被单测直接复用也可以迁移到别的 GUI 框架——实际项目中我就把它抽出来用作离线校验标注结果的脚本工具。3. 核心模块实战拆解从按下鼠标到 XML 落盘的完整链路3.1 事件分发机制mousePressEvent 如何开启一次标注画布的核心是 canvas.py 里的 Canvas 类它继承 QWidget 并重写了三个鼠标事件方法构成标注框从创建到调整的完整状态机。第一次按下鼠标是创建起点松开是创建终点之后在已选中的框上按下则是进入拖动调整模式。def mousePressEvent(self, event): if event.button() Qt.LeftButton and self.editing(): pos self.transform_pos(event.pos()) # 像素坐标转画布坐标 if self.current is not None: # 已有正在绘制的框则闭合它并装入标注列表 self.close_shape() elif self.drawing(): # 开始画新框记录起点 self.current Shape(labelself.current_label, line_colorself.current_color) self.current.add_point(pos) self.current.add_point(pos) self.update() else: # 尝试命中已有 Shape命中则进入编辑态 self.select_shape_at(pos) self.update() elif event.button() Qt.RightButton and self.editing(): self.delete_selected_shape()transform_pos 把鼠标的物理像素坐标换算成画布内部坐标这个换算是 Qt 绘图的基础操作。如果当前在缩放状态zoom 值不为 1鼠标事件拿到的坐标要先减画布偏移量再除以缩放系数。这个步骤漏掉的话图片放大了标注框就会偏移这点在后面的避坑章节会展开讲。3.2 矩形框实时预览mouseMoveEvent 的动态绘制def mouseMoveEvent(self, event): if self.drawing(): # 更新矩形对角点实现拖拽过程中的实时预览 pos self.transform_pos(event.pos()) self.current.points[1] pos self.update() elif self.selected_shape is not None: # 编辑已有框根据拖拽位置调整某个角点 self.adjust_vertex(event.pos()) self.update()高频调用 update() 触发 repaint 是 Qt 绘图的常规姿势重绘范围会被 Qt 自动裁剪到脏区域不会整块刷新。这里真正影响性能的是每个 Shape 的 paint 方法里有没有做坐标裁剪——标注几百个框的密集场景下不裁剪会明显感觉拖动不跟手。labelImg 的做法是直接在 bounds() 基础上加一个判定绘制前先判断是否与当前可视区域相交。3.3 保存流程从 Shape 列表到 PascalVOC XMLdef save_pascal_voc_format(self): writer ET.ElementTree(ET.Element(annotation)) root writer.getroot() root.append(self._xml_element(folder, self.folder_name)) root.append(self._xml_element(filename, self.filename)) size ET.SubElement(root, size) size.append(self._xml_element(width, str(self.image_width))) size.append(self._xml_element(height, str(self.image_height))) size.append(self._xml_element(depth, str(self.image_depth))) for shape in self.shapes: obj ET.SubElement(root, object) obj.append(self._xml_element(name, shape.label)) obj.append(self._xml_element(difficult, str(int(shape.difficult)))) bndbox ET.SubElement(obj, bndbox) x1, y1, x2, y2 shape.bounds() # 注意VOC 要求整数坐标 bndbox.append(self._xml_element(xmin, str(int(x1)))) bndbox.append(self._xml_element(ymin, str(int(y1)))) bndbox.append(self._xml_element(xmax, str(int(x2)))) bndbox.append(self._xml_element(ymax, str(int(y2))))保存逻辑看着不复杂但有几个细节决定生成的文件能不能被检测框架直接消费。一是每个 object 节点必须有 difficult 字段很多训练脚本会据此过滤难例二是坐标强制转成 int浮点坐标在多数 VOC 解析器里会报类型错误三是 filename 应该存文件名而不带路径否则跨目录迁移数据集时路径会断。3.4 YOLO 格式导出坐标归一化怎么算才不出错def save_yolo_format(self): width, height self.image_width, self.image_height lines [] for shape in self.shapes: x1, y1, x2, y2 shape.bounds() # YOLO 格式中心点 宽高全部归一化到 0~1 center_x (x1 x2) / 2.0 / width center_y (y1 y2) / 2.0 / height box_width (x2 - x1) / width box_height (y2 - y1) / height class_id self.classes.index(shape.label) lines.append(f{class_id} {center_x:.6f} {center_y:.6f} f{box_width:.6f} {box_height:.6f}) with open(self.output_file, w) as f: f.write(\n.join(lines))YOLO 格式的坑在两点一是 class_id 由类别列表顺序决定这个列表在第一张图保存时会自动生成 classes.txt中途增删类别会导致已有标注的 id 全部错位二是归一化分母用的是原图宽高不是画布显示区域的宽高。如果用户做了图片缩放后再导出必须用原始像素尺寸做分母否则训练时边界框全偏。4. 运行环境与配置源码跑起来的完整步骤4.1 环境准备Python 版本与依赖安装# 建议用 Python 3.6~3.9太高版本在部分系统上可能遇到 pyqt5 编译问题 python -m venv labelenv source labelenv/bin/activate # Windows 下用 labelenv\Scripts\activate pip install PyQt55.15.2 lxmlrequirements.txt 里的核心依赖就两个PyQt5 和 lxml。lxml 用于 XML 解析比标准库的 xml.etree 在超大标注文件下的性能好不少。安装 PyQt5 时有条件的话直接装 5.15.2这个版本对高分屏的支持比 5.12 之前的版本稳定得多也避免了 Qt4 时代的代码要打的兼容补丁。4.2 源码启动不用打包直接跑python labelImg.py源码根目录直接执行即可。如果图标或资源加载失败通常是因为 resources.py 没有生成或版本不匹配重新编译一份即可# 用 pyrcc5 把 resources.qrc 编译为 python 模块 pyrcc5 resources.qrc -o resources.py首次打开软件后先点击左侧的“打开目录”选择图片文件夹右侧会出现文件列表。再点“打开”加载第一张图即可开始标注。注意打开图片和打开目录是两个不同入口这个设计是为了区分单图标注和批量标注两种工作流。4.3 配置文件预定义类别列表的用法# data/predefined_classes.txt每行一个类别名 person car bicycle motorcycle这个文件的内容会以菜单形式出现在主界面的类别下拉框里。没有预先定义类别的话也可以直接在标注时手动输入新类别名保存后该名称会追加到文件末尾。实际打标时建议先把全部类别定稿再开工中途加类会让 YOLO 训练的类别索引乱掉这种低级错误通常要返工整个数据集。4.4 标注流程走一遍快捷键操作顺序W # 进入绘制模式鼠标在图片上拖出矩形框 D / A # 下一张 / 上一张图片 CtrlS # 保存当前标注 CtrlD # 复制上一张的标注到当前图 Del # 删除选中的框常用的只有六个快捷键W 画框、A/D 切图、CtrlS 保存。CtrlD 复制标注在处理视频连续帧时特别有用——相邻帧的物体位置变化不大复制过来微调比重新框快十倍。判定标注是否完成的标准是左侧文件列表里的图标从圆圈变成对勾或者直接看 XML 文件是否已生成。5. 避坑与常见问题排查labelImg 实战中的血泪经验5.1 中文路径导致图片无法加载现象打开一个路径带中文的文件夹图片列表能显示但点开图片后画布黑屏控制台报图片解码失败。原因Qt 的 QImage 加载非 ASCII 路径在 Windows 上会走本地编码转换部分版本对中文字符处理有 bug导致路径被错误解码。解决把数据集放在纯英文路径下包括盘符名、一级目录名都不要有中文。这个问题无解只能提前规避。5.2 自动保存功能失效现象勾选了设置里的自动保存点下一张时弹出“是否保存”对话框自动保存没生效。原因自动保存拿走的是 labelImg.py 里 autosave 标志位但这个标志位在切换图片时被重置了官方的实现本来就有这个问题。解决还是手动养成 CtrlS 的习惯或者改一段逻辑在 load_file 函数里检测 autosave 标志为真时直接掉用保存函数不再弹窗。改完之后自动保存就真正好用了。5.3 高分屏下文字和图标模糊现象在 2K、4K 分辨率的屏幕上软件界面文字发虚图标边缘有锯齿。原因Qt 默认没有开启高 DPI 缩放需要设置 Qt.AA_EnableHighDpiScaling 属性。解决在 labelImg.py 主文件开头加两行QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)设置完整环境变量也可以达到同样效果。在 Windows 上还可以右键 exe 兼容性设置覆盖高 DPI 行为但加代码更普适。5.4 YOLO 格式导出的 category_id 错乱现象用 labelImg 导出的 txt 标注文件训练 YOLOv5loss 一直不收敛检查发现同一类物体在不同图片里的 id 不一致。原因labelImg 的 YOLO 导出逻辑依赖一个运行时生成的 classes.txt它在第一次保存时记录当前画布上出现的所有类别。如果中途用另一张图片新建了类别或者删除了类别classes.txt 的顺序会变已标注的 id 全部错位。解决开工前先定稿类别清单写入 predefined_classes.txt整个数据集打标过程中不要增删类别。如果确实要加类导出完成后再统一做一次 id 映射不要让标注工具参与类别管理。5.5 大批量文件标注时保存超时卡死现象标注了一百多张图后CtrlS 需要一两秒才能完成期间界面无响应。原因XML 文件解析和序列化全在 UI 线程完成文件列表越长保存时的 ET.ElementTree 写盘越慢。解决把保存操作放进子线程或者在每张图保存完成后立刻把 Shape 列表从内存卸载。实际项目中我用第二种效果立竿见影——labelImg 处理完一张图后释放所有 Shape 引用内存占用就稳定在几百 MB 不涨了。6. 二次开发实战给 labelImg 加一个标注统计面板想要快速统计当前数据集的标注分布不用额外写脚本去解析 XML可以在 labelImg 里加一个统计面板实时显示各类别框数量。改造点有两个一是在主窗口增加一个 QTextEdit 的 dock 面板二是在每次关闭 Shape 时刷新统计。# 在原 labelImg.py 的 save_file 函数里加一行刷新调用 def update_statistics(self): counter {} for shape in self.canvas.shapes: counter[shape.label] counter.get(shape.label, 0) 1 stats_text \n.join(f{k}: {v} for k, v in counter.items()) self.stats_dock.setPlainText(stats_text)代码逻辑不复杂但加这个面板对打标质量的把控极有价值——标到一半时瞄一眼就知道 person 类的框数量是否异常偏少往往能提前发现漏标。再进一步还可以给 Shape 加一个 truncated 字段让标注员对边界被截断的物体打标训练模型时这个字段配合 difficult 一起判断可以让收敛效果更好。另一个比较常用的改造是把保存逻辑抽取成插件式接口labelImg 的 save_format 支持 PascalVOC 和 YOLO 两种如果你要存成 JSON 或者其他私有格式只需要修改 labelFile.py 中的 save_pascal_voc_format 和 save_yolo_format 两个方法的实现。这种方案比在主流程里加分支判断清晰得多——新格式加一个方法老格式不做任何改动互不干扰。从那以后我每次拿到新的标注工具源码都会先看它的数据持久化层有没有独立成类。独立成类的工具才好二次开发否则所有逻辑耦合在主窗口里每加一个格式就要动一遍事件循环的代码改到最后自己都分不清哪里是原来的逻辑。希望这套拆解能帮你在自己的项目里少走点弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站