简介: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_color=None, difficult=False): 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(label=self.current_label, line_color=self.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 XML
def 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 PyQt5==5.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 # 下一张 / 上一张图片 Ctrl+S # 保存当前标注 Ctrl+D # 复制上一张的标注到当前图 Del # 删除选中的框常用的只有六个快捷键,W 画框、A/D 切图、Ctrl+S 保存。Ctrl+D 复制标注在处理视频连续帧时特别有用——相邻帧的物体位置变化不大,复制过来微调比重新框快十倍。判定标注是否完成的标准是:左侧文件列表里的图标从圆圈变成对勾,或者直接看 XML 文件是否已生成。
5. 避坑与常见问题排查:labelImg 实战中的血泪经验
5.1 中文路径导致图片无法加载
现象:打开一个路径带中文的文件夹,图片列表能显示,但点开图片后画布黑屏,控制台报图片解码失败。
原因:Qt 的 QImage 加载非 ASCII 路径在 Windows 上会走本地编码转换,部分版本对中文字符处理有 bug,导致路径被错误解码。
解决:把数据集放在纯英文路径下,包括盘符名、一级目录名都不要有中文。这个问题无解,只能提前规避。
5.2 自动保存功能失效
现象:勾选了设置里的自动保存,点下一张时弹出“是否保存”对话框,自动保存没生效。
原因:自动保存拿走的是 labelImg.py 里 autosave 标志位,但这个标志位在切换图片时被重置了,官方的实现本来就有这个问题。
解决:还是手动养成 Ctrl+S 的习惯,或者改一段逻辑,在 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 标注文件训练 YOLOv5,loss 一直不收敛,检查发现同一类物体在不同图片里的 id 不一致。
原因:labelImg 的 YOLO 导出逻辑依赖一个运行时生成的 classes.txt,它在第一次保存时记录当前画布上出现的所有类别。如果中途用另一张图片新建了类别或者删除了类别,classes.txt 的顺序会变,已标注的 id 全部错位。
解决:开工前先定稿类别清单写入 predefined_classes.txt,整个数据集打标过程中不要增删类别。如果确实要加类,导出完成后再统一做一次 id 映射,不要让标注工具参与类别管理。
5.5 大批量文件标注时保存超时卡死
现象:标注了一百多张图后,Ctrl+S 需要一两秒才能完成,期间界面无响应。
原因: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 两个方法的实现。这种方案比在主流程里加分支判断清晰得多——新格式加一个方法,老格式不做任何改动,互不干扰。
从那以后我每次拿到新的标注工具源码,都会先看它的数据持久化层有没有独立成类。独立成类的工具才好二次开发,否则所有逻辑耦合在主窗口里,每加一个格式就要动一遍事件循环的代码,改到最后自己都分不清哪里是原来的逻辑。希望这套拆解能帮你在自己的项目里少走点弯路。
本文还有配套的精品资源,点击获取