简介:这是一份面向iOS开发者的Paddle OCR移动端集成资源,解决在iPhone/iPad上实现离线文字识别的需求,适用于扫描文档、车牌识别、图片取字、卡证信息提取等场景,也适合需要快速接入OCR能力的中高级开发者。包内共1107个文件,以Objective-C/C++头文件与源码(h/m/hpp)为主,同时包含xcconfig、plist、podfile、storyboard等Xcode工程配置,以及核心静态库libpaddle_api_light_bundled.a和C++后处理源码(ocr_clipper、ocr_db_post_process、ocr_crnn_process),压缩包整体约151.85MB。目前已有808人学习下载。借助这份资源,无需从零搭建模型转换链路,可直接在现有Xcode工程中集成PaddleOCR轻量级模型,参考其调用代码完成图像预处理、文字检测与识别、中英文混合识别等功能。资源还配套模型量化、异步推理等性能优化思路,便于在移动端算力受限环境下平衡速度与精度,适合作为快速落地OCR能力并开展二次开发的实用素材。
1. 在 iOS 上落地 Paddle OCR:移动端文字识别先过选型这关
做 iOS 端 Paddle OCR 的人,大多不是来炫技的,而是手里有一批必须在本地识别的小票、合同和设备铭牌。移动端文字识别听起来像调一个 SDK,真到真机稳定跑出来,模型选型、格式转换、内存控制、线程调度都是关卡。PaddleOCR 是中文识别、模型体积、推理速度之间平衡得比较好的开源方案,还能完全离线跑,适合车间、仓库这类现场业务,也适合隐私数据不便出本机的场景。
这里按我的落地路径拆三段:为什么默认走 Paddle Lite、模型怎么转成 iOS 能加载的 .nb 文件、嵌入后怎么排高频坑。对想在 App 里塞进一套可用离线 OCR 的 iOS 开发者来说,顺着这条线走,比在搜索引擎里拼碎片教程省得多。
后面涉及的参数都是 PP-OCR 常用的一组,改之前先想清楚自己是在调精度还是调速度。
2. 先选型再写代码:iOS 上跑 Paddle OCR 的三条路线,为什么默认走 Paddle Lite
PaddleOCR 训练出来的产物是 PaddlePaddle 推理格式,不会直接变成 iOS 可用的 framework。移动端集成时,你可以选三条路:Paddle Lite 原生推理、把模型转成 Core ML、或者只保留推理核心自己重写后处理。我默认走 Paddle Lite,不是因为它新,而是它把 iOS 上的版本坑、算子坑、后处理坑压到了最低。
2.1 三条路线对比:精度、工作量和维护成本
| 路线 | 要做什么 | 精度影响 | 维护成本 |
|---|---|---|---|
| Paddle Lite 原生 | 模型转.nb,沿用官方 det/rec/cls 后处理 | 基本无损 | 低,锁版本即可 |
| Core ML 转换 | Paddle → ONNX → mlmodel,重写前后处理 | 动态 shape 和算子兼容性会损失精度 | 高,模型每更新一版就要重转回归 |
| C++ 自研后处理 | 只复用推理,DB 后处理、CTC 解码全自己搭 | 取决于复刻质量 | 最高,不建议 |
Core ML 路线听起来最“苹果”,实际坑最多。PP-OCR 的检测头用的是可微二值化(DB),识别头里还带 LSTM/CTC,这些算子导成 ONNX 再转 Core ML 时,经常遇到 unsupported op。另一个麻烦是输入尺寸:det 模型希望输入能跟着图片长宽比走,Core ML 对动态 shape 支持又弱,常见做法是把输入固化成 960×960,遇到超长票据或窄长条文字,识别效果立刻掉一截。
Paddle Lite 原生示例里已经写好了 det、cls、rec 的串联和后处理,你要做的只是把模型转成 .nb 塞进工程,再把图片输入、线程调度、结果封装成自己 App 的模型。维护成本集中在版本匹配上——Paddle Lite 的更新节奏不算快,别追新,选定一个能跑通的版本之后就锁死。
2.2 拿到手之后仍需自己补的三块:输入图、线程、结果排序
官方 demo 能跑通,但搬进自己 App 时有三块代码躲不掉。
第一块是 UIImage 转 cv::Mat。PaddleOCR 的输入是 BGR 三通道图,iOS 上的 UIImage 可能是 RGBA、灰度,还带 EXIF 旋转方向。直接拿CGImage转 Mat,经常会得到一张躺着的图,识别出来的坐标和预览对不上。这块要在转 Mat 之前先把 EXIF 方向修正掉,后面第 4 章会给代码。
第二块是线程模型。OCR 推理不能放主线程,Paddle Lite predictor 也不是并发安全的。常见做法是维护一条串行dispatch_queue,所有识别请求排队执行,避免 det 和 rec 同时在多个线程上跑,把 CPU 打满导致系统杀进程。识别完成后的回调要切回主线程更新 UI。
第三块是结果排序。PaddleOCR 返回的是一段段文字框,不是整段段落,而且模型输出的顺序不代表阅读顺序。iOS 业务里用户期望从上到下、从左到右,你得按文本框中心点的 y 和 x 重新排序,再把间距相近的相邻行合并成段落。这块不做,识别率再高,展示层的体验也是乱的。
2.3 端侧模型全家桶:det、rec、cls 和字典文件
真正要打包进 App 的模型有三个,再加上一个字典文件,分工如下:
| 文件 | 作用 | 体积量级 |
|---|---|---|
| det 模型 | 找出图中所有文字区域,输出四边形框 | 2~5 MB |
| rec 模型 | 把框内图片识别成字符序列 | 3~8 MB |
| cls 模型 | 判断文字是否旋转 180°,识别前先纠正方向 | 不足 1 MB |
| ppocr_keys_v1.txt | rec 解码用的中文字典,每行一个字符 | 几十 KB |
这个字典文件很容易被忽略。rec 模型输出的是字符类别概率,要通过字典才能映射回中文;字典顺序必须和模型训练时一致。下载模型时不要拆着混用,比如 det 用 v3、rec 用 v4,或者中英混合模型配上纯中文的字典,都会导致识别错乱。
在 Xcode 工程里,我习惯把三个 .nb 和字典放进独立的ocr目录,用 Copy Bundle Resources 打进 main bundle。目录大致长这样:
App/ ocr/ ch_PP-OCRv4_det_infer.nb ch_PP-OCRv4_rec_infer.nb ch_PP-OCRv4_cls_infer.nb ppocr_keys_v1.txt这里出现的.nb是 Paddle Lite 优化后的模型文件,不能直接把 release 包里的inference.pdmodel拖进工程,否则加载时根本认不出来。把模型转成.nb,就是下一章要解决的事。
3. 模型转换:把 Paddle OCR 的 inference.pdmodel 变成 iOS 能加载的 .nb 文件
从官方 release 下载的模型是 PaddlePaddle 推理格式,iOS 端加载 Paddle Lite 优化后的 .nb 文件,体积更小、加载更快。转换工具是paddle_lite_opt,使用上有一条铁律:工具版本和 App 里集成的 Paddle Lite framework 必须同源。版本错配是最玄学的一种崩溃,后面避坑章会专门说。
3.1 下载解压后先确认文件结构
先下载三个模型对应的.tar.gz,解压后看目录里到底是什么文件:
tar -xzf ch_PP-OCRv4_det_infer.tar.gz find ch_PP-OCRv4_det_infer -maxdepth 1 -type f旧版模型解压后通常是model和params两个文件,新版是inference.pdmodel和inference.pdiparams。无论哪种,paddle_lite_opt接收目录路径即可,它会自动识别目录里的结构。
这里有个常见错误:只把inference.pdmodel单独拖出来给转换工具,结果报“找不到权重”。正确做法是保留完整目录,三个模型分别解压成三个目录,再逐个转换。如果解压后目录里没有权重文件,多半是下载时被平台识别成普通文件截断了,重新下载即可。
3.2 一条命令完成三个模型:paddle_lite_opt 参数说明
转换命令本身不复杂,我在脚本里循环处理三个模型,避免参数漏写:
for name in ch_PP-OCRv4_det ch_PP-OCRv4_rec ch_PP-OCRv4_cls; do ./paddle_lite_opt \ --model_dir=./${name}_infer \ --valid_targets=arm \ --optimize_out=./${name} \ --optimize_out_type=naive_buffer done跑完会在当前目录生成ch_PP-OCRv4_det.nb、ch_PP-OCRv4_rec.nb、ch_PP-OCRv4_cls.nb三个文件。注意--optimize_out只写前缀,不写.nb后缀,工具会自动拼上。
参数里最关键的是--valid_targets=arm,它告诉工具生成 ARM CPU 版本,不要顺手加 x86;真机只需要 arm 版本。--optimize_out_type=naive_buffer会把模型权重固化成 buffer 格式,iOS 端加载速度和内存占用都更友好。
另一个容易卡住的是 rec 模型的动态 shape。部分paddle_lite_opt版本遇到 rec 的变宽输入会报维度错误,此时需要固定一个输入宽度:
./paddle_lite_opt \ --model_dir=./ch_PP-OCRv4_rec_infer \ --valid_targets=arm \ --input_shape="1,3,32,320" \ --optimize_out=./ch_PP-OCRv4_rec \ --optimize_out_type=naive_buffer固定 320 宽意味着识别前要先把文字框内的图片等比缩放到高 32、宽不超过 320;宽度超过 320 的长句子会被压缩,导致尾部掉字。如果转换工具支持动态 shape,就不要加--input_shape,让模型保留原始变宽能力,长票据识别会稳很多。
3.3 转换完别急着拖进工程,先做三个验证
第一,看文件大小:
ls -lh ch_PP-OCRv4_*.nb三个 .nb 应该都有几 MB 的量级,和原模型相当。如果某个文件只有几百 KB,多半是转换时没读到权重,回头检查--model_dir路径。
第二,用官方 iOS demo 做冒烟测试:把三个 .nb 替换进去,跑一张清晰印刷体图片,能整句输出中文才算过。如果 det 能出框、rec 返回空,先检查 rec 模型对应的字典有没有一起放进 bundle。
第三,记录转换工具版本和模型日期。.nb内部结构随着 Paddle Lite 版本演化,代码和模型一起提交到 git,比口头约定可靠得多。
提示:转换工具版本必须和 App 里的 Paddle Lite framework 同版本。我因为版本不一致翻过车,现象是 det 能跑、rec 一调用就崩,排了半天才发现是工具比 framework 新了两代。
三个模型都转换完成后,ch_PP-OCRv4_det_infer.nb这类带_infer的目录就可以删了,iOS 工程里只留.nb和字典文件。
4. 把 OCR 引擎嵌入 iOS App:初始化、后台线程与结果排序
模型准备好之后,下一步是写代码。常见做法是用 Objective-C++ 包一层桥接,Swift 端只暴露一个“传 UIImage、回结果数组”的接口。这样做的好处是 Swift 端完全不需要接触 C++ 和 OpenCV 类型,后续替换模型或参数也不影响 UI 层。
4.1 用 C++ 封装三个 predictor:初始化参数不能只抄默认
先看头文件,把结果结构和引擎接口定义清楚:
// OCREngine.hpp #pragma once #include "paddle_api.h" #include <opencv2/core.hpp> #include <string> #include <vector> struct OCRResult { std::string text; float confidence; std::vector<cv::Point> box; // 四个点,顺时针 }; class OCREngine { public: OCREngine(const std::string &model_dir, int threads, const std::string &dict_path); ~OCREngine(); std::vector<OCRResult> run(const cv::Mat &bgr); void setDetParams(float db_thresh, float db_box_thresh, float db_unclip_ratio); void setClsThresh(float cls_thresh); void setRecThresh(float rec_thresh); private: std::shared_ptr<paddle::lite_api::PaddlePredictor> det_; std::shared_ptr<paddle::lite_api::PaddlePredictor> rec_; std::shared_ptr<paddle::lite_api::PaddlePredictor> cls_; std::string dict_path_; float det_db_thresh_ = 0.3f; float det_db_box_thresh_ = 0.6f; float det_db_unclip_ratio_ = 1.5f; float cls_thresh_ = 0.9f; float rec_thresh_ = 0.5f; };实现文件里,关键是初始化三个 predictor。Paddle Lite 的 MobileConfig 一次只能挂一个模型,所以必须建三个 predictor,分别加载 det、rec、cls:
// OCREngine.cpp using namespace paddle::lite_api; OCREngine::OCREngine(const std::string &model_dir, int threads, const std::string &dict_path) : dict_path_(dict_path) { auto load = [&](const std::string &name) { MobileConfig config; config.set_model_from_file(model_dir + "/" + name + ".nb"); config.set_threads(threads); return CreatePaddlePredictor<MobileConfig>(config); }; det_ = load("ch_PP-OCRv4_det_infer"); rec_ = load("ch_PP-OCRv4_rec_infer"); cls_ = load("ch_PP-OCRv4_cls_infer"); }threads参数建议先用 4。真机 CPU 并不总是线程越多越快,A 系列芯片的性能核和能效核混跑时,6 线程反而可能出现任务颠簸。上线前用真机对比 4 线程和 6 线程的耗时,再决定要不要调。
默认参数里,det_db_thresh控制二值化阈值,调高能减少误检测,但可能漏掉浅色字;det_db_unclip_ratio控制文本区域的扩张比例,调大能让框包得更完整,但相邻文字挨太近时容易合并成一段。这组默认值是从 PP-OCR 官方配置带出来的,在大多数实拍场景下表现稳定,先跑通再调。
4.2 把 UIImage 转成 cv::Mat,再串起 det 到 cls 到 rec
桥接层的关键代码在 Objective-C++ 里,图片转换和推理必须放到后台串行队列:
- (void)recognizeImage:(UIImage *)image completion:(void (^)(NSArray<NSDictionary *> *_Nullable, NSError *_Nullable))completion { dispatch_async(self.ocrQueue, ^{ UIImage *fixed = [self fixExifOrientation:image]; cv::Mat rgba; UIImageToMat(fixed, rgba, true); // opencv2/imgcodecs/ios.h cv::Mat bgr; cv::cvtColor(rgba, bgr, cv::COLOR_RGBA2BGR); std::vector<OCRResult> raw = self->_engine->run(bgr); NSArray *items = [self convertResults:raw]; dispatch_async(dispatch_get_main_queue(), ^{ completion(items, nil); }); }); }ocrQueue必须是串行队列,我是用dispatch_queue_create("ocr.serial", DISPATCH_QUEUE_SERIAL)创建的。PaddleOCR 的内部处理不是并发安全的,两个请求同时进来,轻则内存上涨,重则直接 crash。
UIImageToMat带true参数会生成 RGBA 四通道矩阵,PaddleOCR 要的是 BGR 三通道,所以紧接着必须cvtColor。漏掉这一步,输入通道顺序不对,识别结果会明显变差,但又不至于全崩,很容易误判成模型问题。
run方法内部的标准顺序是:det 先跑,得到所有文字框;每个框从原图裁剪下来,先过 cls 判断是否需要旋转 180 度,再过 rec 输出字符序列;最后用rec_thresh过滤掉低置信度结果。这个顺序不要改,先把裁剪图直接送 rec 是常见误操作。
4.3 Swift 桥接与结果排序:别把 C++ 对象直接暴露出去
ObjC++ 桥接头文件可以这样设计:
// OcrEngineBridge.h typedef void (^OcrCompletion)(NSArray<NSDictionary *> *_Nullable results, NSError *_Nullable error); @interface OcrEngineBridge : NSObject - (instancetype)initWithModelDir:(NSString *)modelDir threads:(int)threads; - (void)recognizeImage:(UIImage *)image completion:(OcrCompletion)completion; @endSwift 端只和这个类打交道:
final class OCRService { private let bridge: OcrEngineBridge init(modelDir: String) { self.bridge = OcrEngineBridge(modelDir: modelDir, threads: 4) } func recognize(_ image: UIImage, completion: @escaping ([TextBlock]) -> Void) { bridge.recognizeImage(image) { jsonList, error in guard error == nil, let jsonList = jsonList else { return } let blocks = jsonList.compactMap { TextBlock(json: $0) } completion(blocks.sorted { $0.sortKey < $1.sortKey }) } } }排序不是可选项。PaddleOCR 返回的框顺序和识别顺序都由模型内部决定,直接展示会出现后一行在前、右半页先出来的问题。我常用的排序键是:
let lineKey = Int(point.minY / 20.0) let sortKey = lineKey * 100_000 + Int(point.minX)20是一个经验行高,表示两个框的中心 y 坐标差在 20 像素以内时认为它们在同一行。真实业务里要根据界面字号和拍摄距离调整,更稳的做法是先算所有框的平均高度,用平均高度的 0.6 倍作为归行阈值。
5. 避坑笔记:iOS 上跑 Paddle OCR 的 5 个卡点,从崩溃到错识别
这一章把我在移动端文字识别落地过程中遇到的高频坑,按“现象 → 原因 → 解决”写清楚。前两个是崩溃和内存,后三个是识别质量,越往后越隐蔽。
5.1 模型版本和 Paddle Lite framework 版本不一致,一启动就崩
现象:初始化 det predictor 时连日志都没有,直接 SIGABRT 或者 EXC_BAD_ACCESS,堆栈停在paddle_api内部。
原因:.nb文件内部结构跟着 Paddle Lite 版本走,你用 3.x 的工具转换模型,App 里集成的却是 2.9 的 framework,或者反过来,都会崩。网上很多教程里的 framework 是几个月前 fork 出来的,而模型是从最新 release 下载的,组合起来就翻车。
解决:锁版本。选定一个官方 demo 能整体跑通的 Paddle Lite framework 版本,去拿同版本paddle_lite_opt转模型。转换完先拿官方 demo 验证,再搬进业务工程。我的习惯是把 framework 二进制直接提交进 git,避免 CI 或同事用包管理器拉到新版本后悄然错配。
5.2 原图直接送识别,iPhone 8 内存瞬间吃掉 600MB
现象:拍完一张 4000×3000 的照片,识别过程中 App 内存由 80MB 冲到 600MB,甚至被系统 jetsam 杀掉。
原因:原图转成 RGBA 四通道 Mat 已经有 48MB,det 内部还要做缩放,后处理的二值图、框扩张图、仿射变换裁剪图都会叠加内存;如果 UI 层和 imageIO 也同时操作这张大图,峰值很容易到 500MB 以上。
解决:进 OCR 之前先把图片最长边压到 2000 像素内,控制在内存和清晰度的平衡点。det 的limit_side_len保持 960,rec 仍然按检测框在原图上裁剪,裁剪图分辨率本身不会太大。另外全局只放一条串行识别队列,不要多个按钮同时触发识别。
5.3 中文全乱码,识别结果跑出一串英文和符号
现象:中文文本识别出来是一串英文、拼音或者生僻符号,但英文数字识别基本正常。
原因:rec 模型和字典不配套。PaddleOCR 的 rec 输出是字符类别概率,要靠字典做解码映射。如果 rec 模型是中文模型、字典却用了英文小字符集,或者字典文件被替换、截断,解码结果自然全乱。det 用 v3、rec 用 v4 这类跨代混用,也会因为预处理逻辑不同导致输入对不上,表面看是识别率低,实际是链路配错。
解决:中文模型配官方中文ppocr_keys_v1.txt,不要从网上随便找“改进字典”。检查方法是把同一张图在电脑上用 PaddleOCR Python 端跑一次,Python 正常而 iOS 乱码,问题在 iOS 端字典加载或解码映射;两端都乱,说明模型和字典本身就是错配。
5.4 图片方向不对但 OCR 又没全错,容易误杀 cls 模型
现象:拍照预览是正的,识别结果却有几行反了,竖排文字表现尤其明显,阅读顺序变成从下往上。
原因:UIImage 的imageOrientation存了 EXIF 方向,UIKit 显示时会自动转正,但你通过CGImage直接取像素转 Mat 时,EXIF 方向没有生效。问题不在 cls 模型,而在输入图本身。cls 只能判断文本主体是不是倒的,管不了相机传感器把图存成旋转 90 度这件事。
解决:在调用UIImageToMat之前,用 UIGraphics 重绘一次,把imageOrientation归一化为.up,并写一个fixExifOrientation:工具函数统一处理。如果你的业务是扫码或长曝光连拍,可能需要保留拍摄方向,那就在图片采集层直接固定方向,不要在 OCR 层做二次猜测。
5.5 首帧耗时 3 秒以上,之后回落:本地识别也要预热
现象:App 冷启动后第一次识别特别慢,第二张开始正常;同一个模型在官方 demo 里却始终很快。
原因:三个.nb文件打进 main bundle 后,首次加载要做大量磁盘 I/O 和算子初始化。如果工程里用了动态下载模型,还得先把文件从 bundle 或网络缓存复制到可写目录,复制时间会被误算进首帧耗时。
解决:App 启动后、用户真正拍照前,先创建OCREngine完成三个 predictor 的初始化。预热时不必真的跑一张图,只要构造函数执行完,模型文件已被映射进内存,后续首帧就会快很多。加载时直接用set_model_from_file读 main bundle 路径,不要先复制到 Documents 再读,省掉一次无谓的耗时。
6. 上线前最后一步:把耗时和阈值调成可以对外承诺的数值
文档和默认参数只保证“能跑”,上线前要自己定一套基线。先把耗测量量准,再按业务场景调参数,最后把整个版本组合记录在案。
6.1 用最小改动拿到单帧耗时基线
临时识别接口里加一行时间统计,是最快的定位方式:
CFAbsoluteTime start = CFAbsoluteTimeGetCurrent(); NSArray *res = [self recognizeSync:image]; NSLog(@"ocr cost %.1f ms", (CFAbsoluteTimeGetCurrent() - start) * 1000);注意三个前提:真机 Release 配置测,Debug 编译优化会影响耗时;同设备连续测 10 张取中位数,不要只看最好成绩;同一张测试图可以复用,保证对比口径一致。中端 iPhone 上 CPU 单帧 100ms 到 400ms 都算正常,文本行数越多,rec 跑的次数越多。如果超过 500ms,先看 det 是不是框出了一堆无效区域,而不是急着换 GPU。
6.2 三个参数组合:召回优先还是精度优先
我的常用组合如下,按业务场景选:
| 场景 | det_db_box_thresh | det_db_unclip_ratio | rec_thresh |
|---|---|---|---|
| 尽量别漏字 | 0.45 | 1.8 | 0.3 |
| 干净单据 | 0.6 | 1.5 | 0.5 |
| 强干扰环境 | 0.7 | 1.2 | 0.6 |
调参后一定要跑一批固定回归图,至少 30 张真实业务图,人工标注期望结果,再统计漏识率和错误率。OCR 这类模块非常像黑匣子,最怕的是三个月后线上反馈变差,而你不知道当时跑的是哪个模型、哪组参数、哪个 framework 版本。每次发布把“模型 + framework + 参数 + 测试机型”记成一个条目,相当于给自己留一颗后悔药,出现问题能快速回退到上一个正常版本。
这套流程走到这里,你已经能从模型转换一路跑到真机出结果。之后再做业务定制,不管是拍照自动识别、相册批量识别,还是接 iOS 自动化测试,底层引擎都不用再动。希望帮到你。
本文还有配套的精品资源,点击获取