最近在做HarmonyOS应用时遇到一个典型需求:用户上传纸质发票照片,应用需要自动提取票面上的关键信息。最初想接入云端OCR服务,但数据要出设备这一关就过不了——发票上的公司名、税号、金额都是敏感信息,老板直接拍板必须端侧处理。兜兜转转最后落回HarmonyOS自带的AI能力,用通用文字识别把整条链路跑通了。折腾了一周,核心流程、代码细节、性能调优都过了一遍,也踩了不少文档里不会写的坑。这篇就把整个方案从头到尾拆开讲清楚,给准备在HarmonyOS上做文字识别的朋友一份可以直接复用的参考。

[图片说明占位]

顺便说下适用人群:如果你正在做鸿蒙原生应用,需要识别图片中的中文、英文文字,或者想做票据、名片、文档扫描类的功能,这篇文章可以帮你少走弯路。我会从技术选型讲到具体代码实现,再到疑难排查,尽量把“为什么这么做”也说透,不只是给一段能跑的代码。

1. 内容整体设计与思路拆解

1.1 做通用文字识别,我的方案是怎么定的

HarmonyOS上实现文字识别,粗略分有三条路线。

第一条是用系统内置的AI能力,也就是HarmonyOS AI框架里的通用文字识别接口,通过Core Vision Kit提供。输入一张图片,返回识别出的文本块、行、字符以及对应的坐标信息。优点是系统级支持,离线可用,数据不出设备,调用也简单,不需要引入额外依赖。

第二条是集成第三方云服务SDK,比如主流云厂商的通用OCR。这类方案识别精度通常较高,支持票据、卡证等专用场景,但需要联网,涉及数据上传,而且要在合规和隐私层面做功课。对B端应用来说,数据出境和隐私协议本身就是大坑。

第三条是自研模型,在端侧部署一个轻量化OCR模型。自由度最高,但工程量和维护成本也最大。你要考虑数据标注、模型压缩、推理框架集成、多机型适配,没有专门的算法团队很难落地。

考虑我们项目的场景——发票识别,数据敏感,要求离线可用,最终选了第一条路线,用HarmonyOS系统自带的通用文字识别能力。整体逻辑是:应用通过系统能力完成端侧OCR识别,再对识别结果做后处理,根据关键词和正则规则从识别文本中提取结构化字段,上传时只传我们需要的字段,而不是整张票据图片。

1.2 端侧识别的系统级优势在哪里

选择系统级OCR方案,不是因为它最强大,而是因为它在我们项目的约束条件下最合适。总结下来有几个关键优势。

第一,隐私合规。整个识别过程在设备本地完成,图片数据不出端,不用向用户申请额外的数据上传权限,隐私协议和安全审核都好过。

第二,离线可用。没有网络也能识别,在一些网络受限的场景下(比如地铁、地下车库、弱网环境)体验稳定。

第三,接入成本低。用系统API,不需要集成第三方SDK,不需要申请服务密钥,也不需要处理SDK版本兼容,依赖面小了很多。

HarmonyOS的系统AI能力在架构上做了统一封装,底层通过端侧神经网络推理实现文字检测和识别。坦白讲,它的识别精度和云端专用OCR相比有一定差距,尤其在复杂背景、低清晰度、艺术字体等场景下。但它的优势在于足够“通用”,并且在中文识别上做得很出色,对国人的使用习惯和常见文档排版有较好的兼容性。

[图片说明占位]

1.3 HarmonyOS通用文字识别的能力边界

在投入开发之前,先搞清楚这个能力能做什么、不能做什么,可以避免后期返工。

从能力范围看,HarmonyOS的通用文字识别支持中英文识别,可以返回页面级、块级、行级甚至字符级的识别结果,同时提供每个文本块的位置坐标和角度信息。它适合文档扫描、图片翻译、票据信息提取等下场景。

不适合的场景主要有:手写体识别(效果不太理想)、复杂表格结构还原(它只返回文本坐标,不负责表格结构)、自然场景中的不规则形变文字(可以检测但识别率会明显下降)。

还有一点容易忽略——HarmonyOS的文字识别能力只做了“识别”,不做“理解”。它返回的是文字内容和位置,不会告诉你这是发票号还是金额,这段逻辑需要自己在业务层实现。所以完整的通用文字识别项目,通常由“OCR识别”和“信息结构化”两个部分组成,前者是系统能力,后者是开发者的业务逻辑。

2. 核心细节解析与实操要点

2.1 输入数据准备:图像质量和格式对结果的影响

OCR的第一步是拿到图片并转换成系统API能接收的格式。在HarmonyOS上,文字识别接口要求输入的是PixelMap(像素图),不是文件路径,也不是Base64字符串。所以第一步通常是从多媒体文件创建PixelMap。

关于输入图像的质量,踩坑经验比官方文档更直观。实测下来,有几个硬性建议:

  • 图片建议不小于720P(1280x720分辨率),太小了文字笔画粘连严重,识别率断崖式下降。
  • 文字在图片中占比尽量大,如果文字只占画面一角,背景干扰会显著影响识别效果。
  • 图片过亮或过暗都会导致二值化效果差,建议保持正常曝光。
  • 模糊图片建议直接提示用户重拍,再好的算法也无法从糊掉的图中恢复清晰文字。

代码上创建PixelMap的常用方式是:

import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';

// 根据文件路径打开图片,创建PixelMap
const file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
const imageSource = image.createImageSource(file.fd);
const pixelMap = await imageSource.createPixelMap({
  desiredPixelFormat: image.PixelMapFormat.RGBA_8888
});
// 用完后记得释放
pixelMap.release();
imageSource.release();

这里有一个小细节: desiredPixelFormat 建议指定为 RGBA_8888 。默认格式在部分机型上识别接口内部需要做格式转换,虽然不至于报错,但会多一次内存拷贝,性能上有损耗。

2.2 识别参数配置:别看配置少,影响不小

HarmonyOS的通用文字识别接口只提供少量配置项,但每个都值得关注。

  • isDirectionDetectionSupported :是否检测文本方向。开启后,旋转90度、180度的图片也能正确识别,内部会先做方向分类再进入识别流程。代价是额外耗时,如果业务场景能保证图片方向正确,关掉可以省20%-30%的耗时。
  • isCharacterLevel :是否返回字符级识别结果。如果只需要整行文本,建议关闭,省内存也省解析时间。如果要做逐字比对或绘图标注,再开启。
  • language :可指定识别语言类型,支持中文、英文等。默认值通常是中英文混合识别。不建议在纯中文场景中把语言限制为“仅中文”,实测混合模式下对中文的识别率并不比单一模式差,反而更稳,因为一张图中难免出现个别英文字母或数字。

配置示例:

import { textRecognition } from '@kit.CoreVisionKit';

const config: textRecognition.TextRecognitionConfiguration = {
  isDirectionDetectionSupported: true,
  isCharacterLevel: false,
  language: 'zh-hani' // 按实际SDK能力填写,也可以不设置走默认
};

需要特别注意:不同API版本中配置项的命名可能略有出入,我写的时候核对的是HarmonyOS NEXT的接口命名。接入前一定要打开SDK里对应版本的接口定义看一眼,别照搬老项目代码。

2.3 识别结果的结构与解析策略

调用识别接口后,拿到的结果是一个带有层级关系的数据结构。理解这个结构是做好后处理的关键,我看到很多人在这一步栽跟头——把结果当普通字符串处理,丢失了坐标和层级信息。

大致的结构层级是这样的:

TextRecognitionResult
 └── TextBlock(文本块,对应一段连续文本)
      ├── value(该块的文本内容)
      ├── position(整块的外接矩形坐标)
      ├── angle(文本块相对于水平方向的旋转角度)
      └── Lines(行列表)
           ├── value(行文本内容)
           ├── position(行的坐标)
           └── Characters(字符列表,如果开启了字符级识别)
                └── value(字符内容)

解析时按“块→行→字符”逐层遍历。如果你只需要纯文本,可以拼装每个 TextBlock.value 。但如果要做结构化提取,强烈建议按“块”做聚合,因为同一块内的文本通常属于同一段落或同一语义单元。

我做发票识别的经验是:不要直接在 Lines 级别做正则匹配,因为一行可能会被误切开(比如表格里每列是一行,但语义上属于同一条目)。先按块聚合完整文本,再做后续处理,字段提取的准确率会明显提升。

3. 实操过程与核心环节实现

3.1 环境准备与工程配置

下面是完整的实操过程,我以API 12+的HarmonyOS NEXT环境为例(如果是API 9或API 10的旧工程,部分接口名字会不一样,但整体流程可以照搬)。

工程方面需要做以下几点准备:

  1. 使用DevEco Studio创建HarmonyOS应用工程,确保SDK版本支持 @kit.CoreVisionKit (这对应AI能力套件)。
  2. 在 module.json5 中配置相册读取权限。如果只是读取应用沙箱内的图片,不需要权限;但读取系统相册图片,需要申请 ohos.permission.READ_IMAGEVIDEO (API 10及以上的按分区存储权限模型申请)。
  3. 如果使用旧版API,需要在 requestPermissionsFromUser 运行时申请。新版权限模型下,相册权限仍然是敏感权限,需要动态申请。

权限申请代码示例:

import { abilityAccessCtrl, common } from '@kit.AbilityKit';

async function requestPermission(context: common.UIAbilityContext): Promise<void> {
  const permissions: Array<Permissions> = ['ohos.permission.READ_IMAGEVIDEO'];
  const atManager = abilityAccessCtrl.createAtManager();
  // 需先检查是否已授权
  const result = await atManager.requestPermissionsFromUser(context, permissions);
  if (result.authResults.some(res => res !== 0)) {
    // 用户拒绝授权,引导用户去设置页或提示无法使用
  }
}

注意:不要试图把权限声明藏在代码里不管。HarmonyOS的权限模型对敏感权限管得比较严,不声明会在运行时直接抛异常或者静默失败,非常坑。

3.2 核心编码实现:从相册到识别结果

完整流程分为三步:选择图片 -> 创建PixelMap -> 调用识别接口。

第一步,用PhotoViewPicker让用户选择图片:

import { photoAccessHelper } from '@kit.MediaLibraryKit';

async function pickImage(): Promise<string> {
  const picker = new photoAccessHelper.PhotoViewPicker();
  const result = await picker.select({
    MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE,
    maxSelectNumber: 1
  });
  if (result.photoUris.length === 0) {
    throw new Error('用户取消选择');
  }
  return result.photoUris[0];
}

这里有一个常见坑: PickerSelectResult 返回的是 photoUris ,不是文件路径。需要通过 fileIo.openSync 按URI打开,不能直接用字符串路径做文件操作。

第二步,根据URI创建PixelMap:

import { fileIo as fs } from '@kit.CoreFileKit';
import { image } from '@kit.ImageKit';

async function loadPixelMap(uri: string): Promise<image.PixelMap> {
  const file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
  const imageSource = image.createImageSource(file.fd);
  const pixelMap = await imageSource.createPixelMap({
    desiredPixelFormat: image.PixelMapFormat.RGBA_8888
  });
  imageSource.release();
  fs.closeSync(file);
  return pixelMap;
}

第三步,调用核心识别接口:

import { textRecognition } from '@kit.CoreVisionKit';

async function recognizeText(pixelMap: image.PixelMap): Promise<string> {
  const config: textRecognition.TextRecognitionConfiguration = {
    isDirectionDetectionSupported: true,
    isCharacterLevel: false
  };
  // 部分SDK版本返回的是Promise<TextRecognitionResult>,部分版本用回调方式
  const result = await textRecognition.recognizeText(pixelMap, config);
  const blocks = result.value.textBlocks;
  let fullText = '';
  for (let block of blocks) {
    fullText += block.value + '\n';
  }
  return fullText;
}

这段代码已经可以直接跑通一个“图片文字提取”的简单需求了。但实际项目要做得稳,后处理这块才是重头。

3.3 结构化后处理:从文本到业务字段

OCR拿到的是无结构文本,业务需要的是结构化字段。比如发票识别,要从一堆文本里找出发票号码、开票日期、金额、购买方名称等。

我的做法是两步走:先用正则做粗筛,再用关键词上下文做精排。

以提取发票号码为例,常见格式是“发票号码:12345678”或“No:12345678”,基本可以用正则覆盖:

function extractInvoiceNumber(text: string): string | null {
  // 全局搜索“发票号码”附近的内容
  const patterns = [
    /发票号码[::\s]*([0-9]{8,20})/,
    /发票号[::\s]*([0-9]{8,20})/,
    /NO\.?\s*[::\s]*([0-9]{8,20})/i
  ];
  for (let pat of patterns) {
    const match = text.match(pat);
    if (match && match[1]) {
      return match[1];
    }
  }
  return null;
}

金额提取也是类似逻辑,但要注意金额可能出现“小写金额”和“大写金额”两个位置,需要根据字段名区分:

function extractAmount(text: string): string | null {
  const pattern = /价税合计[((]小写[))][::\s]*[¥¥]\s*([0-9]+(\.[0-9]{1,2})?)/;
  const match = text.match(pattern);
  if (match && match[1]) {
    return match[1];
  }
  return null;
}

这一层的核心不是写正则,而是充分了解你的业务单据格式。建议先收集100张真实样本,跑一遍OCR,看输出结果,再根据实际输出调整提取规则,这才叫“按数据驱动开发”,而不是闭门造车写一堆理论正则。

[图片说明占位]

3.4 并发与性能调优

如果应用里需要批量识别多张图片,比如用户一次性导入10张发票,建议用并发任务来控制总耗时。

HarmonyOS NEXT提供了 taskpool ,适合做CPU密集型的并发任务。注意:OCR识别的耗时大头在底层推理引擎,这部分是Native代码,不建议把整个识别过程放到taskpool里和UI协程抢资源,正确的做法是串行或限制并发数。

我自己测试下来,同时并发2个识别任务效果最佳,超过3个反而会因为CPU资源竞争导致单张耗时变长,总吞吐量没有明显提升。原因很简单——端侧推理是CPU密集操作,过度并发会导致线程切换开销反而拖慢整体。

如果要在识别前对图片做压缩,核心原则是控制最长边不超过1600像素。太小的图文字笔画丢失严重,太大的图推理时间成倍增长。实测1080P的原图直接识别,单张耗时约300-500ms(不同机型差异很大);压缩到最长边1280后,耗时能降到200ms左右,精度几乎无损。

4. 常见问题与排查技巧实录

4.1 典型问题速查表

我把实际开发中遇到频率最高的问题整理成了一张速查表,基本可以覆盖90%的踩坑场景。

症状 可能原因 解决办法
调用识别接口直接抛异常 传入的不是PixelMap或PixelMap已释放 检查是否在调用前执行了 release()
返回结果为空数组 图片过小/过暗/文字太模糊 引导用户重新拍摄,或使用系统图片编辑先做裁剪
识别出的中文乱码 输入图片编码异常或PixelMap格式不对 设置 desiredPixelFormat 为 RGBA_8888 重新创建
识别结果明显错字 图像模糊、倾斜角度太大 开启 isDirectionDetectionSupported ,提示用户正对拍摄
偶发崩溃,日志无堆栈 权限未声明 检查 module.json5 中权限声明
图片从相册选完就崩溃 直接把 photoUris 当文件路径用了 使用 fileIo.openSync(uri, ...) 打开
第一次调用后内存暴涨 创建的多个PixelMap没有释放 用完后统一调用 pixelMap.release()
识别结果总是丢行 图片里文字排版复杂、行间距过小 保持图片直拍,避免透视变形,必要时加图像预处理

4.2 上传前的大坑:PixelMap释放与生命周期

排查过程中发现,最容易被忽略的问题是PixelMap的生命周期。在部分版本中,PixelMap一旦被释放,后续再次使用同一个引用会直接崩溃。

典型场景:用户先预览图片,然后点击“识别”按钮。预览时创建了一个PixelMap,识别时又重复创建一个,用完后释放了一个,但另一个仍然在使用。这种问题在真机上很难复现,但在快速点击按钮时就会偶发崩溃。

解决方案是写一个统一管理类,保证“用完即释放、只释放一次”:

class PixelMapManager {
  private static instance: PixelMapManager;
  private currentPixelMap: image.PixelMap | null = null;

  static getInstance(): PixelMapManager {
    if (!PixelMapManager.instance) {
      PixelMapManager.instance = new PixelMapManager();
    }
    return PixelMapManager.instance;
  }

  setPixelMap(pixelMap: image.PixelMap) {
    this.release();
    this.currentPixelMap = pixelMap;
  }

  getPixelMap(): image.PixelMap | null {
    return this.currentPixelMap;
  }

  release() {
    if (this.currentPixelMap) {
      this.currentPixelMap.release();
      this.currentPixelMap = null;
    }
  }
}

4.3 识别效果评估:怎么判断达没达标

拿到一段识别结果,怎么判断识别得好不好?不能靠肉眼感觉,建议用一个简单的量化指标——字符级准确率(Character Accuracy Rate, CAR)。

做法是把识别结果和真实文本做字符比对,公式是:

CAR = 1 - (编辑距离 / 总字符数)

比如真实文本是“100个字符”,识别结果为“99个字符”,编辑距离为1,那CAR就是98%。

手动跑文本比对太累,开发阶段建议做一个批量测试工具:准备20张已知内容的图片,自动跑OCR,自动比对识别文本和预期文本,得出平均准确率。我的经验标准是:常规打印体文档,CAR达到95%以上才算合格;名片、票据等复杂排版场景,CAR到90%以上基本可上线,剩下的由后处理规则兜底。

这个量化标准还有个好处,就是后续如果升级了系统版本、换了测试机型,可以通过同一套测试样本快速对比识别率变化,避免“换了新系统发现识别率下降”这种问题藏到线上才暴露。

4.4 图片方向与旋转的隐藏门槛

一个容易忽略的细节是:文字识别对图片方向有要求。系统提供的 isDirectionDetectionSupported 可以自动检测并校正90/180/270度旋转,但这是有代价的——耗时明显增加,而且个别极端角度(比如45度斜拍)效果仍然不理想。

如果业务场景允许,建议在产品层面做兜底:识别前让用户“手动旋转图片到正确方向”,比依赖算法硬扛更稳。我给应用加了一个“旋转校正”按钮,用户点一下旋转90度,再点一次识别。实测在这种交互模式下,识别准确率提升非常明显,用户成本也不高。

5. 进阶玩法与实际项目反思

5.1 多个文本块的场景怎么排布

通用文字识别不只是识别一整张图的文字,还会按区域划分出多个块。利用这个特性可以做很多有意思的功能,比如文档布局分析:根据每个文本块的坐标,判断它是标题、正文还是表格区域。

我试过的一个场景是身份证识别。身份证正面有固定的文字分布——姓名、性别、民族、出生、住址、公民身份号码。如果OCR返回的文本块坐标分布符合身份证正面布局,就可以按坐标直接定位到“公民身份号码”后面的区域,提升号码提取准确率,而不是全局正则匹配。这种做法比纯正则更稳,对样本变化的容忍度也更高。

5.2 识别速度与准确性的平衡策略

实际项目里,速度和准确性永远是博弈关系。我采用的策略是这样的:

  • 用户主动点击“识别”时,开启方向检测和字符级识别,追求高准确率,等待时间可以长一点。
  • 批量导入自动识别时,关闭方向检测和字符级识别,追求吞吐量,识别后再用后处理规则修正常见错误。
  • 对模糊图片,先做一次直方图均衡化预处理,再走识别流程,能在不明显增加耗时的情况下提升低质量图片的识别率。

5.3 自动识别流程失败后的降级方案

再稳的流程也有失败的时候。用户拍了一张严重反光的票据,怎么预处理都救不回来,识别结果一塌糊涂。这时候不要把识别结果硬塞给用户,体验非常糟糕。

更合理的产品策略是设置降级兜底:

  • 第一步,自动识别;
  • 第二步,识别结果置信度低或提取字段缺失时,自动弹出手动补录页面,把OCR识别出的部分字段预填到表单中,用户只需要补齐缺失的内容;
  • 第三步,如果识别结果整体质量很差(文本量太少或乱码过多),引导用户重新拍摄。

这个降级策略是个很关键的工程经验。通用文字识别不是万能的,好的体验需要靠产品设计和后处理流程来兜底,而不是指望模型能处理所有极端场景。

5.4 日志与调试工具设计

开发期调试OCR问题时,最痛苦的是看不到“模型到底识别成了什么”。我建议在调试模式下把识别结果和原图叠加渲染出来,保存成一张带框图的图片。每个文本块用矩形框标出,旁边标注识别出的文本内容。这样一眼就能看出是检测阶段漏了文字,还是识别阶段认错了字,还是后处理阶段提取规则写错了。

这两个阶段的错误排查完全不同:

  • 检测漏字:问题在输入图像质量或方向校正;
  • 识别错字:问题在图片清晰度或字体复杂度;
  • 提取失败:问题在自己的正则或规则没有覆盖到实际文本格式。

我在工程里加了一个 debug 开关,打开后把带框图的调试图片保存到沙箱目录,开发阶段用起来非常顺手。这个思路移植到其他AI能力的调试上也同样适用。

5.5 踩坑后的几点真实体会

把整个项目复盘一遍,有几个体会值得分享给后来的开发者。

不要过度迷信系统自带能力的“开箱即用”。通用文字识别确实是一段代码就能跑通,但要达到业务可用级别,后处理才是大头。我在正则和后处理上花的时间,大约是调通核心AI接口的3倍。

也不要一上来就想着自研OCR模型。除非你的场景非常垂直且样本量大,否则端侧自研模型投入产出比非常低。先用系统能力把完整业务流程跑通,在真实用户反馈中找出瓶颈,再判断要不要上自研方案,这是更稳妥的技术路线。

最后,数据隐私这件事,在AI能力接入中要比想象中更重要。系统级端侧能力的好处是天然规避了很多合规问题,但如果你接的是云端服务,一定要确保在用户协议、权限申请、数据存储、数据销毁等环节都有完整方案,别等被审核打回来再补。

HarmonyOS的通用文字识别是一个下限不低、上限看人的能力。跑通接口很简单,做到业务可用需要用心打磨,这篇文章里写到的预处理、后处理、降级方案和量化测试方法,都是我实际验证过的经验。照着这条链路走,可以少踩不少坑。最后再多说一句:不管用哪个版本的SDK,动手之前先花10分钟把你工程里对应版本的接口定义和文档通读一遍,比网上找任何教程都管用。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐