ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyQt5 + PaddleOCR 桌面OCR标注工具实战解析

PyQt5 + PaddleOCR 桌面OCR标注工具实战解析 简介一份基于PyQt5与PaddleOCR实现文字识别的Python项目源码定位为毕业设计、课程大作业或项目初期立项演示的优质范例主要面向计算机、人工智能、物联网等专业的在校学生和开发者帮助解决图形界面下快速完成图片文字提取与编辑的实际需求。项目将GUI交互与OCR识别能力深度融合涵盖图像导入、画布标注、亮度对比度调节、文字识别、结果编辑与列表管理等完整流程并包含工具栏、颜色对话框、文件预览等实用组件代码分层清晰便于阅读和二次开发。压缩包共80个文件大小约4.37MB核心为24个Python源码另有界面UI/XML文件、PNG/JPG图标素材、配置文件与说明文档还附带演示动图和示例图片可直观了解运行效果。目前已有42人学习下载对希望快速上手PyQt5桌面应用开发或PaddleOCR集成实践的读者是一份完整且具参考价值的示例。1. 为什么毕业设计选 PyQt5 PaddleOCR 而不是 Tkinter TesseractOCR 类的毕业设计和课程大作业最常见的完成形态是 Flask 接口配一个上传框识别完把 JSON 渲染到页面上就算交差。这个项目不一样的地方在于它的核心是一个带标注工作流的桌面应用PyQt5 负责界面和鼠标交互PaddleOCR 负责文本检测与识别两者之间用一层guiocr包组织起来。打开程序后可以加载图片、一键 OCR、把识别框直接画在图上再逐条修改文字内容和标签整个过程不需要浏览器、不需要起服务断网也能跑。比起 Web demo这种形态更贴近真实标注工具演示的时候老师可以亲手点鼠标改错字体验比看接口返回强得多。适合三类人拿它当毕设或课程设计底座、不想从零写前端交互的在校生想快速给团队搭一个桌面文字识别工具、又不想被 Web 框架绕一圈的工程师以及准备做数据集标注、但不想啃 labelme 源码的人。另外如果只是想要 PyQt5 做界面结构上也可以参考这个工程把 PaddleOCR 换成其他推理引擎后面会讲怎么替换。2. PaddleOCR 推理封装引擎初始化、参数选择与 ocr_utils 返回结构2.1 为什么识别逻辑要拆成独立模块项目里所有模型相关代码都收在guiocr/utils/ocr_utils.py。这个拆分不只是为了目录好看GUI 里的主窗口、画布、列表项都依赖识别结果但没有任何一个控件应该直接知道 PaddleOCR 的调用方式。把引擎初始化、推理调用、返回值标准化放在一个模块里界面层拿到的永远是list[dict]这样的统一结构将来换引擎、换模型或者从单张识别改成批量识别只动这一个文件就行。实际开发里我见过很多把PaddleOCR(...)直接写在按钮点击事件里的写法当时方便后面换一个lang参数要全局搜索替换体验很差。所以看到项目里保留ocr_utils.py这一层说明作者是认真考虑过结构而不是把代码堆在一个文件里。2.2 初始化 PaddleOCR 时的推理参数# guiocr/utils/ocr_utils.py 中常见的引擎初始化写法 from paddleocr import PaddleOCR _engine None def get_engine(): global _engine if _engine is None: _engine PaddleOCR( use_angle_clsTrue, # 开启方向分类倾斜图片识别率更高 langch, # 识别语言ch 为中文简体 show_logFalse, # 关闭推理日志避免刷屏 ) return _engine这段代码的逻辑是先判断_engine是否已经创建避免每次识别都重新加载模型。use_angle_cls控制方向分类器开启后会多跑一个分类分支专门处理图片旋转 0 度和 180 度的情况手机拍的票据、扫描件经常有这个问题。lang决定加载哪个语言的识别模型和字典常见取值是ch、en、japan、korean。show_log建议设成False否则 PaddleOCR 会把每一张图的推理耗时打到标准输出在 PyQt5 里这些日志会混进你自己的调试信息干扰判断。需要注意 PaddleOCR 的推理模型分为三段检测模型det负责找出文字框方向分类模型cls负责纠正旋转识别模型rec负责把框内图像转成字符串。平时说「PaddleOCR 模型」默认把这三个都加载了。use_angle_clsFalse时跳过 cls 这一个分支速度更快但图片有旋转时准确率会明显下降。2.3 把返回结果整理成 GUI 能消费的结构def recognize(img_path): engine get_engine() result engine.ocr(img_path, clsTrue) items [] for line in result: for box, (text, score) in line: items.append({ box: box, # 四点坐标 [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] text: text, # 识别出的字符串 score: score, # 置信度 0.0 ~ 1.0 }) return items这里的逻辑是把 PaddleOCR 的原始返回值拆成一个个dict。早期版本的ocr()返回嵌套结构最外层是每一行文字行内是坐标框加(文本, 置信度)元组新版本改为predict()后返回结构略有差异但只要recognize对外输出的结构不变界面层的代码就完全不用改。封装完之后GUI 层不需要关心模型细节拿到items直接画框、填列表就行。需要注意box里存的是原图像素坐标不是控件坐标后面 GUI 绘制时要做缩放换算这一块在image.py里处理。常见的坑是有人把两次识别的结果格式搞混直接在for line in result上取下标导致 IndexError。遇到这种情况先把result打印出来看一层结构再继续写循环。2.4 模块职责边界模块职责对应文件模型层引擎初始化、推理调用、结果标准化utils/ocr_utils.py数据层图片读写、缩放、坐标系换算utils/image.py界面层画布绘制、列表渲染、交互widgets/canvas.py等另外一个关键点engine.ocr()是同步阻塞调用图片较大时一次推理可能要一两秒直接放在 GUI 线程里会卡界面。常见做法是放到QThread里执行识别完成后通过pyqtSignal把items发回主线程。标注工具在加载 4000px 大图时这个区别体感非常明显演示前最好先确认一下项目里有没有做线程封装。3. PyQt5 标注链路canvas 坐标换算、列表联动与标签编辑3.1 image.py图片加载与坐标换算GUI 里显示的图片永远是被缩放过的画布上鼠标位置和原图像素位置不是一对一的关系。image.py要维护的就是这套换算关系记录原始图片尺寸、当前缩放比例、画布偏移量任一时间点都能把控件坐标换算回原图坐标。# utils/image.py 的坐标换算思路 from PyQt5.QtGui import QImage class ImageView: def __init__(self, path): self.pix QImage(path) self.scale 1.0 def to_scene(self, x, y): # 控件坐标 - 原图坐标 return int(x / self.scale), int(y / self.scale) def to_view(self, x, y): # 原图坐标 - 控件坐标 return int(x * self.scale), int(y * self.scale)这段代码的逻辑很直白所有标注框统一按原图坐标存储渲染时才乘以缩放系数。只有这样放大缩小、平移画布之后标注数据才不会漂移。最常见的 bug 是画框时忘了把鼠标坐标除以scale就存进去结果放大两倍后框的位置全部错位。3.2 canvas.py画框与重绘策略canvas.py是标注交互的核心。它继承QWidget重写paintEvent绘制图片、绘制标注框、高亮当前选中的区域。绘制文字框的典型代码如下def paintEvent(self, event): painter QPainter(self) painter.drawImage(0, 0, self.pix) for shape in self.shapes: pen QPen(QColor(0, 255, 0), 2) painter.setPen(pen) box shape[box] # 原始四点坐标 # 坐标转换到控件后绘制 points [QPoint(*self.view.to_view(x, y)) for x, y in box] painter.drawPolygon(QPolygon(points))逻辑说明drawImage先把图片画到底层画布上之后遍历标注框用绿色画笔绘制多边形。这里画的是四边形因为 PaddleOCR 返回的框不一定是正矩形用drawPolygon比drawRect更通用。QPen的宽度一般设 2 像素在缩放倍数较大时可以考虑按1 / scale动态调整线宽避免放大后线条粗得看不清文字。鼠标交互方面常见做法是setMouseTracking(True)开启鼠标跟踪在mousePressEvent记录起点mouseMoveEvent更新橡皮筋矩形mouseReleaseEvent确定最终坐标并生成 shape。项目里shape.py就是干这个的把一次鼠标操作结果整理成一个带类型、坐标、标签的对象。3.3 OCR 结果回填与 label_list 联动OCR 识别完之后结果要同时出现在右侧列表和画布上。label_list_widget.py和myQListWidgetItem.py配合实现「列表项与图像框」的双向联动靠信号完成信号触发时机对应操作itemClicked点击列表项高亮画布上对应文字框shape_selected点击画布上的框滚动列表并选中对应项ocr_finished识别线程结束清空列表批量重新填入# 把 OCR 识别结果批量塞进列表的伪代码 def on_ocr_finished(self, items): self.list_widget.clear() for it in items: item MyQListWidgetItem(it[text]) item.setData(Qt.UserRole, it[box]) # 坐标存进列表项 self.list_widget.addItem(item)这样做的好处是列表项和画布形状共享同一个box数据源用户修改列表里的文字时myQListWidgetItem里保存的数据同步更新导出时不会出现「图上是新文本列表里还是旧文本」这类不一致。3.4 标签编辑、亮度调整与导出流程实际标注流程中识别结果不可能一次全对。label_dialog.py提供弹窗让用户修改文字内容和标签类别brightness_contrast_dialog.py则负责调整图片亮度和对比度改善低质量图片的识别效果。整体数据流是加载原图 → 同步 OCR 识别 → 结果填入列表并绘制在画布 → 用户逐条修改 → 导出 JSON 或纯文本。导出时遍历列表里的所有 item把Qt.UserRole中保存的坐标和编辑后的文本组合成结构化数据这一步直接对接数据集格式。4. requirements 与 default_config.yaml依赖锁定和推理参数配置4.1 requirements 里应该锁什么requirements.txt是下载后第一个要看的文件。PyQt5、PaddleOCR 这类项目依赖复杂paddleocr会连带安装 opencv、numpy、shapely 等一堆库版本互相踩坑的概率很高。常见做法是先把关键包装上再单独锁版本pip install -r requirements.txt一份常见的requirements.txt关键内容大致是这样的PyQt55.15 paddleocr opencv-python PyYAML这里不建议把paddlepaddle手动写进去因为 CPU 和 GPU 版本的安装方式不同写死反而容易装错。安装时需要注意pyqt5-qt55.15.19这类带精确版本号的间接依赖如果 pip 解析失败可以改用pip install pyqt55.15.9降级重试通常是新版本 Qt 与当前系统的兼容性问题。4.2 default_config.yaml 的参数拆解项目里把推理参数抽到了config/default_config.yaml好处是改配置不用动源码答辩演示时现场切换语言模型比较方便。常见配置项如下配置键作用常用调整ocr.lang识别语言ch/en/japanocr.use_angle_cls是否启用方向分类倾斜文本设truedet.limit_side_len检测最长边大图调 960 以上rec.batch_num识别批次大小文本行多时调大det.limit_side_len这个参数容易被忽略。默认值对普通截图够用但遇到超长图或者高清扫描件时检测模型会把长边压缩到固定长度小字会直接丢框。实际项目里我会把原图先做一次长边缩放再进 OCR或者把limit_side_len调大代价是推理时间变长、显存占用变高需要按机器配置折中。4.3 app.py 的启动顺序# app.py 的启动逻辑 import sys from PyQt5.QtWidgets import QApplication from guiocr.widgets.main_window_ui import MainWindow def main(): app QApplication(sys.argv) window MainWindow() window.show() sys.exit(app.exec_()) if __name__ __main__: main()这段代码的逻辑是标准的 PyQt5 启动流程但项目结构上把main.py和app.py分开是有讲究的main.py负责读取 YAML 配置、初始化日志app.py只负责创建界面。这样拆分之后以后想写无界面的批处理脚本可以跳过app.py直接复用配置加载和 OCR 封装不需要把 GUI 代码也跑一遍。4.4 .zbak 后缀文件怎么处理项目里有大量.zbak后缀文件比如misc.xml.zbak、profiles_settings.xml.zbak这是 PyCharm 工程配置文件被改名后的备份。.idea目录属于 IDE 配置跑代码用不到可以直接忽略。真正需要关注的是guiocr/包、main.py、app.py、requirements.txt和config/目录。交作业之前建议清理掉这些备份文件rm -f *.zbak .gitignore.zbak rm -rf .idea清理之后代码结构更干净论文里画系统结构图时也更容易向老师解释哪些是核心代码。5. 部署排错OpenGL 界面无显示、GPU 安装与中文路径5.1 PyQt5 界面无显示与 OpenGL 软件渲染把 PyQt5 程序放到服务器或虚拟机里运行时最常见的问题是窗口起不来、白屏、或者直接段错误。大部分情况下是 Qt 检测 OpenGL 失败导致的常见做法是在导入 PyQt5 之前设置环境变量export QT_OPENGLsoftware export QT_QPA_PLATFORMxcb python main.pyQT_OPENGLsoftware强制 Qt 使用软件渲染跳过显卡驱动检测QT_QPA_PLATFORMxcb指定 Linux 下的窗口系统协议解决部分发行版默认平台插件找不到的问题。如果设了这两个变量后窗口能正常显示说明是显卡驱动或 Qt 的 GL 检测有问题而不是代码本身的问题。代码里也可以写死这个环境变量避免每次都要手动 exportimport os os.environ.setdefault(QT_OPENGL, software)5.2 PaddleOCR GPU 版本怎么装经常有人把paddleocr和paddlepaddle搞混。paddleocr本身只提供 OCR 的 Python API真正的底层算子在paddlepaddle里。想用 GPU 跑需要安装paddlepaddle-gpu而不是paddlepaddle并且版本要和本机 CUDA 对应。建议直接用虚拟环境安装避免污染系统 Pythonpython -m venv venv_ocr venv_ocr\Scripts\activate pip install paddlepaddle-gpu pip install paddleocr安装完成后可以用一行代码验证 GPU 是否生效import paddle print(paddle.is_compiled_with_cuda())输出True说明编译了 CUDA 支持实际是否调用 GPU 还要看paddle.device.get_device()。如果输出False说明装成了 CPU 版需要重装对应 CUDA 版本的paddlepaddle-gpu。5.3 中文路径导致解析错误项目说明里特别强调「项目名和路径不要用中文」这个提醒是真实的不是套话。PaddleOCR 加载模型和 OpenCV 读取图片时对中文路径兼容性很差cv2.imread遇到中文路径直接返回None后续代码执行findContours就会报空指针错误。z解决方式有两个一是把整个项目放到纯英文路径下推荐这种二是如果图片路径改不了用np.fromfile加cv2.imdecode绕过imread的限制import cv2 import numpy as np def imread_unicode(path): data np.fromfile(path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)Windows 下还会遇到另一个问题控制台编码导致的中文乱码。运行前先执行chcp 65001切到 UTF-8否则 logger 输出的中文信息在 cmd 里全是乱码排查问题时完全找不到有效信息。5.4 用 logger.py 判断是模型问题还是界面问题项目自带的logger.py承担了日志输出职责。遇到程序异常时不要直接扒代码按顺序做三件事先看日志里有没有 PaddleOCR 的推理耗时记录确认模型是否正常加载再单测ocr_utils.recognize接口传一张测试图看返回结构是否符合预期最后才打开 GUI 做界面交互测试。这样能快速区分问题出在模型层还是界面层避免在 PyQt5 的信号槽里找半天最后发现其实是模型路径加载失败。6. 二次开发把标注工具改造成批量识别与数据集导出工作台6.1 写一个批量识别脚本标注工具一次只能处理一张图但实际场景往往是几十张合同一起扫描。基于现有的ocr_utils封装写批量脚本只需要几十行# batch_ocr.py from guiocr.utils.ocr_utils import recognize import glob import json results [] for img_path in glob.glob(imgs/*.jpg): items recognize(img_path) results.append({image: img_path, items: items}) with open(ocr_results.json, w, encodingutf-8) as f: json.dump(results, f, ensure_asciiFalse, indent2)这段代码把指定目录下所有 jpg 文件逐个识别结果统一写入 JSON 文件。ensure_asciiFalse保证中文直接以原文写入而不是转成\u转义序列用文本编辑器打开也能读懂。6.2 替换成自己的推理模型如果想对特定场景优化比如只识别发票号码可以在初始化时指定自己的微调模型PaddleOCR( det_model_dirmodels/det, rec_model_dirmodels/rec, cls_model_dirmodels/cls, langch, )替换模型后先用单张测试图验证识别效果再跑批量脚本避免一次处理几百张才发现模型参数不对。模型目录里的配置文件也要一起保留PaddleOCR 加载时会读取其中的 yaml 字段来初始化算子。6.3 导出独立于 GUI 的数据集目录对做标注数据集的人来说可以把每张图的识别结果按图片同名存储方便后续用 LabelMe 或其它工具打开校对for item in results: base item[image].replace(.jpg, .json) with open(base, w, encodingutf-8) as f: json.dump(item[items], f, ensure_asciiFalse, indent2)每个 JSON 文件里的box坐标都是原图像素坐标和标注工具里看到的一致配合shape.py里的结构可以直接画回原图验证。到这里这个项目的价值就不止于一个毕设 demo而是一个可以实际用来批量处理单据、生成训练数据的桌面工具。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进