前言

通用文字识别封面

开会时,用户常把纸质资料拍成照片,放进自己的笔记。照片保留了现场内容,但需要引用其中一句话或搜索某个日期时,用户还得对着图片重新输入。如果笔记页面能够提取图片里的文字,用户就可以直接复制或搜索,再对照原图检查识别结果。

从图片中提取文字通常称为光学字符识别,也就是 OCR。HarmonyOS 的 Core Vision Kit 提供通用文字识别能力,接收图片后可以返回文字内容和文字所在的区域。应用需要先准备图片,把它交给识别服务,再把返回的文字放到页面上。用户从哪里选择图片、怎样查看和复制结果,还需要由应用页面负责。

当前页面打开后会显示一张预置的文字图片,用户也可以换成自己选择的照片。无论图片来自哪里,识别服务接收的都是解码后的像素图,因此页面需要先把图片准备成接口需要的数据。

一、从图片文件得到可识别的像素图

页面上的图片有两种来源:工程中准备的 ocr.png 和用户从相册中选择的照片。前者让页面打开后就有一张可以处理的图片,后者让同一个识别入口能够接收用户自己的内容。识别接口接收的 PixelMap 是保存图片像素数据的对象。无论图片来自工程资源还是相册,页面都要先把图片解码成 PixelMap,才能交给识别服务。

将默认图片 ocr.png 放在 entry/src/main/resources/rawfile 目录下,页面进入通用文字识别功能时就会读取这张图片。ImageStore.ets 中的 raw() 方法负责读取应用资源,再通过 ImageKit 创建像素图。页面用当前 Ability 的上下文创建 ImageStore,类中的 context.resourceManager 因此能够读取应用包内的图片资源。

  async raw(name: string): Promise<image.PixelMap> {
    const bytes = await this.context.resourceManager.getRawFileContent(name);
    const source = image.createImageSource(bytes.buffer as ArrayBuffer);
    try {
      return await source.createPixelMap({ desiredPixelFormat: image.PixelMapFormat.RGBA_8888 });
    } finally { source.release(); }
  }

getRawFileContent() 取得的是图片文件中保存的编码数据,识别服务还不能直接使用这些数据。createImageSource() 根据文件内容创建图像源,应用再调用 createPixelMap() 完成解码,得到可以读取像素的 PixelMap。这里指定的 RGBA_8888 描述解码后的颜色数据:每个像素包含红、绿、蓝和透明度四个通道,每个通道占 8 位。文字识别接口需要这种格式的像素图,因此代码在解码时就设置好格式。解码结束后,finally 会调用图像源的 release(),返回的像素图继续由页面持有。

页面中的 prepare() 会调用这个方法读取 ocr.png,再把得到的像素图保存为当前输入。如果想更换页面打开时显示的图片,需要把新图片放进应用的 rawfile 目录,并修改这里使用的资源文件名。文件名与资源不匹配时,页面会显示错误信息。这时默认图片还没有准备好,可以先检查文件是否位于当前模块的 rawfile 目录,再核对名称和大小写是否与代码一致。

页面把返回的像素图保存到 input 状态,再用 Image(this.input) 显示图片。用户点击开始处理时,页面同样从 input 取得像素图,所以识别服务接收到的图片与页面当前显示的图片保持一致。图片尚未加载时,input 的值为 undefined,处理入口会检查这个状态,避免把空输入传给识别服务。页面取得像素图后还会读取实际宽高,并显示输入已就绪,供用户在处理前确认图片已经加载。

待识别图片

默认图片准备好以后,用户还可以点击选择图片,换成相册中的照片。系统照片选择器负责让用户选择文件,返回结果中的 photoUris 保存所选图片的资源地址。应用需要先根据地址打开文件,再把文件内容解码成识别服务需要的像素图。ImageStore 的 choose() 方法会调用照片选择器和文件接口,这两个接口分别从 MediaLibraryKit 和 CoreFileKit 导入。

  async choose(): Promise<image.PixelMap | undefined> {
    const picker = new photoAccessHelper.PhotoViewPicker();
    const result = await picker.select({ MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE, maxSelectNumber: 1 });
    if (result.photoUris.length === 0) { return undefined; }
    const file = fileIo.openSync(result.photoUris[0], fileIo.OpenMode.READ_ONLY);
    let source: image.ImageSource | undefined = undefined;
    try {
      source = image.createImageSource(file.fd);
      return await source.createPixelMap({ desiredPixelFormat: image.PixelMapFormat.RGBA_8888 });
    } finally { source?.release(); fileIo.closeSync(file); }
  }

选择器的 MIMEType 指定图片类型,maxSelectNumber 将选择数量限制为一张,因此应用只需要读取 photoUris 中的第一个地址。openSync() 根据这个地址打开文件,并将返回的文件描述符交给 createImageSource()。图像源读取文件内容,再将图片解码成 RGBA_8888 格式的像素图。文件在解码期间需要保持打开,等 createPixelMap() 返回以后,代码才关闭文件。默认资源和相册图片虽然来自不同的位置,最终都会变成 PixelMap,后面的识别方法就可以使用同一种输入。

choose() 的返回类型允许 PixelMap 和 undefined 两种结果。用户完成选择并成功解码后,方法返回新的像素图,页面拿到它以后才替换原有图片。如果用户没有选中图片,选择器返回的 photoUris 可能为空,choose() 此时会返回 undefined。页面收到空结果后继续保留原有输入,用户仍然可以识别这张图片。选图、文件打开或解码出错时,页面会显示错误信息。对于已经打开的文件,解码代码会在 finally 中调用图像源的 release(),并关闭文件。

二、把像素图交给文字识别服务

图片已经显示在页面上,点击开始处理时,页面会把当前 PixelMap 和 OCR 类型交给 VisionTask,再由任务调用 VisionAdapter。页面管理按钮与文字区域,适配层负责与识别服务交互。这样用户换图后再次处理时,送入服务的就是页面当前持有的图片。

文字识别是异步调用,页面发出请求后需要等待服务返回。开始处理时,页面会清空上一次的文字和文字行,并将状态设为处理中,避免用户在等待期间把旧内容当成本次结果。等识别结束或发生错误以后,页面再恢复按钮,用户就可以继续选择图片或重试。

等待期间如果允许用户再选择另一张图片,界面可能已经切到新图,旧图的结果却随后返回。页面因此在处理期间禁用选择图片和开始处理按钮,任务入口也会拦住重复操作。同一进程同时调用同一项视觉能力还可能收到系统繁忙错误,单张图片按顺序处理能够避免这类并发请求。

识别服务需要先完成初始化,才能开始处理图片。VisionAdapter 的 OCR 分支会等待 init() 返回,初始化成功以后,再把像素图传给 recognizeText()。调用时还会开启文字朝向检测,让服务识别图片中文字的方向。

        if (!await textRecognition.init()) { throw new Error('OCR 初始化失败'); }
        const data = await textRecognition.recognizeText({ pixelMap: input }, { isDirectionDetectionSupported: true });

input 是页面准备好的像素图,适配层把它放进 pixelMap 字段,再交给识别接口。用户从相册选择的照片可能有不同的文字方向,isDirectionDetectionSupported 设置为 true 后,服务会在识别时检测文字朝向。页面保留输入图片,方便用户拿到结果以后对照检查。如果初始化返回失败,代码会抛出错误,页面随后显示失败信息,识别接口也不会继续调用。

文字识别调用关系

无论识别成功还是发生异常,适配层都会进入 finally,调用 release() 释放文字识别服务。页面仍然持有输入像素图,不会在释放服务时一并释放图片。这样识别结果出现以后,页面还能展示原图,用户也可以对照原图检查文字。

三、把返回的文字放到页面上

识别服务返回的是一个结果对象,其中的 value 保存整体文字,blocks 按文本块组织识别内容。每个文本块又包含若干文字行,行中的 value 保存文字内容,cornerPoints 记录这行在图片中的位置,words 保存更细的文字单元。页面要让用户直接阅读和复制时,可以使用整体文字。如果后续需要找到某一行在图片中的位置,应用还可以使用返回的文字行。

一个文本块可能包含多行文字。如果应用只读取每个块的第一行,同一个块里的其他行就不会进入按行处理的结果。因此,适配层需要遍历每个文本块,再遍历其中的 lines,把识别出的文字行收集起来。

        result.text = data.value;
        data.blocks.forEach((block) => { block.lines.forEach((line) => { result.lines.push(line); }); });

代码把整体文字存入 result.text,同时把各个文本块中的行对象收集到 result.lines。整体文字适合直接展示,但单独保存字符串无法保留每行在图片中的位置。行对象把文字和位置放在一起,应用需要按行处理时,可以继续使用这次识别返回的数据,不必重新识别图片。

VisionTask 将结果交回页面以后,页面把 result.text 赋给 detail 状态,并保存文字行。界面通过 Text(this.detail) 显示文字。由于 detail 使用 @State 管理,识别结果写入状态以后,文字组件也会更新。用户可以对照页面保留的原图检查文字,再决定是否复制。

实际识别结果

四、让识别结果跟随图片变化

文字显示出来以后,用户可以点击复制识别文字。页面先取得系统剪贴板,再用 pasteboard.createData() 把 detail 包装成剪贴板数据,其中 MIMETYPE_TEXT_PLAIN 指定内容为纯文本。等 setData() 写入完成,页面才显示复制成功,用户随后就可以将文字粘贴到笔记或聊天输入框中。

换图时还要处理上一张图片的结果。如果页面已经显示新图,文字区域却保留旧结果,用户就可能把上一张图片的内容当成新结果复制。为此,新图片解码成功后,页面会替换原有像素图,同时清空旧文字。处理空闲时,复制按钮会检查 detail 是否为空,因此清空旧文字后,用户暂时不能复制之前的内容。新图片识别成功并返回文字后,按钮会重新可用。

选图或识别发生异常时,fail() 会把错误码和消息写入 errorMessage,页面状态也会显示处理失败。排查时可以先回想失败发生在哪一步。如果照片选完就出现错误,可以检查图片地址能否打开,以及文件能否解码。如果页面已经进入处理中才失败,则可以结合错误信息检查像素格式和识别服务的调用。

页面将识别文字保存在 detail,错误详情保存在 errorMessage,两者分别显示。每次开始识别时,页面会清空上一次的文字,复制按钮也随之变为不可用。识别失败时,错误信息只会更新 errorMessage,因此不会让空的识别文字变成可复制状态。如果剪贴板写入失败,页面仍会保留已经识别的文字,用户可以再次尝试复制。

识别是异步执行的。如果用户在结果返回前离开页面,并且页面组件随之销毁,recognizeText() 可能还在运行。服务在这段时间里可能仍在读取输入的 PixelMap,如果页面一退出就释放图片,仍在执行的任务就可能访问已经释放的数据。因此,页面需要等这次调用结束以后,再释放自己持有的图片。

Index.ets 中的页面组件即将销毁时,aboutToDisappear() 会把 alive 设为 false,并启动 dispose() 清理。清理方法先通知 VisionTask 取消当前结果,让这次任务返回的数据失效。这个取消不会中断已经开始的识别调用,所以图片在任务结束以前仍需要保持有效。

dispose() 会等待 pagePending 保存的页面任务结束。识别接口返回或抛出错误后,适配层执行 finally 中的 release(),任务才能继续结束。由于 alive 已经是 false,即使任务返回结果,页面也不会再用结果更新界面。等页面任务结束以后,dispose() 再调用 input.release() 清理像素图,让它在识别服务使用期间保持有效。

五、让用户选择的图片进入同一流程

默认图片能够识别以后,用户选择的照片还需要满足同样的输入要求。文字识别支持 JPEG、JPG 和 PNG 图片,输入宽度要大于 100 且小于 10000 像素,高度要大于 100 且小于 15210 像素。页面会显示解码后的宽高,但目前没有在选图后增加尺寸校验。如果图片超出范围,需要先调整尺寸或更换图片,再进行识别。

这里判断的是图片的像素宽高,文件在相册中显示的存储大小不能直接代替这个尺寸。不同图片的内容和压缩方式不同,即使文件大小接近,解码后的宽高也可能不同。因此,页面读取 PixelMap 的图像信息,才能知道送入识别服务的图片有多宽、多高。

尺寸符合要求以后,还要看图片中是否保留了足够清楚的文字。拍摄时的遮挡、阴影和裁切都可能让部分文字难以辨认,缩小大图时也可能丢失小字细节。服务支持简体中文、繁体中文、英文、日文和韩文的文档印刷体。如果用户的照片中有较多手写内容,识别效果可能与印刷体不同。

总结

这次完成了从图片到文字的处理过程:页面读取默认图片或用户选择的照片,将图片解码成像素图,再交给识别服务。服务返回以后,页面展示文字并提供复制入口,用户可以把结果与原图放在一起核对。

图片准备使用 PhotoViewPicker 和 ImageKit,其中 PixelMap 将两种来源的图片统一为识别输入。textRecognition 返回整体文字和文本行,页面结合任务状态管理识别、结果展示、复制和资源释放。

大家可以先在工程中准备一张包含印刷文字的图片,运行识别并查看页面返回的内容,再从相册中选择另一张文字清晰的照片尝试复制结果。比较两次输入与输出,能够更直观地理解图片解码和文字识别在应用中怎样衔接。

核心关键代码

文中的页面来自带有功能目录的应用。下面的代码将文字识别整理成独立页面,启动后直接进入识别界面。

在自己的 HarmonyOS 工程中准备一张包含印刷文字的 PNG 图片,命名为 ocr.png,放入 entry/src/main/resources/rawfile。如果 services 或 rawfile 目录尚不存在,需要先创建目录,再按下面的文件名添加代码。

Core Vision Kit 的视觉处理需要在支持该能力的真机上运行,模拟器暂不支持本篇调用。服务区域为中国境内,不含港澳台。

识别服务:VisionAdapter.ets

在 entry/src/main/ets/services 目录下创建 VisionAdapter.ets。这个文件负责初始化识别服务,返回整体文字和文字行,并在调用结束时释放服务。

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

export class VisionResult {
  text: string = '';
  lines: textRecognition.TextLine[] = [];
}

export class VisionAdapter {
  async process(kind: string, input: image.PixelMap): Promise<VisionResult> {
    const result = new VisionResult();
    if (kind === 'OCR') {
      try {
        // 初始化成功后再提交像素图。
        if (!await textRecognition.init()) { throw new Error('OCR 初始化失败'); }
        const data = await textRecognition.recognizeText({ pixelMap: input }, { isDirectionDetectionSupported: true });
        result.text = data.value;
        // 保存文字行,保留每行的位置等信息。
        data.blocks.forEach((block) => { block.lines.forEach((line) => { result.lines.push(line); }); });
        return result;
      // 识别完成或发生异常时,都结束本次服务调用。
      } finally { await textRecognition.release(); }
    }
    throw new Error('未支持的处理功能:' + kind);
  }
}

图片读取:ImageStore.ets

在同一个 services 目录下创建 ImageStore.ets。页面通过它读取默认图片或用户选择的照片,并把图片解码成识别接口需要的格式。

import { image } from '@kit.ImageKit';
import { common } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';

export class ImageStore {
  private context: common.UIAbilityContext;
  constructor(context: common.UIAbilityContext) { this.context = context; }

  async raw(name: string): Promise<image.PixelMap> {
    const bytes = await this.context.resourceManager.getRawFileContent(name);
    const source = image.createImageSource(bytes.buffer as ArrayBuffer);
    try {
      // 将应用资源解码成识别服务需要的像素格式。
      return await source.createPixelMap({ desiredPixelFormat: image.PixelMapFormat.RGBA_8888 });
    } finally { source.release(); }
  }

  async choose(): Promise<image.PixelMap | undefined> {
    const picker = new photoAccessHelper.PhotoViewPicker();
    const result = await picker.select({ MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE, maxSelectNumber: 1 });
    if (result.photoUris.length === 0) { return undefined; }
    const file = fileIo.openSync(result.photoUris[0], fileIo.OpenMode.READ_ONLY);
    let source: image.ImageSource | undefined = undefined;
    try {
      source = image.createImageSource(file.fd);
      return await source.createPixelMap({ desiredPixelFormat: image.PixelMapFormat.RGBA_8888 });
    // 解码完成或失败后,释放图像源并关闭已打开的文件。
    } finally { source?.release(); fileIo.closeSync(file); }
  }
}

任务管理:VisionTask.ets

在 services 目录下创建 VisionTask.ets。任务负责限制重复调用,并在页面取消时丢弃过期结果,等待底层识别结束后再开放下一次处理。

import { image } from '@kit.ImageKit';
import { VisionAdapter, VisionResult } from './VisionAdapter';

export class VisionTask {
  busy: boolean = false;
  state: string = '待处理';
  private generation: number = 0;
  private pending: Promise<VisionResult> | undefined = undefined;

  cancel(): void {
    if (!this.busy) { return; }
    // 更换任务标识,让正在执行的调用结束后丢弃旧结果。
    this.generation++;
    this.state = '正在等待底层调用结束,结果将被丢弃';
  }

  async run(kind: string, input: image.PixelMap): Promise<VisionResult | undefined> {
    if (this.busy) { throw new Error('上一次处理仍未结束'); }
    this.busy = true;
    this.state = '处理中';
    const token = ++this.generation;
    // 超时只使结果失效,不提前释放仍被SDK使用的输入,也不开放新的任务槽位。
    const timer = setTimeout(() => { if (this.busy && token === this.generation) { this.cancel(); } }, 30000);
    try {
      this.pending = new VisionAdapter().process(kind, input);
      const result = await this.pending;
      // 页面取消后,任务标识不再匹配,结果不会交回页面。
      if (token !== this.generation) {
        this.state = '已取消,可重新处理';
        return undefined;
      }
      this.state = '处理完成';
      return result;
    } catch (err) {
      this.state = token === this.generation ? '处理失败,可检查输入后重试' : '已取消,可重新处理';
      if (token === this.generation) { throw err as Error; }
      return undefined;
    } finally {
      clearTimeout(timer);
      this.pending = undefined;
      this.busy = false;
    }
  }

  async dispose(): Promise<void> {
    this.cancel();
    try { await this.pending; } catch (_) { /* 运行入口已经接收异常,销毁阶段只等待资源可释放。 */ }
  }
}

页面入口:Index.ets

将页面代码放在 entry/src/main/ets/pages/Index.ets。检查 entry/src/main/resources/base/profile/main_pages.json 的 src 数组是否包含 pages/Index,如果没有就将它加入数组。再检查 entry/src/main/ets/entryability/EntryAbility.ets,让 loadContent() 加载 pages/Index。页面会准备图片、调用识别服务、展示结果并提供复制操作。

import { image } from '@kit.ImageKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { textRecognition } from '@kit.CoreVisionKit';
import { pasteboard } from '@kit.BasicServicesKit';
import { ImageStore } from '../services/ImageStore';
import { VisionTask } from '../services/VisionTask';

@Entry
@Component
struct Index {
  @State input: image.PixelMap | undefined = undefined;
  @State status: string = '准备输入图片';
  @State detail: string = '';
  @State errorMessage: string = '';
  @State busy: boolean = false;
  @State lines: textRecognition.TextLine[] = [];
  @State imageWidth: number = 0;
  @State imageHeight: number = 0;
  private alive: boolean = true;
  private activeJob: boolean = false;
  private pagePending: Promise<void> | undefined = undefined;
  private task: VisionTask = new VisionTask();
  private context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  private io: ImageStore = new ImageStore(this.context);

  aboutToAppear(): void { this.alive = true; this.launch(() => this.prepare()); }
  aboutToDisappear(): void { this.alive = false; this.dispose(); }

  private launch(action: () => Promise<void>): void {
    if (this.activeJob || !this.alive) { return; }
    this.activeJob = true;
    // 保存页面任务,离开页面时等待它结束。
    this.pagePending = action().catch((err: BusinessError) => { if (this.alive) { this.fail(err); } })
      .finally(() => { this.activeJob = false; });
  }

  private async dispose(): Promise<void> {
    this.task.cancel();
    // 已发出的识别可能仍在使用图片,先等待页面任务结束。
    try { await this.pagePending; } catch (_) { /* 错误已由任务入口处理。 */ }
    this.input?.release(); this.input = undefined;
  }

  private async prepare(): Promise<void> {
    if (this.busy || this.task.busy) { return; }
    this.detail = ''; this.errorMessage = ''; this.status = '准备输入图片'; this.lines = [];
    this.busy = true;
    try {
      const pm = await this.io.raw('ocr.png');
      if (!this.alive) { pm.release(); return; }
      this.input?.release(); this.input = pm;
      const info = await pm.getImageInfo(); this.imageWidth = info.size.width; this.imageHeight = info.size.height;
      this.status = '输入已就绪:' + this.imageWidth + ' × ' + this.imageHeight;
    } catch (err) { this.fail(err as BusinessError); } finally { if (this.alive) { this.busy = false; } }
  }

  private fail(err: BusinessError): void {
    if (!this.alive) { return; }
    this.status = '处理失败,可检查输入后重试';
    // 错误与识别文字分开保存,避免复制到错误内容。
    this.errorMessage = '错误码:' + (err.code ?? -1) + '\n' + err.message;
  }

  private async choose(): Promise<void> {
    if (this.busy) { return; }
    this.busy = true; this.errorMessage = '';
    try {
      const pm = await this.io.choose();
      if (!this.alive) { pm?.release(); return; }
      if (!pm) { this.status = '未选择图片,保留原输入'; return; }
      // 新图准备成功后,再替换原图并清空旧识别结果。
      this.input?.release(); this.input = pm; this.lines = []; this.detail = '';
      const info = await pm.getImageInfo(); this.imageWidth = info.size.width; this.imageHeight = info.size.height;
      this.status = '已选择:' + this.imageWidth + ' × ' + this.imageHeight;
    } catch (err) { this.fail(err as BusinessError); } finally { if (this.alive) { this.busy = false; } }
  }

  private async run(): Promise<void> {
    if (this.busy || !this.input) { return; }
    this.busy = true; this.status = '处理中'; this.lines = []; this.detail = ''; this.errorMessage = '';
    try {
      const result = await this.task.run('OCR', this.input);
      if (!this.alive) { return; }
      if (result) { this.lines = result.lines; this.detail = result.text; }
      if (this.alive) { this.status = this.task.state; }
    } catch (err) { if (this.alive) { this.fail(err as BusinessError); } }
    finally { if (this.alive) { this.busy = false; } }
  }

  private async copyText(): Promise<void> {
    this.errorMessage = '';
    try {
      const board = pasteboard.getSystemPasteboard();
      await board.setData(pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, this.detail));
      this.status = '识别文字已复制';
    } catch (err) { this.fail(err as BusinessError); }
  }

  build() {
    Column({ space: 12 }) {
      Text('通用文字识别').fontSize(23).fontWeight(FontWeight.Bold).fontColor('#142B52').width('100%')
      Stack({ alignContent: Alignment.TopStart }) {
        Image(this.input).width('100%').height(300).objectFit(ImageFit.Contain)
      }.width('100%').height(300).backgroundColor('#E8EDF4').borderRadius(12)
      Row({ space: 8 }) {
        Button('选择图片').enabled(!this.busy).onClick(() => { this.launch(() => this.choose()); })
        Button('开始处理').enabled(!this.busy).onClick(() => { this.launch(() => this.run()); })
      }
      Button('复制识别文字').enabled(!this.busy && this.detail.length > 0)
        .onClick(() => { this.launch(() => this.copyText()); })
      Text(this.status).fontSize(15).fontColor('#145897').width('100%')
      if (this.errorMessage) { Text(this.errorMessage).fontSize(14).width('100%') }
      Scroll() { Text(this.detail).fontSize(14).width('100%').copyOption(CopyOptions.LocalDevice) }.layoutWeight(1)
    }.width('100%').height('100%').padding({ top: 16, left: 18, right: 18, bottom: 16 }).backgroundColor('#F1F4F9')
  }
}
Logo

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

更多推荐