智绘鸿蒙 · 如 7 而至端侧 AI 能力实战手记

手把手:拍一张发票,让报销单自己填好

智绘鸿蒙·如 7 而至

前言

出差回来要贴发票。一百多张,每张都要把金额、日期、发票代码抄进报销表,抄错一个小数点就得重来。

如果有个功能:手机对着发票拍一下,三个字段自动落进表格——这篇文章就带你做这个。

拆开看,这件事只有两小步:把图里的字认出来,和从一堆字里挑出我要的三个值

第二步听起来简单,其实坑最多。我们后面会亲眼看到它把 11.04 当成报销金额。

第一步交给鸿蒙的现成能力:通用文字识别textRecognition),归属 @kit.CoreVisionKit。不需要联网、不需要申请任何权限、不用自己塞模型。

这里要特别说明一点:发票上有公司税号,属于敏感数据。"识别过程完全在设备本地完成"这件事,对能不能上架、用户愿不愿意用,都是关键。

先认识一下它给的数据长什么样

很多人以为 OCR 返回的就是一串字符串。不完全是,它的返回是带层级的

TextRecognitionResult
├── value: string                    // 全部文本,就是我们以为的那串字
└── blocks: TextBlock[]              // 文本块(一段)
        ├── value: string
        └── lines: TextLine[]        // 行
                ├── value: string
                ├── cornerPoints: PixelPoint[]   // 这一行的四个角点
                └── words: TextWord[]            // 词
                        ├── value: string
                        └── cornerPoints: PixelPoint[]
  • OCR(Optical Character Recognition):光学字符识别,把图片里的文字转成可编辑文本。
  • PixelPoint:一个二维坐标点 { x, y }
  • cornerPoints:四个角点,因为一行文字可能是倾斜的,用矩形框表达不了。

注意两点:

  1. 坐标是 cornerPoints(四个点),不是 boundingBox(一个矩形)。 你想做"点哪复制哪",得用这四点算区域。
  2. value 是拼接结果,blocks / lines / words 才是结构。 只拿 value 做正则,等于放弃了模型已经帮你切好的行。

接口本身很薄:

textRecognition.init(): Promise<boolean>                                  // 5.0.0(12) 起
textRecognition.release(): Promise<void>                                  // 5.0.0(12) 起
textRecognition.getSupportedLanguages(): Promise<Array<string>>
textRecognition.recognizeText(visionInfo, configuration?): Promise<TextRecognitionResult>

configuration 目前只有一个字段 isDirectionDetectionSupported——是否允许旋转文字方向检测。

我们一步一步写

第一步:起一个引擎壳子

init() 是全局的,不要每次识别都调。我们用一个布尔位记住状态:

export class OcrRunner {
  private inited: boolean = false;

  async init(): Promise<void> {
    if (this.inited) {
      return;
    }
    try {
      this.inited = await textRecognition.init();
    } catch (err) {
      const e = err as BusinessError;
      throw new Error(`OCR 服务初始化失败(${e.code}):${e.message}`);
    }
  }

  async release(): Promise<void> {
    if (!this.inited) {
      return;
    }
    await textRecognition.release();
    this.inited = false;
  }
}

顺手把 getSupportedLanguages() 打出来看看它到底认几种字。我们在真机上实测返回:

zh-CN / en / ja / ko / zh-TW

五种。也就是说简中、繁中、英、日、韩都在能力范围内——但它是按字符识别的,不做语义纠错,这一点第三节末尾会看到后果。

第二步:拿到 PixelMap

样票走 rawfile,真实票据从相册选。两条路都是 createImageSourcecreatePixelMap

async decode(bytes: ArrayBuffer): Promise<image.PixelMap> {
  const source: image.ImageSource = image.createImageSource(bytes);
  const pm: image.PixelMap = await source.createPixelMap();
  await source.release();
  return pm;
}

从相册导入时传 fdimage.createImageSource(fd.fd)

别忘了 source.release()ImageSource 持有原生资源,页面反复进出会累积。

第三步:识别

const info: textRecognition.VisionInfo = { pixelMap: src };
const conf: textRecognition.TextRecognitionConfiguration = { isDirectionDetectionSupported: rotate };
const res: textRecognition.TextRecognitionResult = await textRecognition.recognizeText(info, conf);

然后自己数一遍结构(返回值里没有现成的行数和词数):

let lines: number = 0;
let words: number = 0;
for (let i = 0; i < res.blocks.length; i++) {
  const bl: textRecognition.TextLine[] = res.blocks[i].lines;
  lines += bl.length;
  for (let j = 0; j < bl.length; j++) {
    words += bl[j].words.length;
  }
}

这三个数字一定要显示出来,理由见下一节。

第四步:抽字段——真正的坑在这里

我们的目标是三个值:金额、日期、号码。第一版正则我是这么写的(这是错的,故意留着):

const money = raw.match(/([¥¥]\s*\d[\d,]*(?:\.\d{1,2})?)|(\d[\d,]*\.\d{2})/);

看起来很合理:匹配一个带货币符号的金额,或者一个两位小数。

真机跑出来的结果:金额 = 11.04

为什么?因为增值税专用发票的表格里有十几行明细,每行都有单价、金额、税额三个小数。11.04 是票面上第一个出现的两位小数——它命中了正则的第二个分支,而正则只返回第一个匹配。真正的价税合计 ¥1401.08 在票面靠下的位置,根本没轮到。

修正的思路:不要找"第一个像金额的",要找"最像合计的"。票面上带 ¥ 符号的金额里,最大的那个就是价税合计:

/** 票面上有十几个小数,取带货币符号的最大值才是价税合计。 */
static pickAmount(raw: string): string {
  const all: RegExpMatchArray | null = raw.match(/[¥¥]\s*(\d[\d,]*(?:\.\d{1,2})?)/g);
  if (!all || all.length === 0) {
    return '未识别';
  }
  let best: number = -1;
  let bestText: string = '';
  for (let i = 0; i < all.length; i++) {
    const num: number = Number(all[i].replace(/[^\d.]/g, ''));
    if (num > best) {
      best = num;
      bestText = all[i].trim();
    }
  }
  return bestText;
}

/** 优先取标签后的编号,避免命中统一社会信用代码。 */
static pickCode(raw: string): string {
  const m: RegExpMatchArray | null =
    raw.match(/(?:发票代码|發票代码|发票号码|發票号码|号码|码)[::]?\s*(\d{8,20})/);
  if (m) {
    return m[1];
  }
  const any: RegExpMatchArray | null = raw.match(/\d{10,20}/);
  return any ? any[0] : '未识别';
}

改完再跑,三个字段全对:金额 ¥1401.08、日期 2024年04月24日、号码 044032100111

请注意这里的一个细节:pickCode 里我把繁体 發票代码 也写进了候选。原因是下面要讲的那个现象。

第五步:别忘了 release

aboutToDisappear(): void {
  this.runner.release();
}

真机运行结果

测试机:HUAWEI MatePad Pro(MRDI-W00),HarmonyOS 7.0.0.107,API 26。样票是一张 720×900 的增值税发票照片。

识别完成后的界面:

自动抽取的三个字段:

实测产出 71 块 / 73 行 / 337 词 / 539 字,耗时 3330 ms

三个必须知道的结论

结论一:耗时不低,必须给进度反馈。

三秒多不是"点一下就出来"的体验,是"必须转圈并允许取消"的体验。而且耗时和文字量强相关,一张密密麻麻的行程单会更久。

结论二:OCR 忠实反映源图质量,不做语义纠错。

我们这张样票本身就有字形瑕疵,识别结果原样保留了:

  • 显示器 被识别成 服示暴
  • 1TB 被识别成 ITB
  • 键盘 被识别成 健盤
  • 整张票简繁混排電子发票發票代码納税人識別號统一社會信用代碼

这不是 OCR 的 bug,恰恰说明它工作得很诚实:图里长什么样,它就读成什么样

所以做产品时,"识别准确率"这个指标其实是由拍照质量决定的,不是你决定的。你能决定的是——给用户提供"识别结果可编辑",以及把置信度低的字段标出来让人复核。

顺带一句:正因为简繁混排,我们的正则必须同时兼容简繁两种写法,否则繁体那半边的标签根本匹配不上。

结论三:别只拿 value 做正则。

res.blocks[i].lines[j] 是模型已经帮你切好的"行"。发票这种强结构文档,按行匹配(比如"找到含合计的那一行,再在行内取金额")比在整段文本里捞数字可靠得多。我们的 pickAmount 之所以能用"取最大值"这种土办法,是因为价税合计恰好是票面最大金额——换一个票种就不成立了。

往生产走还差什么

  1. 字段校验:金额要能对上"大写金额"(票面有 壹仟肆佰零壹圆零捌分),对不上就标红让人复核。
  2. 多图批量:串行跑,别并发;一张 3.3 秒,十张 33 秒,进度条必须有。
  3. 失败兜底init() 失败、识别返回空文本,都要有明确提示,不能静默。
  4. 隐私声明:虽然全程端侧,仍建议在界面上写一句"票据数据不会离开本机",这是用户真正关心的。

总结

什么场景值得用它:报销、巡检、归档这类"拍张单据填表"的活。端侧 OCR 把字认全,简繁中英日韩都在能力范围内,票据数据不出设备。

不适合:指望它直接给你结构化数据。它交出来的是文本,字段还得自己抽,而且要预留抽错被人工复核的兜底。

💡 一句话:OCR 认字很靠谱,认业务不靠谱——那个被当成报销金额的 11.04 就是教训。

参考文献

  1. Core Vision Kit 开发指南 · 通用文字识别:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-text-recognition
  2. Core Vision Kit API 参考 · textRecognition(文字识别):https://developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-text-recognition-api
  3. Core Vision Kit 错误码:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/errorcode-core-vision