简介这是一份面向 iOS 开发者的 Paddle OCR 移动端文字识别完整工程包帮助在扫描文档、图片和现实场景中高效提取中英文文字。资源共 1107 个文件压缩包约 151.85MB以 h/m/hpp 源码文件、xcconfig/xcscheme 工程配置、png 素材以及模型转换与加载相关文件为主同时附带 podfile、plist、storyboard、OpenCV 配置等便于直接集成到 Xcode 项目。依托轻量级、高精度的 PaddleOCR 框架内容覆盖模型获取与转换、图像预处理、Swift/Objective-C 识别调用、性能优化和 UI 交互等环节适合希望免费用上离线文字识别能力的中高级 iOS 开发者。已有 807 人学习下载。整套工程目录组织清晰可从中掌握 iOS 端 PaddleOCR 的完整部署链路通过逐文件对照学习代码结构快速复用到文档扫描、车牌识别、名片提取等真实业务场景同时包内大量可编译源码与配置脚本也能帮助规避环境搭建和模型转换环节的常见问题。1. 为什么移动端 OCR 我这次选了 Paddle OCR我前段时间做了一款名片和票据识别工具第一个遇到的问题就是选择识别方案。调用云端 OCR 接口确实省事但每次识别都要等网络往返而且用户在地下室、电梯里直接变砖苹果自带的 Vision 框架对中文长文本和倾斜文字识别率又不理想。于是转向端侧方案最后选了 Paddle OCR。它不是单纯的识别库而是一套包含文本检测、方向分类、文字识别的完整流程官方提供了移动端轻量模型和 iOS 的 C 预测库libpaddle_api_light_bundled.a可以直接把 OCR 能力打进 App离线跑不按次收费。对处理身份证、快递单、字幕这类固定版式的场景这套方案在准确率和体积之间是性价比很高的选择。2. Paddle OCR iOS 部署的核心静态库与后处理源码在集成之前先要搞清楚拿到手里的东西为什么值钱。工程里只有四个核心文件libpaddle_api_light_bundled.a、ocr_clipper.cpp、ocr_db_post_process.cpp、ocr_crnn_process.cpp。很多第一次接触的人会把它们当成一个整体拖进工程但它们的角色完全不同静态库是 Paddle Lite 推理引擎三个 C 文件是 OCR 检测和识别模型的后处理逻辑。理解这个边界后面调参和排错才不会一头雾水。2.1 静态库 libpaddle_api_light_bundled.a 到底封装了什么PaddleOCR 的模型可以导出成多种格式但要在 iOS 上高效运行官方推荐的是 Paddle Lite 前端框架。libpaddle_api_light_bundled.a是 Paddle Lite 针对 iOS 交叉编译好的静态库里面包含了 Kernel 算子、Tensor 内存管理、配置解析和模型推理执行器。最终我们调用的是paddle::lite_api命名空间下的CreatePaddlePredictor它负责加载.nb模型文件并完成从前向计算到输出张量的一系列动作。拿到这个 .a 第一件事是确认它是给哪个 CPU 架构用的因为模拟器和真机的指令集不一样。用 lipo 看一眼lipo -info libpaddle_api_light_bundled.a如果输出里只有arm64那这个库只能跑在真机上模拟器编译直接报错。如果输出是x86_64 arm64这种 fat 文件说明打包时加了一层模拟器支持但通常官网下载包只提供 arm64。所以如果项目必须支持模拟器调试要么自己编译一套 x86_64 的 Paddle Lite 库要么在 Build Settings 里对模拟器架构排除这个静态库的链接。我一般选择后者真机调试比模拟器更有参考价值因为内存压力和 CPU 调度都是真实的。另外要注意静态库是预编译的算子集合在编译时被固定了下来。如果你的 OCR 模型里含有这个库不支持的算子运行时会提示Kernel not found但编译不会报错。这个问题在 3.2 接paddle_use_kernels.h时还会遇到后面细说。ocr_clipper.cpp提供的是图像裁剪与仿射变换功能它不依赖 OpenCV而是自己实现了多边形裁剪算法负责把检测网络输出的四边形区域从原图中抠出来并矫正成水平矩形再送给识别网络。2.2 检测和识别后处理源码各管哪一段这里涉及 OCR 双阶段流程先检测后识别。ocr_db_post_process.cpp对应检测后处理它处理的是 DBDifferentiable Binarization模型输出的概率图。具体做法是对每个像素做二值化阈值可调然后通过连通域寻找候选区域再用最小外接矩形框出文本区域。这套逻辑完全用原生 C 重写所以不需要额外引入 OpenCV这也是官方 iOS 工程能保持轻量的原因。ocr_crnn_process.cpp对应识别前处理和结果解码。识别模型输入的是被裁剪矫正的文本图片它需要先将图片缩放为固定的高度默认 32做归一化再填充进 tensor。模型输出是一串概率向量按时间步计算每个字符的概率分布最终解码成文字。这里包含的ctc_decode逻辑会去除重复字符和空白符得到真正可读的字符串。三个文件和静态库的协作关系可以用一张表说清楚文件阶段输入输出ocr_clipper.cpp检测与识别之间原图 四边形坐标输出矫正后的文本区域图ocr_db_post_process.cpp检测后处理模型输出的概率图输出文本框坐标按原图尺寸ocr_crnn_process.cpp识别前/后处理文本区域图输出解码字符串表格里最后一行容易忽略ocr_crnn_process.cpp不只是后处理它还要负责把裁剪出的图像转为模型输入 tensor包括缩放、BGR 通道转换和归一化。排错时如果识别结果全是乱码先检查是不是 3 通道变成了 4 通道或者在填充 tensor 时 RGBA 和 BGR 顺序写反了。之前我遇到过识别结果全部错位最后发现是 UIImage 的 PNGData 带上了 alpha 通道而模型输入要求的是三通道低级错误却耗了半天。3. iOS 工程集成从模型转换到 Xcode 配置拿到这些文件接下来要把它们塞进 Xcode 工程。这一步有两个关键点一是模型文件必须转换成 Paddle Lite 能读的.nb格式二是 Xcode 需要正确链接 C 静态库。很多项目卡在链接阶段是因为对 Paddle Lite 的依赖关系不熟。3.1 用 opt 工具把推理模型转成 Paddle Lite 格式PaddleOCR 训练完或者从官方仓库下载来的模型是推理模型格式包括model和params两个文件或者合并后的__model__。Paddle Lite 不能直接读这种格式需要先用opt工具优化并转化为.nb文件。在 macOS 上可以直接用编译好的opt二进制。假设当前目录下已经准备好检测模型命令长这样./opt --model_dir./ch_ppocr_mobile_v2.0_det_infer \ --valid_targetsarm \ --optimize_outocr_det \ --optimize_out_typeprotobuf参数含义分别是--model_dir指定包含推理模型的目录--valid_targetsarm说明最终部署目标为 ARM 架构iOS 移动端就选这个--optimize_out是输出文件前缀运行后会得到ocr_det.nb--optimize_out_typeprotobuf控制缓存格式保持默认即可。识别模型同样命令再跑一遍把model_dir和optimize_out替换成识别模型的路径。转换完的.nb文件会小很多因为 Paddle Lite 已经把算子融合、内存复用等优化做完还去掉了训练相关的节点。注意这里不要试图转成 Core ML 格式PaddleOCR 的模型输出后处理是一套 C 逻辑转成 Core ML 后要么丢算子要么还得在 Swift 里重写后处理得不偿失。我身边有人试过最后又改回 Paddle Lite 路线。3.2 Xcode 链接静态库和头文件配置接下来把.a和三个.cpp文件拖进工程。.cpp直接加入目标但编译时要注意 C 标准库匹配。Paddle Lite 需要 libc所以在 Build Settings 的Other Linker Flags里加上-lc并把C Standard Library设为libc。如果之前项目用的是libstdc这里必须改掉否则会出现各种operator new找不到的 undefined symbol。还要注意头文件路径。Paddle Lite 的头文件目录中通常有paddle_api_light.h和paddle_use_kernels.h在 Xcode 的Header Search Paths中指向该目录。paddle_use_kernels.h里有一堆宏比如USE_LITE_KERNEL它的作用是告诉静态库要链接哪些算子是 Paddle Lite 裁剪模型的重要手段。默认情况下这一行需要保留#include paddle_use_kernels.h如果不 include 这个头未来运行时会直接报kernel not found—— 这不是崩溃而是在 Paddle Lite 初始化阶段的打印模型能加载但算子查不到推理返回空结果。下面是一个常用的 Build Settings 对照表直接照着配置就能规避大部分链接错误Key值作用Other Linker Flags-lc链接 C 标准库C Standard Librarylibc使用 LLVM 的 C 标准库Header Search Paths$(PROJECT_DIR)/PaddleLite/include找到paddle_api_light.hEnable C ExceptionsNOPaddle Lite 编译时默认关闭异常Enable RTTINO关闭运行时类型信息减小包体积Strip Linked ProductYES静态裁剪产物减小 .a 链接大小有点反直觉的是Enable C Exceptions和Enable RTTI都要关掉因为libpaddle_api_light_bundled.a本身是不带异常和 RTTI 的。如果你的其他第三方库开了异常也没关系只要对 Paddle Lite 的编译单元关闭即可否则链接会报异常相关的符号缺失。3.3 模型文件的管理与拷贝模型文件放进工程后不能直接给 C 代码使用因为 Bundle 目录是只读的。Paddle Lite 的MobileConfig::set_model_from_file需要的是可读的文件路径。常见做法是在 App 启动时把.nb复制到NSTemporaryDirectory或 Application Support 目录再传给 C 层。复制代码在 Objective-C 里做比较简单NSString *src [[NSBundle mainBundle] pathForResource:ocr_det ofType:nb]; NSString *dst [NSTemporaryDirectory() stringByAppendingPathComponent:ocr_det.nb]; if (![[NSFileManager defaultManager] fileExistsAtPath:dst]) { [[NSFileManager defaultManager] copyItemAtPath:src toPath:dst error:nil]; }这样后面 C 层直接拿到[dst UTF8String]作为模型路径即可。每次都判断文件是否已存在防止重复复制浪费 I/O。如果模型放在主 Bundle 之外比如首次启动后从服务器下载同样处理路径问题但记得下载校验 MD5防止模型损坏导致无法加载。4. 用 C API 实现文字检测与识别现在工程能编译了进入核心实现。我们要写的是 Swift 和 C 的桥接层或者在 Objective-C 中直接调用 Paddle Lite 的 C API。这里以 C 类为例因为 PaddleOCR 的 demo 本身就是 C 写的直接用最省事。4.1 初始化两个 Predictor检测和识别是独立的两个模型需要分别加载。每个PaddlePredictor都是一个完整的推理单元包含自己的输入输出 tensor 和内存。初始化代码如下#include paddle_api_light.h #include paddle_use_kernels.h using namespace paddle::lite_api; std::shared_ptrPaddlePredictor create_predictor(const std::string model_path) { MobileConfig config; config.set_model_from_file(model_path); config.set_threads(2); // 控制 CPU 线程数 config.set_power_mode(LITE_POWER_HIGH); // 使用高性能模式 return CreatePaddlePredictorMobileConfig(config); }MobileConfig是移动端专用的配置结构它比TinyPublishConfig更常见因为不需要设置模型目录直接给文件路径。set_threads设置的是 OpenMP 和线程池的核数一般 2 到 4 就够设置太多反而因为线程切换增加延迟。LITE_POWER_HIGH在高通、麒麟这种 SoC 上会尝试调用大小核调度让 CPU 处于高频率如果是耗电敏感的场景可以换成LITE_POWER_LOW但推理时间通常会上升 30% 左右。这里有一个容易踩的坑两个模型不能共用一个 predictor但可以在同一个进程内同时存在。因为 Paddle Lite 的 predictor 内部有状态而且不同模型的 tensor shape 不一样混用会造成 tensor 维度错乱。我在第一次集成时图省事用一个 predictor 先后加载两个模型结果检测正常识别全部乱码后来才发现是 reuse 导致的。4.2 图像预处理从 UIImage 到输入 Tensor预处理是最影响识别率的环节。检测模型的输入尺寸通常是 640x640识别模型的高度固定为 32宽度按比例缩放。两者都采用 BGR 通道顺序并在送入前做归一化。以检测模型为例需要先把图像 resize 到 640x640然后填充到输入 tensorvoid fill_tensor(const unsigned char* rgba_data, float* dst, int width, int height, float mean0, float mean1, float mean2, float std0, float std1, float std2) { const float mean[] {0.485f, 0.456f, 0.406f}; const float std[] {0.229f, 0.224f, 0.225f}; int size width * height; for (int i 0; i size; i) { float r rgba_data[i * 4 0] / 255.0f; float g rgba_data[i * 4 1] / 255.0f; float b rgba_data[i * 4 2] / 255.0f; dst[i * 3 0] (b - mean0) / std0; // BGR dst[i * 3 1] (g - mean1) / std1; dst[i * 3 2] (r - mean2) / std2; } }这段代码有两个细节第一rgba_data是 UIImage 转成的 RGBA 字节流但 PaddleOCR 模型要求输入是 BGR所以赋值顺序是b, g, r。如果顺序反了模型检测出的区域会整体偏移且识别结果乱码非常隐蔽。第二归一化使用 ImageNet 的均值方差[0.485,0.456,0.406]这是 PaddleOCR 预处理脚本里的默认值不是随便猜的。如果你拿自己的数据集重新训练过模型这里应替换成训练时的均值方差。输入 tensor 的 shape 需要和模型一致检测模型固定用{1, 3, 640, 640}。识别模型则是{1, 3, 32, width}其中宽度是可变的但为了效率我一般直接 resize 到{1, 3, 32, 320}虽然有点浪费算力但避免了动态 shape 的额外处理逻辑。在真正做 resize 时我建议用 vImage 而不是 Core Graphics因为 Core Graphics 在连续多次调用时会引入额外的色彩空间转换耗时不小。vImage 的缩放更底层且能保持字节顺序vImage_Buffer src {rgbaData, height, width, bytesPerRow}; vImage_Buffer dst {resizedData, 640, 640, 640 * 4}; vImageScale_ARGB8888(src, dst, NULL, kvImageHighQualityResampling);这里dst行的对齐必须是 16 字节倍数640 * 4 2560正好是 16 的倍数所以不会有坑。如果宽度不是 16 的倍数需要手动调整bytesPerRow否则 vImage 会返回kvImageInvalidParameter。4.3 执行检测并在原图上定位文本框检测步骤是填充 tensor调用 predictor-Run()再从输出 tensor 里取概率图。概率图的 shape 通常是[1, 1, 640, 640]我们需要将它映射回原图尺寸并交给ocr_db_post_process.cpp中的后处理函数。// 假设 predictor 已经初始化 auto input_tensor predictor-GetInput(0); input_tensor-Resize({1, 3, 640, 640}); fill_tensor(rgba_data, input_tensor-mutable_datafloat(), 640, 640); predictor-Run(); auto output_tensor predictor-GetOutput(0); const float* score_map output_tensor-mutable_datafloat(); // score_map 这里是 640x640 的网格需要转成 vector 交给 db_post_process std::vectorstd::vectorfloat map(640, std::vectorfloat(640)); for (int y 0; y 640; y) { memcpy(map[y][0], score_map y * 640, 640 * sizeof(float)); }拿到score_map后根据原图的宽高比例用阈值比如 0.3过滤低分像素再用DBPostProcess类生成候选框。这个阈值膨胀系数box_thresh在实际项目中非常敏感设置为 0.3 可以兼顾召回率但也意味着会有一些虚框设 0.6 则精准但可能漏掉浅色印刷体。我通常让用户在 UI 上提供两档内部设置 0.3 和 0.55。DBPostProcess的构造函数里还有两个参数值得关注unclip_ratio默认 1.5表示对候选框进行膨胀因为检测网络给出的四边形通常比实际文字区域略小膨胀可以保证后面的识别模型能截取到完整的字符边缘thresh是二值化阈值一般不需要动。如果发现某些行被切掉一半可以调大unclip_ratio。4.4 裁剪字符区域并走识别模型检测得到的是四边形顶点需要先用ocr_clipper.cpp里的多边形裁剪函数把感兴趣区域从原始 UIImage 中扣出来。这里要注意检测输出的坐标是按原图尺寸不是按 640x640所以需要乘回缩放比例。否则裁剪出来的是变形图像。裁剪完成后把图像直接缩放成识别模型需要的高度 32宽度按比例缩放后填充到第二个 predictor 的输入 tensor。识别模型的输出是一个序列概率调用ocr_crnn_process.cpp中的解码函数即可得到文字。std::string recognize_crop(const UIImage* cropImg) { // 转 RGBA 和 resize 到 32 高 // ... auto input rec_predictor-GetInput(0); input-Resize({1, 3, 32, crop_width}); // fill ... rec_predictor-Run(); auto out rec_predictor-GetOutput(0); return ctc_decode(out-mutable_datafloat(), out-shape()); }这里的ctc_decode是ocr_crnn_process.cpp里的实现它会先按概率选出每个时间步的 argmax再做相邻去重和去除空白字符。有些版本还支持 beam search但移动端 CPU 上性能差距不大默认贪心解码就够。这一章里两个模型的输入输出 shape 也要心里有数模型输入 shape输出 shape文本检测[1,3,640,640][1,1,640,640]概率图文字识别[1,3,32,width][1, sequence_len, 字典大小]识别模型的width可以是动态的但 Paddle Lite 在处理动态 shape 时会做一遍显式 reshape耗时比固定 shape 要慢不少。所以工程里的常见做法是固定 width 为 320多余部分填 0。5. 真机调试中的性能优化与几个绕不开的坑最后收在这类部署里最常见的三个问题上都是能直接抄的结论。5.1 用单例管理 predictor避免重复加载OCR 模型体积大加载和初始化耗时约 500ms 到 1s。如果每次拍照都重新创建 predictor用户会明显感觉到卡顿。我一般用dispatch_once创建两个单例对象分别持有检测和识别 predictor。需要注意 iOS 的内存警告Paddle Lite 的 tensor 内存是 C 侧分配的不会自动纳入 ARC 管理所以不要在单例里持有 UIImage 等大对象识别完立刻释放 UIImage避免内存峰值。5.2 链接报错时先查依赖库如果编译出现 Undefined symbols:_cblas_sgemm不要怀疑你的代码是缺少 Accelerate 框架。在 Link Binary With Libraries 里添加Accelerate.framework即可。同样如果报cv::Mat相关其实不是真的用到了 OpenCV而是后处理文件里有#include opencv2/opencv.hpp但没有实际依赖删除这行即可。还有一种情况是.cpp文件用了 C17 的语法而 Xcode 默认是 C11把C Language Dialect改为C17就好。5.3 验证耗时与优化方向用CFAbsoluteTimeGetCurrent()包住predictor-Run()只需要统计这一行。在我的实测中iPhone 13 上检测约 120ms识别约 80ms固定 width320。如果希望压到 60ms可以先量化模型Paddle Lite 的 opt 工具支持用--quant_model来做量化但需要注意量化后识别精度会略降对印刷体影响不大对复杂背景的文字可能掉点。也可以把set_threads调成 4但耗电会更明显需要权衡。把时间统计埋点放在predictor-Run()前后这个耗时才是真实推理耗时不要直接拿整个识别流程测那样会把图像转换和 copy 路径的耗时混进去。本文还有配套的精品资源点击获取