
简介基于PyQt5实现的可视化自动标注工具支持在YOLOv5、YOLOv8、Segment Anything之间切换也预留自定义模型接入入口适合计算机、电子信息工程、数学等专业学生用于课程设计、期末大作业或毕业设计参考尤其适合需要快速完成数据标注任务、又希望保留二次开发空间的项目场景。资源包共155个文件压缩后仅7.59MB核心代码为52个Python脚本并配套18个yaml/yml模型与算法配置、ui/qrc界面定义、qm/ts多语言资源以及png运行截图和md说明文档源码组织清晰便于定位标注主流程、模型加载和界面事件等关键模块。目前已有1575人浏览学习具有一定的参考热度。随包说明文档梳理了工具启动、模型选择、标注操作与结果导出步骤配合截图可快速上手读者可在现有工程上替换数据集、注册自定义推理后端或调整交互逻辑从而沉淀出一套贴合自身数据集的标注方案。整体来看这是一份兼顾完整性与可扩展性的高质量参考资料。1. 为什么我建议把自动标注工具构建在 PyQt5 上做目标检测数据集的时候最痛苦的往往不是训练模型而是给几百张图框目标。手动一张张画框再手动改类别半天就过去了。我见过不少团队先写一个 Web 标注页面结果浏览器打开大图内存直接吃满缩放手感也很差。后来把工具改成 PyQt5 桌面应用用 QGraphicsView 做画布标注手感接近原生图片查看器再配合 YOLOv5、YOLOv8、Segment Anything 这些模型做预标注人工只需要修正少数边界效率提升非常明显。这套基于 PyQt5 的可视化自动标注源码里最值得学习的不是单个模型调用而是“界面线程”和“模型推理线程”如何解耦。下面把拆源码时比较关键的几层逻辑整理出来适合想拿来做课程设计、期末大作业或者正在搭建内部标注工具的从业者参考。2. 拆解 PyQt5 标注器的界面骨架画布、工具栏与信号槽2.1 主窗口的模块划分标注工具本质上是一个图像编辑器只是叠加了标签页。工程里如果把所有交互都写进 MainWindow后面加新模型或新标注模式一定会乱。我看这套源码的时候发现它的主窗口结构基本分成三块左侧是图像列表和类别列表中间是 QGraphicsView 绘制的画布右侧或顶部是工具栏。类别列表绑定每个类别的颜色画布负责显示图片、矩形框和多边形掩膜。下面是主窗口初始化的核心骨架和源码保持一致的主线思路class MainWindow(QMainWindow): def __init__(self): super().__init__() self.canvas QGraphicsView() self.scene QGraphicsScene(self) self.canvas.setScene(self.scene) self.image_list QListWidget() self.class_list QListWidget() self.model_combo QComboBox() self.model_combo.addItems([YOLOv5, YOLOv8, SAM, Custom]) self.model_status QLabel(模型未加载) toolbar self.addToolBar(main) toolbar.addAction(自动标注, self.run_auto_label) toolbar.addAction(保存标签, self.save_labels) self.setCentralWidget(self.canvas) self.add_dock_widgets() self.auto_save_timer QTimer() self.auto_save_timer.timeout.connect(self.auto_save)这段代码的关键在于界面的更新入口。canvas和scene分离是 QGraphicsView 体系的标准用法scene 管理所有图元view 负责视图缩放。model_combo放在工具栏里切换模型类型只改变后续推理时的后端选择并不会在切换瞬间加载模型。真正的模型加载放在首次调用自动标注时避免程序启动就吃掉大量显存。2.2 信号槽驱动标注流程PyQt5 的信号槽机制是这套工具能稳定运行的基础。常见的错误写法是直接在槽函数里做耗时推理比如在“自动标注”按钮的 clicked 信号里同步调用模型结果界面立刻假死。源码里将耗时操作全部放进独立线程线程结束后通过自定义信号把结果传回主线程。下面这张表格概括了工具里几个核心信号的使用方式信号触发时机连接槽函数作用clicked点击自动标注按钮start_auto_label_thread启动推理线程thread_finished推理线程完成update_canvas_boxes在主线程绘制框item_selection_changed切换图像列表load_current_image清空画布并加载新图currentTextChanged改变模型下拉框change_model_backend切换标注后端类型信号槽的坑在于线程与主线程的对象生命周期。QThread的run方法里如果创建了 QWidget 对象或者直接操作scene中的图元PyQt5 会报 “Thread object must be created in the main thread” 一类的错误。所以在实际实现中线程只返回检测结果字典主线程收到信号后才更新画布。2.3 连接画布缩放与坐标换算标注工具离不开缩放QGraphicsView 自带的scale方法直接可用。但画布缩放后鼠标点的屏幕坐标必须换算成图像坐标否则框会错位。源码里通常在鼠标移动事件中做一次换算def mouse_move_event(self, event): scene_pos self.canvas.mapToScene(event.pos()) self.statusBar().showMessage(f图像坐标: {int(scene_pos.x())}, {int(scene_pos.y())})mapToScene将视图坐标转换为场景坐标注释里的“图像坐标”是相对于原图的坐标。这个换算在后续模型预标注结果回显时同样重要YOLO 返回的坐标是归一化值回显时要乘以画布当前加载图片的尺寸而缩放只是视图层面的视觉变化不影响这张基准尺寸。理解这层关系再看源码里update_canvas_boxes函数就不会被一堆坐标变换搞晕。3. 模型接入层把 YOLOv5、YOLOv8、SAM 和自定义模型统一成推理接口3.1 统一接口的必要性如果每个模型都单独写一个标注调用函数界面层就要维护四套逻辑YOLOv5 返回 xywh 归一化框YOLOv8 返回 xyxy 或 xywhSAM 返回的是掩膜多边形自定义模型又可能是任意输出。最合理的做法是在模型层定义一个统一的predict接口输出统一成“框 类别 置信度 可选掩膜”的列表。这套源码里最值得抄的就是这个抽象后面想再加新模型只写一个子类不碰界面代码。class BaseModelBackend(ABC): abstractmethod def load_model(self, model_path: str) - None: 加载模型失败时抛出异常 abstractmethod def predict(self, image: np.ndarray): 返回 List[Detection], Detection 包含 bbox, label, score, mask class YOLOv8Backend(BaseModelBackend): def predict(self, image): results self.model.predict(image, verboseFalse) detections [] for r in results: for box in r.boxes: detections.append(Detection( bboxbox.xyxy[0].tolist(), labelself.model.names[int(box.cls)], scorefloat(box.conf), maskNone )) return detections这里的Detection是我在源码基础上建议的数据类它的bbox统一使用xyxy绝对像素坐标。YOLOv8Backend内部用 Ultralytics 包的接口只负责把结果转成统一结构。load_model里建议增加一次环境检查比如torch.cuda.is_available()的状态方便在主界面显示“CPU 模式”或“GPU 模式”。3.2 模型选择器如何工作界面层的model_combo切换只改变一个后端工厂。调用自动标注时根据当前下拉框文本实例化对应的后端。工厂函数可以用字典维护避免写一堆if/elseBACKEND_REGISTRY { YOLOv5: YOLOv5Backend, YOLOv8: YOLOv8Backend, SAM: SAMBackend, Custom: CustomModelBackend, } def create_backend(name: str) - BaseModelBackend: backend_cls BACKEND_REGISTRY.get(name) if backend_cls is None: raise ValueError(f未知模型类型: {name}) return backend_cls()BACKEND_REGISTRY本质上是一个策略模式。新增自定义模型时只需要在字典里注册一个继承BaseModelBackend的类。这样界面不需要知道任何模型细节。这里想提醒一点注册的键名要和下拉框选项完全一致否则切换后会出现模型不匹配。源码里settings.json大概率就是用来持久化当前选中的模型名称启动时根据配置恢复下拉框状态。3.3 多模型并行与线程池设计自动标注在批量处理图像时如果一张一张推理GPU 的利用率其实不高。理想方案是启动一个线程池每个线程独立持有模型实例。PyQt5 中可以用concurrent.futures.ThreadPoolExecutor也可以通过QThreadPoolQRunnable。我更推荐后者因为它不需要手动管理线程生命周期并且可以和 Qt 的信号槽系统无缝对接。class LabelWorker(QRunnable): def __init__(self, backend, image_path, callback_signal): super().__init__() self.backend backend self.image_path image_path self.callback_signal callback_signal pyqtSlot() def run(self): image cv2.imread(str(self.image_path)) image cv2.cvtColor(image, cv2.COLOR_BGR2RGB) detections self.backend.predict(image) self.callback_signal.emit(str(self.image_path), detections)调用时用QThreadPool.globalInstance().start(worker)。注意QRunnable内部不能直接更新界面必须通过callback_signal发回主线程。这个模式避免了grabWindow或setPixmap在子线程调用造成的崩溃。如果是在 CPU 环境下跑 SAM建议把最大线程数限制在 1 或 2因为 SAM 的 ViT 编码器本身显存和内存占用都高多线程并行反而触发 OOM。3.4 各模型返回结果的特点对照YOLOv5 和 YOLOv8 都源自 YOLO 系列但输出处理有差异。YOLOv5 的 PyTorch 模型输出是[batch, 25200, 85]的张量需要自己用 NMS 过滤YOLOv8 在推理包里已经做了后处理返回Results对象。SAM 是 Segment Anything Model官方接口接收input_box生成掩膜但掩膜是二值矩阵还需要转成多边形才能保存为标签。对照关系如下模型后端输出内容坐标格式标注用途YOLOv5检测框xywh 归一化矩形标注YOLOv8检测框/分割掩膜xyxy 绝对像素矩形与实例分割SAM二值掩膜图像尺寸矩阵精细轮廓标注自定义模型取决于模型设计需要自行转换特定业务目标这套源码最方便的一点是每个后端都封装好了切换模型不会让已有标注丢失。没有封装好之前我从 YOLOv5 切到 SAM 时旧标注框还在画布上新掩膜又加载进来最后保存时格式冲突花了不少时间排错。所以“切换模型时清空临时预标注、保留已确认标注”这一条应该在界面逻辑里单独处理。4. 自动标注的数据闭环坐标转换、类别映射与格式输出4.1 从模型输出到画布坐标的换算模型推理结果要显示到画布上第一件事是坐标格式统一。YOLOv5 导出的是归一化后的(cx, cy, w, h)而画布内部更适合用(x1, y1, x2, y2)绝对坐标。换算过程不能省略否则标注框会整体偏移。def yolo_to_xyxy(box, img_w, img_h): cx, cy, w, h box x1 (cx - w / 2) * img_w y1 (cy - h / 2) * img_h x2 (cx w / 2) * img_w y2 (cy h / 2) * img_h return [x1, y1, x2, y2]参数中img_w和img_h是原图尺寸不能用画布的显示尺寸。很多初学 PyQt5 的人在缩放画布后直接拿画布宽度来计算结果发现超过 100% 缩放的图框全乱。正确做法是使用cv2.imread得到的原始宽高或者在加载图像时缓存一份尺寸。坐标转换后还需要做一次边界裁剪防止模型输出超出图像范围标注工具里常用numpy.clip处理。4.2 类别映射表的设计做自动标注时模型训练时的类别顺序和当前项目的类别顺序往往不一致。例如 YOLOv8 官方模型默认 80 个 COCO 类别而你的数据集只需要“车”“人”“红绿灯”三类。这时必须设计类别映射表把模型输出类别映射到项目类别 ID。源码里settings.json的典型配置会包含这样的映射文本{ category_mapping: { person: 0, car: 1, traffic_light: 2 } }映射表有三个细节需要注意。第一未在映射表中的类别默认丢弃不生成标注。第二映射表的键必须是模型输出的类别字符串不能是模型类别 ID因为 YOLOv5 和 YOLOv8 的类别 ID 顺序不同。第三如果使用 SAM它不输出类别通常需要人工手动选择当前目标类别因此工具界面需要在掩膜生成后弹出一个类别选择框。4.3 输出 YOLO 格式标签文件标注完成后保存标签是最后一步。YOLO 格式的 txt 文件每行是class_id cx cy w h其中坐标全部归一化到 01。保存函数最容易出错的不是写入而是类别 ID 的获取。如果在界面层保留的是类别名称保存时必须通过映射表转成 ID。def save_yolo_label(img_path, boxes, category_ids, output_dirlabels): img cv2.imread(str(img_path)) ih, iw img.shape[:2] label_path Path(output_dir) / (Path(img_path).stem .txt) lines [] for box, cls_id in zip(boxes, category_ids): x1, y1, x2, y2 box cx ((x1 x2) / 2) / iw cy ((y1 y2) / 2) / ih w (x2 - x1) / iw h (y2 - y1) / ih lines.append(f{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) label_path.parent.mkdir(parentsTrue, exist_okTrue) label_path.write_text(\n.join(lines), encodingutf-8)这里的category_ids已经是映射后的项目类别 ID。如果处理的是 KITTI 数据转 YOLO还多一个环节KITTI 的标签包含截断、遮挡、观察角度等字段要先把目标检测部分的(x1, y1, x2, y2)提取出来再按上述方式归一化。网上看到不少把 KITTI 转 YOLO 的脚本核心也就是这段话里的坐标转换逻辑。值得注意的一个边界情况是当 w 或 h 计算出来是 0说明模型输出的框有问题应当跳过该行而不是写入 NaN 到标签文件。4.4 自动保存与人工修正的配合自动标注不是一键生成最终标签更聪明的做法是将模型输出作为预标注标注人员只需要确认或微调。这套工具里应该有一个“已确认 / 未确认”状态位。保存时只导出已确认的框未确认的框标记为ignored true。我建议把状态写到 JSON 中间文件中这样即使程序崩溃重新打开还能从断点继续。if box.confirmed: export_boxes.append(box) else: pending_boxes.append(box)很多从业者会忽略这个细节导致自动标注后需要全部重新检查。状态位机制的引入能把“自动标注”从一次性批处理变成可迭代的标注流程。这也是基于 PyQt5 的工具相对在线标注服务更灵活的地方。5. 进阶使用技巧与三个最容易踩的坑5.1 学会用断点续标源码包里的settings.json不只是用来存模型路径的它非常适合记录当前标注进度。我通常的做法是每完成一张图的确认就更新一次settings.json中的last_image_index。下次打开工具时直接跳转到上次标注的图像节省重新查找的时间。对于几百张图的批量标注这个功能比想象中重要。5.2 自定义模型的接入类型自定义模型文件如果输出维度不是标准检测格式需要写一个转换层。最容易踩的坑是把模型的原始张量直接当Detection返回到界面。比如一个分类模型输出的是全图类别它不产生框这时应该让predict返回空检测列表并自动跳过该图。判断模型是不是可用先做一次纯 Python 推理输出结果打印到控制台确认张量形状后再写转换代码。5.3 显存监控与线程数限制在 PyQt5 标注工具中建议加上显存监控。用 PaddleOCR 或 SAM 这类大模型做预标注时显存溢出会直接杀死进程没有保存的标注全部丢失。可以定期读取nvidia-smi或pynvml抓取显存使用率当使用率超过 90% 时暂停后台标注线程提示用户降低批大小。如果机器只有 CPU建议 SAM 的predict内部把torch.no_grad()包住同时限制推理线程为 1否则内存会快速打满。最后一个小技巧是切换到无 GPU 环境时把torch.cuda.set_device(-1)放在最前面会比逐行改.cuda()快得多也能让这套工具在课程设计演示时少出现环境问题。本文还有配套的精品资源点击获取