前言
仓库那边找到我:一天两百多个快递面单,两个人拿键盘敲进 Excel,敲到下午四点眼睛是花的。老板的原话是"你不是搞技术的吗,这个东西不是很简单"。
是挺简单。鸿蒙 7 的 Core Vision Kit 里那个通用文字识别就是干这个的,端侧跑、不要网络、不要调云端 API 按次付费。我在 MatePad Pro 上把整条链跑通了,最后的结果是:四张面单,三张全对,一张崩得很难看。
崩的那张教会我的东西,比前三张加起来都多。这篇文章就按我干活的顺序写:先造数据,再调接口,最后处理那些接口文档不会告诉你的破事。
先说我不信 demo 图
任何 OCR 需求,拿官方示例图跑通就汇报"做完了"的,都是给自己埋坑。所以我没有用网图,而是用 PIL 自己画了面单——因为只有我自己画的,我才确切知道标准答案是什么。
四张图,同一批版式,退化做成阶梯:
| 编号 | 尺寸 | 退化处理 | 标准答案 |
|---|---|---|---|
| 标准面单 | 760×1000 | 无 | SF1350778829431 |
| 手持翻拍 | 456×600 | 旋转 3.2°、高斯模糊 1.7、JPEG q58 | YT7512098834126 |
| 小图压缩 | 300×395 | 旋转 −6.5°、模糊 0.8、q45 | JD0017283946510 |
| 极限压图 | 200×263 | 在小图基础上再模糊 1.4、q30 | JD0017283946510 |
前三档就是仓库手机随手拍的正常水平,第四档是我故意拿来找死的。
接口长什么样
三个调用就够,没什么花活:
this.inited = await textRecognition.init();
const info: textRecognition.VisionInfo = { pixelMap: src };
const conf: textRecognition.TextRecognitionConfiguration = { isDirectionDetectionSupported: false };
const res: textRecognition.TextRecognitionResult = await textRecognition.recognizeText(info, conf);几点实在的:
init()是有状态的,我放在单例里做幂等,整个批量过程只初始化一次。别每张图 init/release 一遍,那是给自己加延迟。isDirectionDetectionSupported是给"图片可能是横着/倒着拍的"准备的。我的四张图旋转角都在 7° 以内,直接关掉。这个开关对耗时的影响我没有单独测,不拿它当优化手段。- 返回值是一整块文本
res.value,外加blocks → lines → words三层结构和每个line的cornerPoints。注意value是把所有行拼起来的,这一点后面会害我。 - 一次只能一张图。想批量只能自己排队串行。
结果:三张对一张崩
真机上一遍跑完四张:
| 样本 | 耗时 | 识别字符数 | 行数 | 单号结果 | 收件人 | 电话 |
|---|---|---|---|---|---|---|
| 标准面单 | 1512ms | 197 | 22 | SF1350778829431 ✓ | 张伟 ✓ | 138****4471 ✓ |
| 手持翻拍 | 1310ms | 183 | 19 | YT7512098834126 ✓ | 李静 ✓ | 139****2280 ✓ |
| 小图压缩 | 1240ms | 178 | 20 | JD0017283946510 ✓ | 王强 ✓ | 137***9056 少一个星号 |
| 极限压图 | 888ms | 33 | 7 | JO300017283946510 ✗ | 空 | 空 |
四张合计 5010ms。同一张标准面单我前后跑了三次,耗时 1288 / 1402 / 1512ms,波动 ±15%,所以别拿单次耗时做 SLA。
坑一:崩溃是断崖式的,不是慢慢变差
极限压图那张最有意思。它不是"识别得差一点",而是整页只剩 33 个字符、7 行——前面三档都是 178~197 字符、19~22 行。也就是说 OCR 面对糊到一定程度的图,是直接放弃,而不是勉强认几个。
更要命的是它错得很自信:
标准答案:JD0017283946510
实际输出:JO300017283946510D 认成了形近的 O,中间还凭空多出 3000。返回值里没有任何"我不确定"的信号——没有置信度字段,没有 partial 标记。你正则一匹配,[A-Z]{2}\d{10,15} 照样命中,看起来像个合法单号,直接进表了。
所以判据应该换成输入侧的信号:字符数低于 100、或行数低于 15 的面单,直接标黄退回人工,不看它抽出来什么。这个门槛比任何正则都管用,因为它检测的是"OCR 放弃了"这个事实本身。
坑二:抢字段的不是 OCR,是你的正则
前三张单号全对,我正准备收工,回头看了一眼电话字段——标准面单那行是:
电话 1350778829431我当时的表情大概是这样的:面单上明明印着 138****4471。
原因很简单,我的正则写成了这样:
raw.match(/1[3-9]\d[\d*]{4,9}\d{3,4}/) // 只要像手机号就行而 res.value 是按行拼接的一大串,运单号在上面,SF1350778829431 里的 1350778829431 恰好以 13 开头、长度合适,先被匹配到了。手持那张侥幸对了,纯粹是因为它的运单号被识别成了 YT7512...,7 打头不成手机号。
改成锚定标签就对了:
raw.match(/联系电话\s*(1[3-9][\d*\-]{6,12})/)教训一句话:在拼平的 OCR 文本上做字段抽取,等于在没有任何版式信息的情况下猜。要么锚标签,要么用 cornerPoints 拿到行的坐标,按"同一 y 行的右侧内容"来取——后者更稳,但代码量翻三倍。我这次选了锚标签,因为面单的标签词是固定的。
坑三:* 不是字符,是玄学
小图那张的电话是 137***9056,标准答案 137****9056,少一个星号。面单上的脱敏掩码是四个 *,OCR 认成三个。
这类非文字符号(*、·、-、空格)的个数不可信。所以任何"拿掩码位数校验手机号"的想法都要丢掉,脱敏字段只做前缀匹配:137 开头、9056 结尾,中间不管。
那这两百单到底怎么进表
按实测 1.3s/张串行算:
- 200 张 ≈ 260 秒 ≈ 4.4 分钟,中间不用人管。
- 剩下的是复核时间。按我这次的样本,四张里退一张,也就是 25% 的退回率——样本太小,这个比例只能当心理预期,不能当指标。
- 想再快只能并行多实例,但接口本身不支持批量,端侧模型吃不吃得住并发我没测,不吹。
导出这一步我没写进 demo:字段抽完之后拼成 CSV 文本、丢进剪贴板,回工位 Ctrl+V 到表格里就完事了。这一步跟鸿蒙没关系,几行字符串拼接的事,反而是仓库那边最在意的——他们那张 Excel 已经用了六年,你让他们换系统不如让他们 Ctrl+V。
真机数据
| 项目 | 实测值 |
|---|---|
| 单张识别耗时 | 888 ~ 1512ms(随内容量变化,不是随像素) |
| 四张串行合计 | 5010ms |
| 同图重复波动 | 1288 / 1402 / 1512ms,约 ±15% |
| 正常面单字符数 | 178 ~ 197 字,19 ~ 22 行 |
| 崩图字符数 | 33 字,7 行 |
| 单号准确率 | 3/4(含一张故意压坏的) |
| 字段准确率 | 收件人 3/3、电话 2/3(星号数错) |
测试机 MatePad Pro(HarmonyOS 7 / API 26,序列号略)。面单为脚本合成,标准答案已知。



总结
这套东西适合"版式固定、印刷体、量又大"的单据:快递面单、发票、送货单、对账单、体检报告首页。这类单子仓库里一天几百张,人工敲纯属浪费生命。
不适合手写体、倾斜超过十度的随手拍、以及任何分辨率低于 300px 的图。最后这条是硬线,过了就断崖。真要在手机上现场拍,先把取景框和"太糊了重拍"的提示做出来,比在识别之后想办法划算得多。
💡 一句话:OCR 本身很少骗你,它放弃的时候会留下痕迹——字符数和行数突然掉一半,就先别信抽出来的内容。
参考文献
- 通用文字识别(开发指南):https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-text-recognition
- textRecognition(通用文字识别)API 参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-text-recognition-api
- Core Vision Kit 错误码:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/errorcode-core-vision
- Core Vision Kit 目录:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-kit-guide