ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

PaddleOCR打包exe离线部署全指南:从模型导出到PyInstaller避坑

PaddleOCR打包exe离线部署全指南:从模型导出到PyInstaller避坑

简介:PaddleOCR离线EXE识别工具包,面向需在无Python环境下快速完成文字识别的开发者和实施人员,将PaddleOCR模型、运行依赖及调用程序封装为可直接执行的exe工具,输入图片路径即可输出识别结果,适合批量截图转文本、票据信息抽取等场景。压缩包共2000个文件,大小约279.22MB,包含Python源码与字节码(py/pyc)、原生动态库(pyd/dll)、依赖配置文件(ini/dll)以及运行时所需的时区数据库资源,便于离线部署与二次裁剪。已有3789人浏览学习,工具不仅覆盖多语言OCR识别流程,还保留了完整PyInstaller打包配套结构与依赖清单,使用者可对照调整图片路径接口、输出格式或嵌入批处理流程。包内模型与运行文件分类清晰,能帮助读者理解PaddleOCR从脚本到可分发exe的完整构建方法,降低模型轻量化与打包部署的实操成本。

1. 离线部署的刚需:PaddleOCR 打包 exe 离线工具到底解决了什么

OCR 模型在开发机上跑得风生水起,等真要交付给现场用户时,麻烦就来了:目标机器没装 Python、没有 PaddleOCR 依赖链、甚至不能联网。paddleocr 打包 exe 离线工具,就是把你训练好的检测、识别、方向分类模型连同推理库、运行环境全部收进一个目录,对方双击 exe 就能跑,不用装任何东西。但同时我得先说个反直觉结论:打包命令只是最后一步,真正的翻车点几乎全在它之前——模型没导成静态图、推理库选了 GPU 版、入口代码里路径写死了,随便踩中一个都够你折腾半天。这套思路适合做工具型交付的开发者,比如给现场做质检、单据识别、档案数字化的场景。

2. 先选对打包对象:模型、推理库与运行时的组合策略

很多资料一上来就教你敲pyinstaller -F,我建议先忍住。打包 exe 本质上是把你开发环境里跑通的那套依赖「搬进」一个独立目录,搬哪些、怎么搬,直接决定产物能不能用。这一章先把打包对象拆清楚,再给选型建议,这也是后面所有操作的地基。

2.1 离线工具的构成:三个模型文件加推理库加运行时

一个能离线跑的 OCR 工具,拆开看就是三块东西。第一块是模型文件,PaddleOCR 默认识别链路由三个模型组成:文字检测(det)负责找出图片里哪些区域有文字,方向分类(cls)把横躺、倒置的图片扳正,文字识别(rec)把区域图像转成字符串。三个模型缺一个,链路就断:漏了 cls,旋转 180 度的图片识别结果基本是乱码;漏了 det,rec 就没有输入区域。打包前先把这三个模型梳理清楚。

第二块是推理库,也就是 paddlepaddle 及其依赖。第三块是 Python 运行时和第三方库,比如 opencv、shapely、pyclipper 这些。PyInstaller 做的就是把后两块收集进_internal目录,再把第一块作为资源文件带过去。

以 onedir 模式下典型的产物目录为例:

ocr_tool/ ├─ ocr_tool.exe ├─ _internal/ │ ├─ paddle/ │ ├─ paddleocr/ │ ├─ opencv/ │ └─ ... 其他依赖 └─ inference/ ├─ det/ ├─ rec/ └─ cls/

这里的inference就是静态导出的模型目录,注意它必须和 exe 放在同一层级,路径逻辑后面第 3 章细说。很多人在这一步就犯了第一个错:把动态训练用的权重文件.pdparams直接塞进包,结果程序起得来但识别结果全是空。原因很简单,PaddleOCR 通过PaddleOCR(det_model_dir=...)加载的是推理模型,不是训练权重,两者文件的组织方式完全不一样。

2.2 模型选型:server 与 mobile 怎么取舍

PaddleOCR 官方在多个版本里都提供 server 和 mobile 两类模型。mobile 系列是为端侧和 CPU 场景设计的,模型文件更小,推理更快,精度上有轻微损失;server 系列精度更高,但体积和耗时都上去了。对于打包 exe 这种离线交付场景,我一般默认选 mobile。

对比项mobile 系列server 系列
单模型体积通常小几十 MB更大,文件明显增多
CPU 推理速度快,单张图秒级内慢,耗时明显上升
精度够用,边缘场景略弱更好,适合密集小字
打包后产物体积整体可控容易膨胀
适合场景离线工具、普通办公机服务端、性能有冗余的机器

如果你的目标机器 CPU 比较老,或者用户一跑就是几百张图,优先 mobile。把 det、rec、cls 三个模型全配成 mobile 系,打包产物体积和速度都更稳。如果现场对精度要求极高,比如小字号、弯折票据,再考虑 server,但要在产物体积和推理时间上做足心理准备。

2.3 推理库版本:paddlepaddle 的 CPU 版与版本匹配

第二个容易踩的坑是选了 GPU 版 paddlepaddle。GPU 版会带上 CUDA 相关运行库,打包后体积动辄多出几百 MB,而在目标机器没有对应显卡驱动的情况下,exe 甚至会直接启动失败。离线分发工具,一律用 CPU 版。常见做法是单独建一个干净的 venv,然后只装 CPU 版的 paddlepaddle,别偷懒复用你平时跑训练的那个环境,那个环境里很可能残留 GPU 依赖,被 PyInstaller 一起收进包。

版本匹配也要提防:paddleocr 和 paddlepaddle 的版本是有对应关系的,两个都写latest装出来大概率初始化报错。我习惯的做法是,先看当前 paddleocr 分支要求的最低 paddlepaddle 版本,然后把两者都固定到具体版本号写进 requirements.txt,而不是依赖 pip 自动解析。版本号这种玄学问题,等到打包阶段再排查,成本比现在高得多。

还有一个平时不会注意的细节:新建的打包环境里,opencv 不要装 opencv-python-headless 以外的变体,否则会连带拉进 GUI 依赖,无谓地增大包体。这一层的选择直接决定第 4 章 spec 文件里要写多少 excludes。

3. 动手前先埋好点:静态导出与入口代码改造

打包失败的最常见原因不是 PyInstaller 不会用,而是你喂给它的程序本身就依赖了开发机上的绝对路径和动态导入。这一章讲两个打包前必须完成的工作:把动态模型导出成静态推理格式,以及把入口代码改成能自适应打包态的路径写法。

3.1 静态模型导出:把动态图转成 inference 结构

PaddleOCR 训练时用的是动态图,权重文件是.pdparams;部署时更推荐加载静态推理模型,也就是.pdmodel加.pdiparams的组合。静态模型少了动态图构建的开销,加载和推理都更稳定,PyInstaller 打包时也更容易识别文件依赖。

导出命令以检测模型为例:

python tools/export_model.py \ -c configs/det/ch_PP-OCRv4_det.yml \ -o Global.pretrained_model=./pretrain/ch_PP-OCRv4_det_train \ Global.save_inference_dir=./inference/det

这条命令的核心参数就三个:-c指定算法配置文件,里面写明了模型结构;Global.pretrained_model指向训练产出的权重;Global.save_inference_dir是静态模型输出目录。识别模型和方向分类模型同理,分别用各自对应的 yml 配置导出到inference/rec和inference/cls。

导出完成后检查目录:

inference/ ├─ det/ │ ├─ inference.pdmodel │ └─ inference.pdiparams ├─ rec/ │ ├─ inference.pdmodel │ └─ inference.pdiparams └─ cls/ ├─ inference.pdmodel └─ inference.pdiparams

注意一个容易看走眼的地方:PaddleOCR 某些版本导出 rec 模型时,还会带出一个字典文件ppocr_keys_v1.txt,这个文件决定字符映射,漏了它识别结果全变成一串乱码或者直接报字符越界。把它放在inference/rec/目录下随包分发,并在初始化时通过rec_char_dict_path显式指定。

3.2 入口代码改造:规避路径与多进程两个坑

模型导好之后,入口代码不要直接拿训练时的 demo 来打包。训练时ocr = PaddleOCR()会走默认路径,自动去官方地址下载模型,离线环境根本走不通,而且打包后当前工作目录会被 PyInstaller 重定向。我一般会把初始化逻辑单独抽成一个模块,用一个_base_dir函数统一处理开发态和打包态的路径差异。

# ocr_worker.py import sys from pathlib import Path from paddleocr import PaddleOCR def _base_dir() -> Path: # 打包后程序在 PyInstaller 的临时解压目录里运行 if getattr(sys, "frozen", False): return Path(sys._MEIPASS) # 开发阶段直接定位到项目根目录 return Path(__file__).resolve().parent def init_ocr(): base = _base_dir() ocr = PaddleOCR( det_model_dir=str(base / "inference" / "det"), rec_model_dir=str(base / "inference" / "rec"), cls_model_dir=str(base / "inference" / "cls"), rec_char_dict_path=str(base / "inference" / "rec" / "ppocr_keys_v1.txt"), use_angle_cls=True, # 方向分类,旋转图片靠它扳正 lang="ch", # 识别中文 use_gpu=False, # 离线工具一律 CPU show_log=False, # 关闭控制台日志,避免干扰 ) return ocr

这个_base_dir是整个打包成败的关键之一。PyInstaller 的 onedir 模式下程序运行目录可能是_internal里的临时位置,onefile 模式更是会把内容解压到系统临时目录,你如果写死成./inference,在常规运行时几乎必然找不到模型。sys._MEIPASS是 PyInstaller 注入的变量,指向资源真正被解压的目录,判断条件getattr(sys, "frozen", False)用来区分是否处于打包态。

参数层面的几个点也值得说清楚:use_angle_cls=True对应 cls 模型,如果你的图片全是正向的,可以关掉,但离线场景用户丢进来的图不可控,开着更保险;show_log=False不只是为了干净,还会减少大量日志写入临时目录的频率,避免拖慢冷启动。

还有一个隐藏很深的坑:Windows 下 PyInstaller 打包带 PaddleOCR 的程序,必须在主入口加多进程保护。PaddleOCR 内部某些依赖(比如预处理)会走多进程,而 Windows 下多进程在打包程序里如果没有保护,运行时可能直接崩溃或卡死。主文件里这样写:

# main.py import multiprocessing if __name__ == "__main__": multiprocessing.freeze_support() # 然后进入你原来的主逻辑 main()

freeze_support()是 Windows 多进程打包的官方要求,不加它,exe 在用户机器上可能出现随机闪退,而这个现象在开发机直接跑 python 脚本时几乎复现不出来。这一点我吃过亏,每次打包前都会检查主入口有没有写这一句。

4. 打包构建:PyInstaller 的 spec 配置与资源收集

到了真正打包这一步,不要再用pyinstaller -F main.py这种裸命令。PaddleOCR 依赖复杂,裸命令大概率漏掉隐式导入或资源文件,最终得到一个跑不起来的 exe。正确做法是准备一份 spec 文件,把 hidden imports、资源目录、排除项全部写清楚,让打包过程可复现,改一次配置就能重新出包。

4.1 编写 spec 文件:hidden imports 与资源目录

spec 文件本质是 Python 脚本,PyInstaller 执行它来构建产物。我常用的模板如下:

# ocr_tool.spec # -*- mode: python ; coding: utf-8 -*- from pathlib import Path project_root = Path.cwd() inference_dir = project_root / "inference" # 静态模型目录 fonts_dir = project_root / "fonts" # 中文字体,防止目标机器缺字 assets_dir = project_root / "assets" # 其他资源,如图标、配置文件 a = Analysis( ["main.py"], pathex=[str(project_root)], binaries=[], datas=[ (str(inference_dir), "inference"), (str(fonts_dir), "fonts"), (str(assets_dir), "assets"), ], hiddenimports=[ "paddle", "paddleocr", "paddleocr.tools", "skimage", "imgaug", "shapely", "pyclipper", ], excludes=[ "paddle.fluid.core_avx", "paddle.dataset", "matplotlib.tests", "PIL.ImageShow", ], noarchive=False, ) pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, exclude_binaries=True, name="ocr_tool", console=True, # 调试期保留控制台,交付时再关 ) coll = COLLECT( exe, a.binaries, a.datas, name="ocr_tool", )

这份 spec 里最核心的是hiddenimports。PyInstaller 做的是静态分析,它扫描 import 语句,但 PaddleOCR 和 paddle 内部大量模块是动态导入,靠分析根本抓不全。这里的清单是根据实际跑出来的报错不断补出来的,属于血泪经验。新版本的环境可能还会报缺其他模块,比如lmdb、prefetch_generator,看报错缺什么就往这个列表里加什么,加到不报为止。

datas把模型、字体、资源目录整体拷进包,元组左边是源路径,右边是目标目录。注意这里的目标路径是相对_internal的,程序运行时通过_base_dir()拼出完整路径。

excludes是为了压体积。paddle.dataset里带了一堆训练数据下载脚本,对推理毫无用处;matplotlib.tests、PIL.ImageShow这类都是无关测试资源。每排除一项,产物能瘦一圈。第一次打包时体积往往是最大的,后续不断加 excludes 会逐步瘦下来。

4.2 构建命令与产物校验

spec 写好后,构建命令很简单:

pyinstaller --clean --noconfirm ocr_tool.spec

--clean清掉上次构建的缓存,--noconfirm覆盖输出目录。用 spec 而不是-F的好处是:-F生成单文件,运行时把全部依赖解压到临时目录,冷启动慢,杀毒软件误报率高;而 spec 里的COLLECT是 onedir 模式,产物是一整个目录,资源文件直接裸露在外,模型路径更直观,启动也快。离线交付多一个文件夹不多,换取的是稳定性和可排查性,这笔账值得。

构建完成后,先看dist/ocr_tool/目录是否完整:exe 在顶层,_internal存在,inference三个模型子目录都在。然后在开发机上双击 exe 跑一张测试图,确认能出结果再考虑后续。第一次跑的时候保留console=True,控制台会把 PaddleOCR 的初始化日志和报错原样打出来,比黑匣子式的闪退好排查得多。全部调通之后,再把它改成console=False重新出包,体验上更像一个正规工具。

5. 打包避坑:最容易翻车的五个现场

这一章全是真实翻车记录。每一条我都按「现象 → 原因 → 解决」的结构写,你对照自己的环境就能快速定位问题,不用把半天时间耗在搜索上。

5.1 现象:运行报错 No module named 'paddle'

打包好的 exe 在开发机上双击就弹错,提示找不到paddle模块,但直接在命令行跑 python 脚本又是好的。原因基本有两个:一是打包用的环境不是干净 venv,里面 paddle 相关包残缺;二是 PyInstaller 静态分析漏掉了 paddle 的动态导入,模块压根没被收进_internal。解决方法是新建干净 venv 重新安装 CPU 版 paddlepaddle 和 paddleocr,并把hiddenimports里的paddle、paddleocr显式补全。之后再报缺什么模块,就往这个列表里加,这个动作会重复几次,别嫌烦。

5.2 现象:exe 秒退或报错找不到模型文件

程序启动就直接闪退,或者报FileNotFoundError,看路径指向的是开发机的C:\Users\xxx\inference。原因很典型:训练代码里用了相对路径或硬编码绝对路径,开发时能跑是因为路径恰好存在;打包后工作目录被 PyInstaller 改了,路径就失效了。解决方法是回到第 3 章,把入口代码改成_base_dir()统一取路径。这里有个小技巧:凡是报错信息里层级很深、带_MEI字样临时目录的,十有八九是路径问题,检查代码里有没有裸写相对路径。

5.3 现象:打包产物体积爆炸,动辄 800 MB 以上

第一次打包出 800 多 MB,这是每个初学者都会经历的事。原因大多是打包环境装了 GPU 版 paddle,把 CUDA 运行库一起卷进来了;或者没写excludes,把训练数据、测试资源全收进包。解决方法是卸载 GPU 版、确认 pip list 里是 CPU 版 paddlepaddle,然后在 spec 里持续补充 exclude 项。正常一个 mobile 模型的离线 OCR 工具,压到 400 MB 上下是合理的,太离谱就从这两个方向排查。

5.4 现象:目标机器上提示缺少 DLL 或运行库

exe 在你机器上正常,拷到用户机器上启动时报缺少msvcp140.dll之类的错误。原因是用户机器没有安装 VC++ 运行库,Python 打包产物依赖这部分系统组件。解决方法是让用户安装 VC++ Redistributable,或者把缺失的 DLL 放进_internal随包分发。如果你的目标用户是普通办公人员,装运行库这一步本身就是阻力,我把常用运行库合并进交付包的依赖目录,并写进交付说明,让现场的人先装再跑。

5.5 现象:识别中文全乱码,或杀毒软件直接隔离 exe

这两个问题表面不相干,但都出现在交付阶段。中文乱码往往不是模型问题,而是目标机器缺少中文字体,PaddleOCR 画框和部分预处理依赖系统字体。解决方法是把中文字体放进fonts目录并在datas里声明,程序初始化时通过PaddleOCR的字体参数指定。杀毒软件隔离是 PyInstaller 打包产物的通病,根源是打包壳特征被误判。解决方法是在测试机上先做白名单验证,正式交付时建议提供校验值,并和对方说明这是打包产物,必要时换 Nuitka 加密打包可以明显降低误报率,但构建配置也要重新调一遍。

6. 离线验证:在干净机器上跑通全流程再交付

6.1 验证清单与冒烟脚本

打包完成并不代表交付完成。我每次都会用一台没有装 Python 和任何依赖的干净机器做验证,步骤如下:

  1. 把dist/ocr_tool/整个目录拷贝过去,不执行任何安装
  2. 断网环境下双击 exe,用一批测试图跑识别
  3. 核对识别文本是否有中文乱码、是否出现闪退
  4. 记录冷启动耗时和单张图推理耗时
  5. 反复退出重启三次以上,确认多进程保护生效

冒烟脚本可以做成一个简单的文件,放在工具目录里:

# smoke_test.py from pathlib import Path from ocr_worker import init_ocr ocr = init_ocr() result = ocr.ocr(str(Path("test_img.png")), cls=True) assert result and result[0], "no text detected" text = "".join(line[1][0] for line in result[0]) assert len(text.strip()) > 0, "empty text result" print("smoke test passed:", text)

这段脚本在干净机器的目标目录下直接运行,如果它通过了,说明模型、字体、资源路径全都没问题。注意冒烟脚本运行完后要删掉,避免占用交付目录空间。

6.2 版本固化与最终交付物

最后一步是把版本信息固化成文件。写一个requirements.txt,把 paddleocr、paddlepaddle、pyinstaller 和相关依赖的版本号全部锁定,和 spec 文件、导出命令放同一个目录,作为下次重建的完整依据。交付物清单建议只保留三样:ocr_tool整个目录、smoke_test.py(若需要对方自检)、一份一页纸的说明,写清运行方式和运行库要求即可。

从那以后,我每次打包完都强制走一遍干净机器冒烟清单,哪怕时间再紧也不跳过。路径问题、字体问题、缺失模块这类坑,只有在干净环境里才会现出原形。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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