简介:面向iOS开发者的Paddle OCR移动端文字识别完整工程包。Paddle OCR是基于深度学习的高精度轻量级OCR框架,这份资源提供了在iOS平台部署所需的全部代码与配置,适合需要快速实现扫描文档、图片文字提取等功能的开发者。压缩包共1107个文件,其中576个h、195个m、192个hpp构成主要源代码层,附带37个png图标资源、16个xcconfig及plist配置文件、13个json等,并已包含编译好的libpaddle_api_light_bundled.a静态库,可大幅简化集成过程。压缩包总大小151.85MB,目录结构清晰,涵盖从OCR前处理、文本检测到文字识别的完整流程,其中ocr_clipper.cpp、ocr_db_post_process.cpp、ocr_crnn_process.cpp等实现文件可直接参考,整体代码层次分明,便于按需修改和扩展,已有808人浏览学习。通过这份资源,开发者可以省去从零搭建与模型转换的繁琐工作,快速获得一套可运行的iOS OCR方案,并参考源码中的调用逻辑进行定制优化,实现中英文、多场景的文字识别能力。
1. iOS 端 PaddleOCR:静态库加三个 cpp,离线文字识别的最小工程
当产品把“扫文档自动出文字”塞进需求,大多数团队第一反应是接云端 OCR。但在医疗、合同、发票这些场景里,图片要留在本地,网络还可能断。这时候,iOS 端 PaddleOCR 移动端文字识别就很适合试:一个 libpaddle_api_light_bundled.a 静态库,配 ocr_clipper.cpp、ocr_db_post_process.cpp、ocr_crnn_process.cpp 三个后处理文件,就能把文字检测、识别和 CTC 解码跑通,完全离线。
这三个 cpp 不是摆设,它们是 PaddleOCR iOS Demo 的主力:静态库负责加载模型和推理,cpp 负责把模型输出变成能看的文本。下面按模型转换、Xcode 集成、避坑、调优、验证的顺序讲。适合第一次接 OCR 的原生开发,也适合想把预算和隐私从云上收回来的团队。我默认你用 UIKit,SwiftUI 也差不离。
2. 先拆角色:Paddle Lite 静态库、.nb 模型和 DB+CRNN 这条管线
动手之前先把链路讲清楚。PaddleOCR 不是一个“拖一个 framework 进来就能用”的黑盒,它分成训练侧和部署侧,咱们 iOS 端用到的是部署侧的 Paddle Lite。要接得稳,得先搞清楚手里的四个文件各管哪一段,不然配置表和代码对不上,排错会非常痛苦。
2.1 模型选型:为什么转 .nb 给 Paddle Lite,而不是 Core ML 或 TFLite
PaddleOCR 训练产出的静态图模型是 inference.pdmodel 和 inference.pdiparams 一对文件,iOS 端不能直接加载。常见做法是用 Paddle Lite 的 opt 工具转成 .nb(naive buffer)格式,再用 libpaddle_api_light_bundled.a 里的 PaddlePredictor 去加载。有些教程会笼统说“转成 .tflite 或 .coreml”,按那个思路去试,大概率会卡在算子转换上。原因很简单:OCR 是一个两段式流程,检测模型 DBNet 负责找文字区域,识别模型 CRNN 负责把区域转成字符。CRNN 里的 LSTM 和 CTC 解码算子,在 Core ML 转换器里支持得并不完整,经常转完报 unsupported op,还得手工改图或者换层。TFLite 也可以转,但 iOS 端要额外引入 TensorFlow Lite 运行时,两个推理引擎并存,包体积和内存都受影响。
Paddle Lite 在 iOS 上的标准形态就是那个 .a 静态库,官方把模型加载、前向推理和张量读写都封装成了 C++ 接口。MobileConfig 里只需要设置模型路径、线程数,就能创建一个可用的 PaddlePredictor。它的 arm 算子针对 CPU 做了专门优化,文本行量不大的场景性能是能接受的。更重要的是,检测和识别两个模型可以用同一个库同时加载,不需要搞两套依赖。
模型选型上还有一条:iOS 端尽量选 mobile 轻量系列。mobile 模型的精度比 server 模型低一点,但体积和耗时小一个量级。如果只是扫描发票、识别名片和拍照翻译,mobile 完全够用。真想用大模型,等识别率指标出来后先跑真机,你就知道发热有多可怕。
2.2 三个 cpp 的分工:多边形、概率图和 CTC 解码
三个 cpp 全是纯 C++,不依赖 UIKit,所以可以在 Xcode 里直接编译。第一个 ocr_clipper.cpp 是 Clipper 库,专门做多边形的布尔运算和偏移。OCR 场景里,DBNet 找出来的文字区域在概率图上是一堆轮廓点,直接用矩形裁可能把笔画切开,用四边形又缺角。Clipper 能把轮廓点整理、合并成多边形,再按 unclip ratio 向外扩张,得到贴合文字的外接框。没有它,检测框要么缺肉、要么乱抖。
第二个 ocr_db_post_process.cpp 是 DBNet 的后处理。DBNet 输出的是一个概率图,每个像素都是“这里是文字”的概率。该文件负责把概率图缩回原图尺寸,用阈值切出前景区域,提取连通域,再用 Clipper 把连通域整理成四边形并还原到原图坐标。你调的“检测框松紧”“漏检多”“误检多”,最终都会落在这个文件的阈值和膨胀逻辑上。
第三个 ocr_crnn_process.cpp 在识别链路里出现两次。识别前,它把文本行图缩放到固定高度 32,宽度按比例调整,做归一化,生成 CRNN 需要的张量;识别后,它把模型输出的 logits 做 CTC greedy decode,去重、去空白,再通过字符字典映射成可读文本。OCR 最容易翻车的 dict 和预处理均值/方差,都在这个文件里。如果识别结果出现乱码或整行空白,先检查它,不要动网络权重。
检测后处理和识别后处理有个本质差别:检测的输入是整张图,输出是几何坐标,所以后处理必须涉及阈值、轮廓、多边形,属于图像处理;识别的输入是文本行小图,输出是分类标签序列,后处理主要是解码和词典映射。理解这个差别,你在改参数时就不会把两个 cpp 的函数搞混。
| 文件/依赖 | 负责阶段 | 输入 | 输出 |
|---|---|---|---|
| libpaddle_api_light_bundled.a | 推理引擎 | 预处理后的 Tensor | 检测/识别模型的 logits |
| ocr_clipper.cpp | 检测后处理 | 文本区域轮廓点 | 多边形外扩 |
| ocr_db_post_process.cpp | 检测后处理 | DBNet 概率图 | 检测框列表 |
| ocr_crnn_process.cpp | 识别前后处理 | 文本行子图 | 识别文本字符串 |
2.3 数据流:一张 UIImage 怎么变成多行字符串
完整的数据流是:UIImage → cv::Mat → 检测预处理 → detPredictor → DBNet 概率图 → ocr_db_post_process → 检测框列表 → 对每个框裁剪 → 透视变换 → 识别预处理 → recPredictor → logits → ocr_crnn_process → 字符串数组 → Swift 展示。检测和识别是解耦的,好处是你单独调整检测参数不影响识别,也可以把识别模型换成多语言模型,检测端完全不动。
这里有两个新手容易懵的地方。第一,两个 predictor 要分开加载、分开保存状态,不要试图只用一个 predictor 同时跑两种模型,因为输入输出张量的 shape 和预处理完全不一样。第二,检测框的坐标要经过多次“网络输入图坐标 → 原图坐标 → 裁剪坐标”的映射,任何一个环节少做一步,就会出现“框在原图上是对的,裁出来却是歪的”这种灵异现象。后面避坑章节会专门讲坐标翻转。
如果你只看官方 demo 的文件夹,会发现这三个 cpp 其实还依赖一些第三方小工具,编译时要保证同一个 target 里它们都能被找到。Xcode 的 header search path 必须同时覆盖 paddle_api.h 所在目录和 cpp 文件引用的其他头文件,否则明明代码是完整工程,却四处报找不到头文件的诡异问题。
3. 接进 Xcode:模型转换、工程配置和最小调用代码
这一章直接抄作业。顺序是:先转模型,再配工程,最后封装一个 OCRObject 类给 Swift 调用。不要跳过转换直接拖 demo,模型路径和格式对不上会浪费更多时间。
3.1 模型转换:把 inference.pdmodel 变成 .nb
先到 PaddleOCR 的 release 里下载移动端模型,下载完你会看到 inference.pdmodel 和 inference.pdiparams 两个文件。然后使用 paddle_lite_opt 工具,命令行执行转换:
# 检测模型,生成 ocr_det.nb ./paddle_lite_opt \ --model_file=./inference.pdmodel \ --param_file=./inference.pdiparams \ --valid_targets=arm \ --optimize_out=naive_buffer \ --optimize_out=./ocr_det # 识别模型,生成 ocr_rec.nb ./paddle_lite_opt \ --model_file=./inference.pdmodel \ --param_file=./inference.pdiparams \ --valid_targets=arm \ --optimize_out=naive_buffer \ --optimize_out=./ocr_rec这里的 --valid_targets=arm 意思是只保留 ARM CPU 上的算子,给 iOS 真机用足够了;如果留了 x86 算子,生成的 .nb 会变大且模拟器也不一定兼容。--optimize_out=naive_buffer 会把权重参数直接写成缓冲区,运行时少一层反序列化,启动更快。检测和识别模型分开转,生成两个 .nb 文件,分别改名成 ocr_det.nb 和 ocr_rec.nb 放到 App 的 bundle 目录里。注意:不同版本的 opt 工具参数略有差异,如果报 “unknown option” 就敲一遍 ./paddle_lite_opt --help 看当前版本支持哪些参数。
转换之前还要确认模型是静态图导出。如果你拿到的模型是从动态图训练脚本里直接保存的参数,而没有 inference.pdmodel 这个模型结构文件,opt 是转不了的,需要先在 Python 里用 paddle 的导出接口导出成静态图。iOS 项目里我一般直接复用 PaddleOCR release 里现成的移动端 infer 模型,省掉导出步骤。另外,M 系列 Mac 和普通 Intel Mac 上跑 opt 行为略有差异,优先下载官方 release 里对应的 Mac 可执行文件,别拿 Linux 版硬跑。
3.2 Xcode 工程配置:静态库、头文件和链接选项
拿到 libpaddle_api_light_bundled.a 之后,把静态库文件和 ocr_clipper.cpp、ocr_db_post_process.cpp、ocr_crnn_process.cpp 一起拖进 Xcode 项目。在 Build Phases 的 Compile Sources 里确认这三个 cpp 都在 target 里,不然链接阶段会缺符号。然后在 Build Settings 里做两件事:第一,C++ Standard Library 选 libc++,因为静态库和 cpp 都是 libc++ 编译的;第二,把包含 paddle_api.h 的目录加进 Header Search Paths,否则 #import "paddle_api.h" 会找不到头文件。
链接方面,编译器报 “Undefined symbol” 或者和 C++ 的 __1 符号有关,在 Other Linker Flags 里加 -lc++ 基本能解决。静态库架构也是个坑,先用 lipo 看一下:
lipo -info libpaddle_api_light_bundled.a输出会列出支持的架构,比如 armv7、arm64。如果你真机调试没问题、模拟器一编译就报错,十有八九是这个静态库不包含 x86_64 slice。真机调试优先。部分预编译库还有 bitcode 兼容问题,工程里开了 Bitcode,遇到 “bitcode bundle” 报错就先把 Enable Bitcode 关掉,至少能先跑起来。
配置完成后,把 ocr_det.nb 和 ocr_rec.nb 拖进 Copy Bundle Resources,确保真机上能通过 NSBundle.mainBundle 路径访问。不把模型放进 bundle 而用沙盒 Documents 目录,会带来路径和权限两套问题。另外,官方 demo 里大量使用 OpenCV,工程里最好也准备好 opencv2.framework。如果你不想引入整个 OpenCV,也可以用 vImage 替换图像读入和缩放,但检测框裁剪、透视变换这些函数就得自己写,工作量不小。我的建议是直接用 demo 自带依赖,把 opencv2.framework 加进 Link Binary With Libraries,反正 iOS 上 OpenCV 的内存管理还算稳。
3.3 最小调用代码:封装一个 OCRObject 类
为了不让 Swift 代码落进 C++ 的泥潭,我用 Objective-C++ 写一个门面类。先建 OCRObject.h,对外只暴露“加载模型”和“识别图片”两个方法:
// OCRObject.h #import <UIKit/UIKit.h> NS_ASSUME_NONNULL_BEGIN @interface OCRObject : NSObject /// 加载指定目录下的 ocr_det.nb 与 ocr_rec.nb - (void)loadModelFromDir:(NSString *)modelDir; /// 识别图片,返回文本行数组(按从上到下排序) - (NSArray<NSString *> *)recognizeTextInImage:(UIImage *)image; @end NS_ASSUME_NONNULL_END实现文件必须改成 .mm,编译器才知道里面是 Objective-C++。核心代码是加载 Paddle Lite 的 MobileConfig:
// OCRObject.mm #import "OCRObject.h" #import "paddle_api.h" using namespace paddle::lite_api; @interface OCRObject () { std::shared_ptr<PaddlePredictor> detPredictor_; std::shared_ptr<PaddlePredictor> recPredictor_; int threadNum_; } @end @implementation OCRObject - (void)loadModelFromDir:(NSString *)modelDir { threadNum_ = 4; NSString *detPath = [modelDir stringByAppendingPathComponent:@"ocr_det.nb"]; MobileConfig detConfig; detConfig.set_model_from_file(detPath.UTF8String); detConfig.set_threads(threadNum_); detPredictor_ = CreatePaddlePredictor<MobileConfig>(detConfig); NSString *recPath = [modelDir stringByAppendingPathComponent:@"ocr_rec.nb"]; MobileConfig recConfig; recConfig.set_model_from_file(recPath.UTF8String); recConfig.set_threads(threadNum_); recPredictor_ = CreatePaddlePredictor<MobileConfig>(recConfig); } - (NSArray<NSString *> *)recognizeTextInImage:(UIImage *)image { // 1. UIImage -> cv::Mat(RGBA) // 2. 调用裁剪、DB 后处理得到检测框 // 3. 每个框裁剪成文本行,交给识别模型 // 4. 用 ocr_crnn_process.cpp 的 CTC 解码得到文本 return @[]; } @end这段代码里 set_threads 是关键参数:threadNum_ 设成 4 在真机上表现不错,模拟器上 2 就够,因为模拟器 CPU 调度和真机差很远,线程开大了反而会因为资源竞争把时间拖长。CreatePaddlePredictor 的 MobileConfig 表示 CPU 推理模式,iOS 端用这个最通用。recognizeTextInImage 里真正的预处理和后处理有几百行,建议直接参考官方 iOS demo 的三个 cpp 怎么拼,我这里只保留主干。
为什么不用 Swift 直接调 C++?因为 paddle_api.h 是 C++ 头文件,Swift 的桥接不支持直接 import C++ 符号,必须由 Objective-C++ 包一层。这正是 .mm 文件存在的意义。别把 paddle_api.h 放进 Swift 桥接头文件,Xcode 会报 Cannot import C++ module。
类写完后,在工程的桥接头文件里导入 OCRObject.h,Swift 里就能直接调:
let ocr = OCRObject() if let dir = Bundle.main.path(forResource: "models", ofType: nil) { ocr.loadModel(fromDir: dir) } let image = UIImage(named: "invoice")! let lines = ocr.recognizeText(in: image) print(lines)loadModel(fromDir:) 和 recognizeText(in:) 是 Swift 自动映射过来的名字。如果桥接不出来,检查 OCRObject.h 是否在 Project Header 里,以及桥接头文件有没有包含它。别在 C++ 头文件里混 Swift 桥接,容易把自己绕晕。
4. 避坑清单:iOS 端 PaddleOCR 从链接到运行的五类翻车现场
PaddleOCR iOS 的坑大多集中在工程集成阶段,而不是模型精度。下面几条都是我自己在真机上一行行试出来的,每一条都按“现象、原因、解决”写,照着查能省一晚上。
4.1 链接阶段报 Undefined symbols,或者 C++ 符号找不到
现象:Xcode 编译通过,链接时报 Undefined symbols,里面带 paddle、clipper、std::__1 这类关键词。
原因:最常见的是三个 cpp 没有进 target,或者 libpaddle_api_light_bundled.a 没在 Link Binary With Libraries 里;也有可能是 C++ 标准库链接参数缺失。如果错误信息里出现 std::__1,基本就是 libstdc++ 和 libc++ 混用了。
解决:第一步,在 Build Phases -> Compile Sources 里确认 ocr_clipper.cpp、ocr_db_post_process.cpp、ocr_crnn_process.cpp 都在;第二步,在 Other Linker Flags 加上 -lc++;第三步,确认静态库已经添加到 Link Binary With Libraries。如果还报 std::__1 相关错误,把 C++ Standard Library 改成 libc++,然后 Clean 一次再编译。这一步没什么技术含量,但最花时间。
4.2 模拟器能编译,一运行就崩
现象:模拟器编译成功,点击 Run 立刻闪退,日志常见 “abort() called” 或 “No such file”。
原因:静态库可能不包含 x86_64 架构;也可能是模型 .nb 文件没有打进 bundle,MobileConfig 加载路径不对。
解决:优先用 lipo -info 检查静态库架构,如果确认没有 x86_64,直接连真机调试,别在模拟器上耗。如果静态库支持模拟器,再检查模型路径:loadModelFromDir 里的 modelDir 必须是 Bundle.main.path,直接把 “models” 写成相对路径是新手最常犯的错。把 ocr_det.nb、ocr_rec.nb 拖进 Copy Bundle Resources 后,打印 Bundle.main.path 确认文件存在。排查顺序很固定:先架构、再路径、最后才怀疑代码。
4.3 中文乱码,英文或数字正常
现象:识别英文和数字正常,识别中文输出一堆乱七八糟的字符,有时还有“口口”类似的占位。
原因:识别模型的输出字符集和 ocr_crnn_process.cpp 中维护的 dict 对不上。Paddle 官方模型库里的中文模型虽然是一个压缩包,但里面配套的 dict 不一定在你下载的 inference 目录里;拿着通用 dict 去解码一个专用模型,就会乱码。
解决:下载模型时找到对应的 dict.txt,把里面每个字符按顺序填到 ocr_crnn_process.cpp 的字符字典数组中,顺序必须严格一致,一个都不能错。验证方法是:先用一张只有数字和字母的图片跑,确认模型本身是好的;再换成中文图片,看乱码是否消失。不要靠眼睛猜,直接对比字符数量。
4.4 检测框位置对,但裁出来的文本行是歪的或颠倒的
现象:在 UI 上画检测框看似没问题,但拿去做识别时文本行方向不对,或者上下字符顺序反了。
原因:UIKit 坐标系 y 轴向下,OpenCV 的 cv::Mat 坐标 y 轴向上,UIImage 转 cv::Mat 后没有统一方向,导致坐标在“原图 → 网络输入图 → 裁剪图”之间来回翻转。
解决:从 UIImage 转 cv::Mat 的那一刻起,所有中间结果都用 cv::Mat 的坐标系处理,不在 UIKit 和 OpenCV 之间交叉。检测框最终要画到 UIImageView 上时,再统一把 y 坐标翻转。另外,裁剪文本行时,四点排序必须保持固定顺序(左上、右上、右下、左下),OCR 裁图函数通常是按这个顺序做透视变换的,顺序一错整块图像就翻转 180 度或 90 度。我建议在拿到检测框后先做一次 sort 再进识别,不要依赖模型输出顺序。
4.5 单张识别耗时太长,线程数越高越慢
现象:同样一张图,threadNum_ 从 4 调到 8,耗时反而变长,有时还伴随 CPU 发热。
原因:Paddle Lite 在 CPU 上多线程不是线性加速。iOS 的功耗管理会限制 CPU 频率,线程开太多造成抢占式切换,模型本身的算子并行度也有限,出现“线程越多越慢”很正常。
解决:先把检测图限制到 max_side_len=960,再在 MobileConfig set_threads 设 2~4 之间,用真机实测。如果目标是识别票据、合同这种文字密密麻麻的图,可以考虑把大图切成若干小块(注意重叠区域去重)逐块识别,而不是开更多线程试图一次算完。内存方面,每转一张 UIImage 就包一层 @autoreleasepool,避免 image 底层缓冲一直占着不释放。
5. 识别效果调优:检测阈值、输入尺寸和性能取舍
模型跑通只是第一步,真正上线要有能调的参数。PaddleOCR 的识别率不是靠改代码碰运气,靠的是把后处理参数、输入尺寸和资源占用拧到合理区间。这一章给的是我自己在 iOS 上拍了一百多张测试图后总结的调法。
5.1 DB 检测后处理参数:先调 thresh,再调 unclip
检测模型输出的概率图,会被 ocr_db_post_process.cpp 里的 BoxesFromBitmap 函数按参数清洗成文本框。这些参数通常在代码里写死,我会把它们抽成 OCRObject 的配置项,方便真机上快速验证,不用反复编译。常用参数和范围如下:
| 参数 | 作用 | 常见范围 | 我的一般起点 |
|---|---|---|---|
| det_db_thresh | 二值化概率阈值,低于它直接置 0 | 0.2~0.4 | 0.3 |
| det_db_box_thresh | 检测框置信度阈值,低于它丢弃 | 0.5~0.7 | 0.6 |
| det_db_unclip_ratio | 检测框外扩比例,越大框越松 | 1.2~2.0 | 1.6 |
| max_side_len | 检测输入最长边限制 | 720~1280 | 960 |
| use_dilate | 概率图是否先膨胀 | true/false | false |
调参顺序有讲究:遇到浅色纸、低对比度文字,先把 det_db_thresh 从 0.3 降到 0.25,不然浅色笔画在二值化时会被误删;如果检测框把文字切了一半,把 det_db_unclip_ratio 加大到 1.8,让框往外扩一点;如果背景复杂、误检多,把 det_db_box_thresh 往上抬到 0.65,宁可漏检也不要框出墙上的字。这些都是真实经验,不是玄学。注意 unclip 不是越大越好,文字密集段落里外扩太多会导致相邻行粘连,识别结果会拼出莫名其妙的长句。
我一般会把参数集中放在一个配置类里,比如给 OCRObject 加一个属性:
@property(nonatomic, assign) float dbThresh; @property(nonatomic, assign) float dbBoxThresh; @property(nonatomic, assign) float dbUnclipRatio;然后在调用后处理时传入,这样不用为了试参数重新编译模型。实测时打印一下每个参数对应的字号耗时,你就会慢慢形成自己的参数手感。
5.2 识别输入尺寸:固定高度 32,宽度别写死
CRNN 识别模型对输入有一个重要约定:高度固定为 32,宽度按文本行原始长宽比缩放,一般不超过 320。如果你把宽度直接固定成 320,短文本会被强行拉长,长文本会被压扁,识别率直线下降。我们项目里曾经出现“五个字的签名横七竖八”,查下来就是识别前把整行 resize 到 320 宽。正确做法是拿到裁剪框高度后,按比例计算目标宽度,再补 padding 到模型的最小支持宽度。ocr_crnn_process.cpp 里做 resize 的地方,改成和高度比例相关就行了。
识别模型输入的均值/方差要和你转换模型时保持一致。常见的是 mean=[0.485, 0.456, 0.406] 或 mean=[0.5, 0.5, 0.5],归一化公式写在 ocr_crnn_process.cpp 顶部。这个参数错了不会崩,但识别率会突然垮掉。如果你发现某张图怎么调阈值都不行,先检查这里,多数翻车都发生在归一化参数和训练配置不一致。
还有一个小坑:计算宽度时会遇到小数取整。一定要用 float 参与整个缩放链,最后才转 int,不要每一级都取整,否则误差累积一小段时间,图像就肉眼可见地糊了。取整误差在单张上不明显,批量跑几百张图时会看到整体识别率偏低,但又说不出是哪坏了。
5.3 性能取舍:线程、复用和异步队列
移动端性能优化的核心不是“让 CPU 跑更快”,而是“别让 CPU 白忙”。第一,PaddlePredictor 初始化一次之后可以反复使用,千万不要每次识别都 CreatePaddlePredictor,那是在给手机做压力测试。第二,用 OperationQueue 管理 OCR 任务,maxConcurrentOperationCount 设为 1,保证同一时间只有一个识别任务;OCR 竞争 CPU 时不会因为多任务并行把主线程卡死,也不会触发内存抖动。第三,线程数别一上来就 8,iPhone 上实测 2 和 4 效果差不多,某些机型 4 更好,跑一遍基准就知道该选哪个。
另外一个容易忽略的点:UIImage 转 cv::Mat 本身就有内存开销。对相册里的高清图,先拿到缩略图或者先对原图 downscale 再交给 OCR,比在识别代码里做 resize 更省内存。正常场景下,一张 1200px 的图用 2 线程识别大概在 300~800ms 之间,如果超过 2 秒,不要单纯怀疑代码,先看是不是模型没转 .nb、线程数不对、或者图片超大。
如果你要在列表页快速预览多张图,我建议直接禁用主线程上的调用,把整个 OCR 流程丢到后台队列,然后回调主线程刷新 UI。不然滑动列表时图片同时进 OCR,主线程被 C++ 计算占满,界面必然掉帧。
6. 端到端验证:拿一批实拍图把耗时和识别率固定下来
调参和踩坑说得再多,不如一张张图跑起来量化。我接 iOS OCR 项目时,最先做的就是“基准三件套”:固定 20 张真实图、固定真机、固定 iOS 版本。这三样里任何一样变动,耗时数据都不可比,后面谈性能优化就成了玄学。
6.1 埋点:把检测和识别耗时分开打印
给 OCRObject 加一个简单的耗时统计,不引入任何第三方库,用 CFAbsoluteTimeGetCurrent 就行:
let start = CFAbsoluteTimeGetCurrent() let lines = ocr.recognizeText(in: image) let totalMs = (CFAbsoluteTimeGetCurrent() - start) * 1000 print("OCR total: \(totalMs) ms, lines: \(lines.count)")如果 totalMs 超预期,再在 C++ 侧分别埋点:检测前后记一个 detCost,识别前后记一个 recCost,返回给 Swift 后打印出来,你会立刻知道瓶颈在检测、识别还是预处理。通常检测阶段占大头,尤其文字区域多的时候;识别阶段更多取决于文本行数量和每个框的宽度。用数据说话,比盲改参数高效多了。
6.2 用批次结果校准参数,而不是单张图
参数调优最大的错觉是“调好了这一张”。我习惯把 20 张图的识别文本和人工 Ground Truth 做一次对比,记录两个指标:字符级正确率和平均耗时。每调一个阈值,跑一遍批次,而不是在单张图上反复拉阈值。比如 unclip_ratio 从 1.6 改到 1.8,单张可能更好看,但批次可能带来相邻行粘连导致失败率上升。批次回归一次半小时,能省掉你上线后收到用户反馈再返工的两天。
6.3 进阶:竖排文本、多语言和结果可视化
如果你的业务有竖排文字需求,可以在检测后加一个方向分类模型,对检测框先判断 0 度或 180 度,再决定是否旋转后进入识别模型。这个思路和识别模型一样,只是多一种 0/180 的输出分支;在 iOS 端需要额外加载一个分类模型并用同样的 MobileConfig 创建 predictor,文本行进入 rec 之前旋转一下即可。竖排文本的方向翻转和横排不同,裁剪后要按长边方向确定旋转角度,不要沿用横排的翻转逻辑。
多语言切换更简单:PaddleOCR 的多语言识别模型通常只是识别网络和字典不同,检测模型可以复用。你只需要在 loadModel 时换掉 ocr_rec.nb,并把 ocr_crnn_process.cpp 里的 dict 换成对应的字符集。iOS 端可以做成一个模型目录选择器,让用户选择语言,加载时切换文件路径,不需要改业务代码。
另外,如果想在 UI 上把识别框和文本区域可视化,记得把检测框坐标以数组形式返回给 Swift,而不是只在类内部画完就丢掉。我在第一个版本里就是把框画在了内部 cv::Mat 上,结果界面完全没法复用,后来才改成返回归一化坐标数组,让 Swift 层通过 UIBezierPath 画到 UIImageView 上。从那以后,我的每个 OCR 工程都强制走一遍“批次基线 + 检测/识别分开埋点 + 参数集中配置”,迭代识别率时不再靠感觉,每次只改一个变量,跑完一批测试图再动下一个。希望这个习惯也能帮到你少踩坑,把时间真正花在让识别结果更准、应用更流畅这件事上。
本文还有配套的精品资源,点击获取