
一次把 PPOCRLabel 打包成 exe 的完整实战记录上个月有个做票据识别的三人小队找到我说他们的标注环节卡住了算法同学在服务器上跑得好好的 PPOCRLabel到了标注同事那几台 Windows 机器上光是装环境就折腾了两天Python 版本不对、paddle 装不上、PyQt5 和别的东西打架最后标注同事撂下一句你能不能给我个双击就能打开的东西。这话其实点到了要害——OCR 标注工具本身是个很成熟的桌面应用但它对运行环境的挑剔程度和它带来的价值不成正比。于是就有了这次把 PPOCRLabel 打包成 exe 的活儿目标很明确一个文件夹或者一个 exe拷过去双击就能框选、能自动预标注、能导出识别结果全程不碰命令行、不装 Python。这篇就把我从环境准备、依赖梳理、spec 配置、路径适配到故障排查的整条链路摊开讲适合手上有一批标注任务要分发给非技术同事、又不想给他们装开发环境的同学参考也适合任何想把 PyQt 深度学习推理这类混合型桌面应用做成绿色包的人。1. 先想清楚为什么要打包以及打包到底打的是什么1.1 标注场景里那些装环境的隐形成本很多人对打包的理解停留在方便但真实成本远不止方便两个字。标注是个典型的人力密集型、短周期、多机分发的活儿一批数据可能分给五个人五个人里三个用笔记本、两个用台式机系统有 Win10 有 Win11有没有装过 Python 谁也不知道。如果走发一份安装文档让他们自己装的路线你要付出的代价是每个人平均半小时到两小时的环境沟通成本加上版本不一致导致的我这边报错你那边正常的排查成本再加上中途有人重装系统又得来一遍。把这些成本乘上一个季度反复来几轮就相当可观了。而打包成 exe 之后分发动作退化成解压、双击出问题的概率从大概率装错降到小概率缺运行库。更关键的是版本可控所有人的 PPOCRLabel 用的是同一份代码、同一份模型、同一套默认参数标注出来的框和识别结果不会因为环境差异而产生莫名其妙的偏差。这一条在多人协作标注里其实比省事更重要我见过因为两个人用的模型版本不同同一批图片的预标注结果差出一截最后返工重标的案例。1.2 三种打包路线的取舍与实际选择摆在我面前的路其实有三条我把它们拉出来对比了一下方案原理体积启动速度适配难度结论PyInstaller分析 import 树把 Python 运行时和依赖一起冻结中等300MB 到 900MB中低到中选它Nuitka把 Python 编译成 C再交给 C 编译器产出二进制偏小快高paddle 这类含大量动态导入的库容易炸放弃绿色便携包直接打包一个嵌入式 Python 加 site-packages大往往 1GB 以上快低备选Nuitka 理论上产物更小更快但它对 paddle 这种大量使用动态加载和 C 扩展的库并不友好编译过程动辄一两个小时出问题还很难定位对一个内部工具来说投入产出不成比例。绿色便携包嵌入式 Python虽然最稳但目录结构松散、文件数量上千拷贝和压缩都慢而且用户很容易误删里面的东西。PyInstaller 是折中最优解它对 PyQt5 有一流的官方 hook 支持对 paddle 虽然需要手工补 hiddenimports但补完就稳定了产物是自包含的双击即跑。所以我最终选了 PyInstaller并且坚持用单目录onedir模式而不是单文件onefile这个决定后面会详细说理由。1.3 拆开看这个 exe 里到底装了哪些东西动工之前我先把要打包的玩意儿拆成四层这个分层后来帮我在排查问题时省了很多时间界面层PyQt5 写的桌面窗口包括主窗口、画布、标注框编辑对话框、识别结果表格。这一层会带出 Qt 的一堆插件platforms、styles、imageformats。业务层PPOCRLabel 自己的 libs 目录负责图片加载、框选交互、结果读写、快捷键处理。推理层paddleocr 的检测与识别流程底下压着 paddlepaddle 的 C 核心和一堆运算库。资源层识别用的字典文件、字体文件、界面图标以及推理模型本身。这四层的打包难度是递增的。界面层基本开箱即用资源层只要路径处理对就没问题真正麻烦的是推理层——paddle 在运行时会去加载一堆以.dll结尾的运算库PyInstaller 的静态分析扫不到这些动态加载必须靠 hook 或者手工收集。2. 打包前的环境准备把地基打平2.1 Python 版本与虚拟环境的选择这一步是整个流程里最不该省、又最容易被省的一步。我的做法是专门建一个干净虚拟环境只装打包必需的包绝不在日常开发环境里直接打包。原因很直接PyInstaller 的分析逻辑是顺着 import 走你环境里多装一个包它就可能在分析阶段被牵连进来轻则体积膨胀重则引入冲突的依赖版本。版本上我选的是 Python 3.8 到 3.9 这个区间paddlepaddle 的 CPU 版对这两个版本的支持最成熟PyQt5 也是。不要贪新3.11、3.12 上装 paddle 经常会遇到 wheel 缺失或者编译问题打包阶段再撞上这些坑会更难受。创建环境的命令没什么花头conda create -n pplabel_pack python3.9 -y conda activate pplabel_pack python -V装依赖的顺序我建议按底层先、应用层后来因为 paddlepaddle 的安装如果先被其他包拉了一个奇怪的版本后面很难掰回来pip install paddlepaddle2.5.2 -i https://pypi.tuna.tsinghua.edu.cn/simple pip install paddleocr pip install pyqt55.15.9 pip install pyinstaller5.13.2 pip install shapely pyclipper opencv-python-headless pillow rapidfuzz注意opencv 这里我用的是 headless 版本因为打包后的工具只做图像处理不做弹窗预览带上完整版会把一堆图形界面相关的库拖进来体积和冲突风险都上升。但如果你的 PPOCRLabel 代码里某处调用了cv2.imshowheadless 版会直接抛异常这种情况就老老实实换回完整版。装完一定要当场验一遍别等到打包完才发现推理跑不起来python -c import paddle; print(paddle.__version__); paddle.utils.run_check() python -c from paddleocr import PaddleOCR; ocrPaddleOCR(use_angle_clsTrue, langch); print(ocr.ocr(test.jpg))这一步很值。我见过太多次包打得挺成功双击打开能显示界面一框选就报错的情况根因都是原本环境里推理就不通只是没验证。2.2 摸清依赖树别凭感觉补模块PyInstaller 打包 pytorch、paddle 这类库时最大的痛苦来源是运行时才暴露的缺失模块。与其一个个报错试出来不如先摸一遍依赖树做到心里有数pip install pipdeptree pipdeptree -p paddleocr pipdeptree -p paddlepaddle看的时候重点盯两类东西一类是那些通过importlib.import_module动态加载的包比如某些图像处理后端PyInstaller 静态分析抓不到另一类是带 C 扩展、带独立 dll 目录的包shapely 的 GEOS、paddle 的 libs、opencv 的 dll它们的数据文件必须显式收集。我当时的做法是开了一个小本子边打包边记录每次报ModuleNotFoundError的模块名最后汇总成 hiddenimports 清单。这个过程跑个四五轮基本就收敛了别指望一次成功。2.3 找一个干净的入口脚本PPOCRLabel 原本的主文件里既有界面代码又有启动逻辑直接拿它当 PyInstaller 的入口会有两个小麻烦一是它内部可能对sys.argv做了参数处理打包后 argv 的第一个元素变成 exe 路径容易引发意外二是窗口程序最好在入口处统一设置高 DPI 缩放、异常兜底和日志重定向。所以我单独写了一个launcher.py作为入口import sys import os import traceback import multiprocessing def main(): multiprocessing.freeze_support() # 冻结后多进程必须先调这个 try: from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QApplication QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) from PPOCRLabel import PPOCRLabel # 包内主类 app QApplication(sys.argv) win PPOCRLabel() win.show() sys.exit(app.exec_()) except Exception: crash_log os.path.join(os.path.expanduser(~), ppocrlabel_crash.log) with open(crash_log, a, encodingutf-8) as f: f.write(traceback.format_exc() \n) raise if __name__ __main__: main()那段异常兜底非常关键。打包后的程序一旦崩溃控制台没有、堆栈也看不到用户只会告诉你点了一下就没了。写一份 crash log 到用户目录能让你在远程电话里三分钟内定位问题所在而不是靠猜。3. spec 文件逐段拆解把坑一个个堵上3.1 第一版命令与必然的失败新手版的命令通常是这样的pyinstaller -F -w -n PPOCRLabel launcher.py-F单文件-w不显示控制台。跑完大概三到五分钟产出一个几百兆的 exe双击——闪退。或者运气好点界面出来了但一拖图片进去就报找不到某个模型文件。第一次失败几乎是必然的因为这条命令只处理了 Python 层面的 import对资源文件和动态库一无所知。所以别在命令行参数上折腾直接一步到位写 spec 文件后续所有配置都在里面维护可复现、可迭代。3.2 hiddenimports让 PyInstaller 看见看不见的东西spec 的第一段是收集隐藏导入。我用了collect_submodules做全量收集虽然会把一些用不上的子模块也带上但在 paddle 这样动态加载密集的库面前宁滥勿缺from PyInstaller.utils.hooks import collect_submodules, collect_data_files, collect_dynamic_libs hiddenimports [] hiddenimports collect_submodules(paddle) hiddenimports collect_submodules(paddleocr) hiddenimports collect_submodules(shapely) hiddenimports [ pyclipper, rapidfuzz, rapidfuzz.fuzz, PIL._tkinter_finder, # 有时会被 pillow 间接引用 pkg_resources.py2_warn, scipy.special, # 若被间接依赖 ]这里有个经验collect_submodules(paddle)出来的列表非常长会让分析阶段明显变慢但换来的是启动时少一堆莫名其妙的报错。如果你追求极致的体积可以收集完之后手工剔除明显无关的部分比如 paddle 里的分布式、静态图导出工具但建议第一版全量等跑通了再瘦身否则你会同时面对缺模块和多模块两类问题的混淆。3.3 datas资源文件才是最容易翻车的地方分析完 import接下来是资源。这部分我分四类收集datas [] # 1. 应用自身的界面资源 datas [(PPOCRLabel/resources, PPOCRLabel/resources)] # 2. paddleocr 的字典与字体ppocr/utils 下有大量 .txt 和 .ttf datas collect_data_files(paddleocr) # 3. paddle 的动态库 datas collect_dynamic_libs(paddle) # 4. shapely 的 GEOS 运行库 datas collect_data_files(shapely)其中第二类是重灾区。paddleocr 推理时会去读ppocr/utils/ppocr_keys_v1.txt这样的字典文件路径是相对包目录算的一旦没被打进去表现就是检测能跑、识别出来全是乱码或空字符串。字体文件同理缺了之后画中文框会变方块。第四类 shapely 的 GEOS 库也不能漏漏了会在from shapely.geometry import Polygon时直接抛 DLL load failed而这个异常往往在界面初始化阶段抛出表现为窗口根本没起来很容易被误判成 PyQt 问题。3.4 路径适配程序在哪、数据写哪必须分开打包后的程序有个反直觉的特性单文件模式下程序运行时会把自己解压到一个临时目录sys._MEIPASS而单目录模式下这个路径就是解压后的_internal目录。不管哪种这两个目录都可能是只读的、会被清理的。所以路径处理必须贯彻一条原则读资源用临时目录写数据用用户目录。我封装了两个小函数代码里所有路径都走它们import sys, os def res_path(*parts): 读取随包分发的只读资源 base getattr(sys, _MEIPASS, os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, *parts) def user_data_path(*parts): 写入用户可写目录避免 Program Files 无权限 base os.path.join(os.path.expanduser(~), .ppocrlabel) os.makedirs(base, exist_okTrue) return os.path.join(base, *parts)第二条在真实分发里救过我好几次。有个同事把整个文件夹放进了C:\Program Files下面标注结果全部写不进去界面上点了保存毫无反应也没有报错——因为写文件失败在代码里没有捕获。改成写用户目录后这个问题彻底消失。3.5 模型文件离线环境下的必答题paddleocr 默认行为是首次运行时自动去网上下载轻量检测和识别模型存到用户目录的.paddleocr下。这个设计对开发者很友好对打包分发极其不友好内网机器、断网笔记本、限制外网的办公环境全都会卡在这一步而且表现是点了自动标注转圈半天然后报网络错误。解法是提前把推理模型下载好随包分发然后在代码里显式指定模型目录from paddleocr import PaddleOCR ocr PaddleOCR( use_angle_clsTrue, langch, det_model_dirres_path(models, ch_PP-OCRv4_det_infer), rec_model_dirres_path(models, ch_PP-OCRv4_rec_infer), cls_model_dirres_path(models, ch_ppocr_mobile_v2.0_cls_infer), use_gpuFalse, show_logFalse, )对应地spec 里要把 models 目录加进 datasdatas [(models, models)]模型目录体积不小轻量版三个加一起一百多兆换成服务器版模型能到几百兆。所以这里有个取舍标注场景用轻量模型足够预标注只是给你一个起点最终框还是人工修没必要上大模型拖慢每一张图的响应。3.6 把体积从八百兆压到三百兆第一版打出来我一看870MB。能跑但压缩包传起来很痛苦解压也慢。瘦身我分三步做第一步排除无关模块。spec 里加excludes [ matplotlib, tkinter, IPython, jupyter, notebook, pytest, sphinx, scipy, pandas, sklearn, paddle.distributed, paddle.static, PyQt5.QtWebEngineWidgets, PyQt5.QtQuick, PyQt5.QtQml, PyQt5.Qt3DCore, PyQt5.QtBluetooth, PyQt5.QtMultimedia, ]排 Qt 那些用不上的大模块收益最明显WebEngine 一个就上百兆。但要注意paddle.static有些推理路径可能会间接引用排除之后务必完整跑一遍检测和识别别只看界面能不能打开。第二步关掉 UPX。很多人第一反应是用 UPX 压缩以减小体积但对 Qt 的 dll 和 paddle 的运算库用 UPX 压缩很容易出现压缩完能启动但某个功能一用就崩的诡异问题而且压缩后的 dll 在某些安全软件里更容易被拦。我的建议是upxFalse踏实。第三步剔除调试符号和测试数据。打包后检查_internal目录把明显的tests、test_data、__pycache__、.pyc冗余项手工清掉。这一步做完最终稳定在 320MB 左右压缩包 180MB是可以接受的分发体积。4. 单文件还是单目录一个必须说清楚的选择4.1 两种模式的真实差异单文件onefile看起来最像一个软件但它的工作机制是每次启动都把内部所有内容解压到临时目录。320MB 的内容解压一次在普通机械硬盘上要十几秒固态也要三五秒而且退出时清理不干净会在临时目录堆垃圾。标注工具的使用特征是开开关关、一天几十次这个启动延迟会让人非常烦。单目录onedir产出的是一个文件夹里面有 exe 和_internal启动时几乎瞬时。分发的时候压缩成 zip 发过去让用户解压到本地再双击体验反而更好。所以我的最终选择是 onedir并且坚持把 exe 和_internal一起压缩不给用户拆分的机会。4.2 spec 里的关键配置a Analysis( [launcher.py], pathex[., ./PPOCRLabel], # PPOCRLabel 的包目录要加进来 binariesbinaries, datasdatas, hiddenimportshiddenimports, hookspath[], excludesexcludes, noarchiveFalse, ) pyz PYZ(a.pure, a.zipped_data) exe EXE( pyz, a.scripts, [], exclude_binariesTrue, namePPOCRLabel, debugFalse, stripFalse, upxFalse, consoleFalse, # 无控制台 iconapp.ico, ) coll COLLECT(exe, a.binaries, a.zipfiles, a.datas, stripFalse, upxFalse, namePPOCRLabel)pathex里的./PPOCRLabel很容易忘。如果 PPOCRLabel 是以子目录形式存在的包不加这一行分析阶段会报找不到模块或者找到的是一个同名但不相干的模块。consoleFalse关掉控制台窗口配合前面 launcher 里的 crash log是生产环境的正确姿势。调试期间建议先设成True能看到完整堆栈等全部跑通再关。5. 干净机器上的验证与分发细节5.1 冒烟测试的必测清单打包机上的成功不算成功因为你的机器上有完整的 Python 环境和各种系统运行库。我每次打完都会在一台从来没装过 Python、没装过开发工具的机器上跑一遍测试项固定这几条双击 exe界面在 5 秒内出现无闪退。拖入一张 4K 大图画布正常显示不模糊不变形。点自动标注检测框和识别文本都能出来中文不乱码。手动修改一个框、删除一个框、合并两个框操作流畅。点导出识别结果输出文件和图片路径都正确。关掉再开之前的配置还在。系统路径含中文和空格的情况下重复以上步骤。最后一条尤其重要。C:\Users\张三\桌面\新 建文件夹这样的路径在某些库的编码处理里会直接翻车而办公环境里这种路径遍地都是。5.2 配置持久化的正确位置标注工具需要记住的东西不少上次打开的目录、界面缩放比例、识别的语言设置、导出的格式偏好。这些如果写在 exe 同目录一旦程序被放进只读目录就失效。Qt 提供了现成的方案from PyQt5.QtCore import QSettings settings QSettings(SomeTeam, PPOCRLabel) # 实际使用中换成你自己的标识Windows 下它会写到注册表的用户配置区换机器不带走、删程序不影响系统、多用户互不干扰。比自己在 exe 旁边写 ini 文件可靠得多。5.3 分发包怎么组织我最终发出去的是一个 zip解开之后长这样PPOCRLabel/ PPOCRLabel.exe _internal/ 使用说明.txt 示例图片/使用说明.txt里只写三件事双击哪个文件、第一次打开慢是正常的杀软扫描、遇到闪退去用户目录找 crash log 发给我。不要写长篇大论标注同事不会看。6. 故障排查速查表与踩坑实录6.1 启动阶段的问题现象大概率原因处理方式双击后窗口一闪就没了缺少运行时依赖或初始化时抛异常把 console 改成 True 重打一版看堆栈同时检查 crash log报 could not find or load the Qt platform plugin windowsQt 的 platforms 插件没打进去确认 PyQt5 的 hooks 生效或手工把PyQt5/Qt5/plugins/platforms加入 datas报 DLL load failedshapely 或 paddle 的动态库缺失用 collect_dynamic_libs 收集并在干净机器上确认界面出现但全是方块字系统缺中文字体或字体文件未打包内置一份字体并在样式表里显式指定打开极慢20 秒以上用了单文件模式改单目录6.2 运行阶段的问题自动标注点了没反应。绝大多数是模型路径问题。要么det_model_dir指错了要么打包时 models 目录没进 datas。判断方法很简单看用户目录下有没有自动生成的.paddleocr文件夹如果有说明它在走在线下载逻辑你配置的本地路径根本没生效如果没有且报错说明路径拼错了。我建议在 launcher 里加一行日志把res_path(models)打出来对着实际目录看一眼就清楚。识别结果是乱码或者空白。字典文件缺失的典型症状。检查_internal/paddleocr/ppocr/utils/下ppocr_keys_v1.txt在不在。这个坑我踩过一次当时花了两个小时怀疑模型版本不对最后发现就是少了一个两百多 KB 的文本文件。标注结果保存不了。前文提过写到了只读目录。改成写用户目录并在保存失败时弹一个提示框别静默失败。静默失败是最坏的设计用户以为保存了实际什么都没写等到发现时已经标了一整天。老机器上打开就卡死。检查一下是不是在做 GPU 推理。打包给普通办公机的版本一定要显式use_gpuFalse否则某些驱动环境下 paddle 会在初始化阶段长时间卡住。杀毒软件报毒或者拦截。PyInstaller 的产物被误判是常见情况尤其是单文件模式。我的处理方式是用单目录模式、给 exe 加图标和版本信息spec 里配置versionversion.txt、在说明里告诉同事把文件夹加入信任区。真要彻底解决就得做代码签名内部工具一般不值当。7. 几个让我少走弯路的实操心得第一条心得是先跑通再优化别反过来。我见过有人一上来就配 excludes、上 UPX、做极致瘦身结果程序各种诡异崩溃最后分不清是排除错了模块还是打包本身有问题。正确顺序是全量收集 → 跑通全流程 → 记录产物大小 → 逐个排除 → 每排除一批就完整回归一次。第二条是把每次打包的 hiddenimports 清单存成文件。这些清单是试出来的是真正有价值的资产。下次换 Python 版本或者升级 paddle 时从这份清单出发能省掉大量重复试错。我自己的做法是把它单独放在pack_extra.py里spec 反过来 import 它。第三条是在代码里留一个环境自检入口。我在主窗口的关于菜单里加了一个隐藏项点开之后输出模型路径、字典文件是否存在、当前用户数据目录、Qt 版本、paddle 版本。这个在远程支持同事时太好用了比让他截图报错信息高效得多。第四条是每张图处理的时间要心里有数。打包成轻量模型后一张 1080P 的票据在普通 i5 上做检测加识别大概一秒多可以接受。但如果有人跟你说点了自动标注三分钟没反应先别怀疑代码去看他是不是拖了一张手机拍的 6000×4000 原图。在实际使用中加一个自动缩放的预处理把长边压到 1500 左右再送进推理速度提升非常直观标注精度几乎没有损失因为人工修框本来就不需要原分辨率的检测结果。最后再分享一个分发时的小技巧把第一次运行需要的东西做成一键检查。我在程序启动时做三件轻量的事——检查用户数据目录能不能写、检查模型目录是否存在、检查磁盘剩余空间是否大于 1GB任何一项不通过就在界面上弹出明确的文字提示而不是崩溃。这三行代码换来的是标注同事不会在群里发打不开三个字然后等你去猜而是直接告诉你它说模型目录找不到。