前言

OCR 坐标可视化封面

图片中的文字已经识别出来以后,用户往往还需要回到原图核对。特别是一张通知或表格里有多行内容时,单独显示文字并不能告诉用户每句话位于哪里。如果页面能在原图上标出每一行的区域,用户就可以顺着文字框找到原来的位置。

文字识别服务除了返回文本,也会返回文字行在输入图片中的角点。应用取得这些点以后,还需要考虑图片在页面上是怎样显示的。原图可能被缩小,图片与显示容器之间也可能留白,直接按原坐标画线就会发生偏移。

一、先确定识别坐标与显示图片的关系

页面准备一张包含印刷文字的图片,点击开始处理后,文字识别服务返回整体文本和文字行。每个文字行中的 cornerPoints 保存该行外框的角点,这些坐标以送入识别服务的图片为参照,横向和纵向的数值对应原图中的像素位置。

理解这些数字时,可以把原图左上角当作坐标原点。横坐标向右增加,纵坐标向下增加,一个角点的两个数值共同确定它在图片中的位置。文字行的多个角点按顺序连接后,得到的是包围该行的区域。只读取文字内容时可以暂时不使用这些点,但只要需要把识别范围标回原图,角点与输入图片就必须一起保存。

识别结果和页面预览必须指向同一张图片。页面把准备好的 PixelMap 保存到 input,识别调用和图片组件都读取这个状态。用户更换图片后,页面清空原有文字行,再对新图片执行识别,这样上一张图片的坐标不会继续画在新图上。

选择文字行作为标注单位,是为了让用户核对一句完整内容。服务也会返回行内更细的文字单元,但逐个单元画框会增加画面中的线条数量。当前页面只需要展示文字所在的行,因此先保留行级角点。页面没有实现文字点击或选中,线框主要帮助用户找到原文位置。

原图宽高由 PixelMap.getImageInfo() 取得。这里读取的是解码后实际送入服务的像素尺寸,而不是页面上图片组件的宽高。如果页面显示的图片经过裁剪或旋转,这两个尺寸和坐标的关系就会改变,应用也要把相应变换计入标注。当前页面完整显示输入图,没有增加裁剪和旋转。

例如,同一张照片原本有较大的像素宽高,放进手机页面后通常只占屏幕的一部分。图片组件可以负责缩小显示,识别服务返回的角点却仍然使用处理时的原图尺寸。应用需要同时记住原图有多大、容器实际有多大,才能算出每个原图坐标在页面上对应的位置。

二、根据图片的显示方式计算坐标

页面使用 ImageFit.Contain 显示图片。这个模式保持图片原有宽高比例,让整张图都进入容器。如果图片和容器的比例不同,图片会在一个方向上贴合容器,另一个方向则出现空白。文字框需要落在图片实际占据的区域,因此计算时既要考虑缩放,也要考虑居中后的留白。

假设原图宽高分别为 iw 和 ih,显示区域宽高为 vw 和 vh。横向能容纳的比例是 vw / iw,纵向能容纳的比例是 vh / ih。取两者中较小的值作为统一比例,才能让图片保持原比例并完整显示在容器内。这个比例同时作用于横坐标、纵坐标以及边框的宽高。

图片缩放后的宽度是 iw × s,高度是 ih × s。当前页面将图片居中放入容器,所以容器剩余宽度的一半就是横向偏移,剩余高度的一半就是纵向偏移。

如果原图和容器的宽高比例相同,图片会同时贴合两个方向,横向和纵向偏移都为零。如果容器相对更宽,空白出现在图片左右,横向偏移需要加入坐标。如果容器相对更高,空白就出现在上下,纵向偏移随之生效。同一组公式可以覆盖这几种情况,不需要为横图和竖图分别写一套固定边距。代码分别用 x() 和 y() 把原图中的点转换为容器中的点:

  private scaleValue(): number { return Math.min(this.viewWidth / this.imageWidth, this.viewHeight / this.imageHeight); }
  private x(value: number): number { return (this.viewWidth - this.imageWidth * this.scaleValue()) / 2 + value * this.scaleValue(); }
  private y(value: number): number { return (this.viewHeight - this.imageHeight * this.scaleValue()) / 2 + value * this.scaleValue(); }

这三个方法使用同一组图片尺寸和容器尺寸。原图中的一个点先乘缩放比例,再加对应方向的偏移,得到的才是绘制区域中的位置。如果只乘比例,点的大小关系虽然正确,却会从容器边缘开始计算,图片一旦居中留白,整组标注就会偏移。

可以用一组简单的尺寸检查公式。假设原图为 900 × 1200 像素,容器为 300 × 300,缩放比例取 0.25,图片显示为 225 × 300,左右各留出 37.5。原图中横坐标为 100、纵坐标为 200 的点,映射后应落在容器的 (62.5, 50)。这个例子中的留白直接参与横坐标计算,不需要给文字框另外猜测一个固定边距。

同样按照这组尺寸,原图左上角会映射到容器中的 (37.5, 0),右下角会映射到 (262.5, 300)。两个点之间的宽度正好是缩放后的 225,也都落在图片实际显示的范围内。先检查图片边界,再检查文字角点,可以帮助区分公式中的缩放问题和偏移问题。

坐标映射流程

原图尺寸使用像素,容器宽高来自界面布局。比例本身把两组尺寸联系起来,后续绘制也在同一容器中使用布局坐标。页面不需要额外拿屏幕密度再乘一次,否则图片和线框会使用不同的换算结果。

三、让图片与文字框使用同一个原点

确定映射公式后,还要保证图片和线框画在同一个区域。页面使用左上角对齐的 Stack,先放置图片,再遍历文字行并绘制 Polyline。图片和折线都使用这个容器的宽高,折线的位置从容器左上角开始计算,因此不必再加标题、按钮或状态栏的高度。

onAreaChange() 把容器的实际宽度保存到 viewWidth,高度则由 viewHeight 状态控制。页面布局变化时,保存的宽度随之更新,映射函数会使用新的容器尺寸。把整个屏幕的宽度直接代入公式并不可靠,因为图片区域还会受到页面边距和父容器布局影响。

这里的宽度需要在布局完成后取得。页面刚创建时,父容器可能还没有给图片区域分配最终空间,所以代码先为 viewWidth 提供初值,再用区域变化回调写入实际宽度。宽度或者高度状态变化以后,界面重新计算折线使用的点,图片和边框便按照同一次布局结果显示。

一行文字返回的是多个角点。页面先按照返回顺序逐个转换,再把第一个点添加到数组末尾,让折线回到起点。这样画出的区域能够保留文字行原有的四边形形状:

  private polygon(points: textRecognition.PixelPoint[]): Array<[number, number]> {
    const mapped: Array<[number, number]> = [];
    points.forEach((point) => { mapped.push([this.x(point.x), this.y(point.y)]); });
    if (points.length > 0) { mapped.push([this.x(points[0].x), this.y(points[0].y)]); }
    return mapped;
  }

线框只设置边线,填充保持透明,所以原图中的文字仍然可以阅读。页面遍历的是当前识别得到的 lines,每个文字行对应一组角点。某一行没有进入数组时,页面也就没有数据为它画框,此时需要先检查识别返回,再检查绘制过程。

识别成功并不代表每张图片都会得到相同数量的文字行。如果服务返回的文字行数组为空,绘制循环就没有线框可画,页面仍可以保留原图供用户检查。此时可以先看输入文字是否清晰、是否进入识别范围,再结合返回内容判断原因,不必先修改缩放公式。

页面保存角点和显示尺寸的时机也要对应。输入图片解码完成后先读取原图宽高,识别结果返回后才更新文字行。用户换图时则先清空旧行,再更新图片和尺寸。这样界面不会拿上一张图片的坐标去计算新图片的边框。

四、改变显示区域,检查标注是否跟随

初次显示时,页面把图片区域高度设为 300。点击切换显示区域以后,高度改为 210,原有文字行不变。页面根据新的高度重新计算比例和留白,再把同一组角点映射到新的显示区域,因此不需要为这次布局变化重新调用识别服务。

较高显示区域的文字框

缩小显示区域后的文字框

检查时可以选择一行靠近图片边缘的文字,观察缩放前后边框是否仍然覆盖同一行。如果边框大小跟着变化,整体却向一侧偏移,应先检查图片的居中留白。如果边框越远离原点偏差越大,则要检查原图尺寸、容器尺寸和缩放比例,确认没有把屏幕宽度当作图片区域宽度。

改变高度会同时影响图片的显示比例和留白。沿用旧比例只调整边框起点,或者只缩小边框却不重新计算偏移,都可能让第一次对齐的结果在尺寸变化后再次错开。页面把尺寸保存在状态中,就是为了让每次绘制都使用当前的图片区域。

同一张图经过识别后,页面可以反复切换显示高度来观察映射。用户选中其他图片时,原有坐标则会被清空,需要重新识别新输入。这样布局变化只影响坐标换算,图片内容变化才会产生新的识别结果。

五、排查坐标偏移时先确认输入

如果所有文字框的大小看起来合适,却整体偏向同一个方向,可以先比较图片显示起点和 Stack 的原点。图片实际从留白之后开始显示,折线却从容器边缘计算时,就会出现整体平移。检查时应确认偏移只计算了一次,既没有遗漏容器内留白,也没有重复加入页面外边距。

如果离原点越远的文字框偏差越大,通常需要重新检查比例。原图尺寸应来自当前 PixelMap,容器宽度应来自承载图片和折线的区域。只要其中一个值来自另一张图片或另一层容器,公式本身即使正确,算出的点也不会落在预期位置。

两个位置不同的文字行可以提供对照:如果它们偏移的距离大致相同,问题更可能出在原点或留白。如果靠近原点的一行接近正确,远处的一行却偏得更明显,就要重点检查比例和宽高来源。沿着这个顺序核对,可以先找出误差从哪一步出现,再决定修改哪一处计算。

当前公式依赖图片完整显示并居中放置。如果产品改用裁剪填充,图片的一部分可能位于容器之外,应用就要按照新的显示方式计算裁剪偏移。调整公式以前,先确定用户实际看到的是整张图片还是裁剪后的区域,再选择相应的映射关系。

总结

文字识别返回的行角点以原图为参照。页面先读取图片和容器的尺寸,再按 ImageFit.Contain 的缩放比例及居中留白转换坐标,最后在图片所在的 Stack 中绘制线框。

这套处理把 PixelMap 的像素尺寸、cornerPoints、区域变化回调和 Polyline 连接起来。用户更换图片时重新取得识别结果,页面尺寸变化时则只需要重新计算标注位置。

大家可以先用自己的文字图片试一次识别,再切换显示区域,观察不同位置的文字框是否仍然对齐。也可以更换宽高比例不同的图片,检查留白改变以后坐标是否仍然落在原文附近。

核心关键代码

截图中的页面带有功能目录。下面的代码整理为独立的 OCR 标注页面,启动后直接显示识别界面。

在自己的工程中准备一张包含印刷文字的 PNG 图片,命名为 ocr.png,放入 entry/src/main/resources/rawfile。在 entry/src/main/ets 下创建 services 目录,再按文件名添加识别、图片读取和任务管理代码。

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

将文件放在 entry/src/main/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

将文件放在 entry/src/main/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,并让入口 Ability 的 loadContent() 加载该页面。

import { image } from '@kit.ImageKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { textRecognition } from '@kit.CoreVisionKit';
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;
  @State viewWidth: number = 320;
  @State viewHeight: number = 300;
  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; } }
  }

  // Contain 保持图片比例,坐标缩放后还需要补上居中留白。
  private scaleValue(): number { return Math.min(this.viewWidth / this.imageWidth, this.viewHeight / this.imageHeight); }
  private x(value: number): number { return (this.viewWidth - this.imageWidth * this.scaleValue()) / 2 + value * this.scaleValue(); }
  private y(value: number): number { return (this.viewHeight - this.imageHeight * this.scaleValue()) / 2 + value * this.scaleValue(); }

  private polygon(points: textRecognition.PixelPoint[]): Array<[number, number]> {
    const mapped: Array<[number, number]> = [];
    points.forEach((point) => { mapped.push([this.x(point.x), this.y(point.y)]); });
    // 把首点加入末尾,闭合文字行的四边。
    if (points.length > 0) { mapped.push([this.x(points[0].x), this.y(points[0].y)]); }
    return mapped;
  }

  @Builder
  private markedImage() {
    Stack({ alignContent: Alignment.TopStart }) {
      Image(this.input).width('100%').height(this.viewHeight).objectFit(ImageFit.Contain)
      ForEach(this.lines, (line: textRecognition.TextLine) => {
        Polyline().points(this.polygon(line.cornerPoints)).stroke('#176BDE').strokeWidth(1.4).fillOpacity(0)
          .width(this.viewWidth).height(this.viewHeight).position({ x: 0, y: 0 })
      })
    }.width('100%').height(this.viewHeight).backgroundColor('#E8EDF4').borderRadius(12)
      .onAreaChange((_old: Area, area: Area) => { this.viewWidth = Number(area.width); })
  }

  build() {
    Column({ space: 12 }) {
      Text('OCR 坐标可视化').fontSize(23).fontWeight(FontWeight.Bold).fontColor('#142B52').width('100%')
      this.markedImage()
      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).onClick(() => {
        this.viewHeight = this.viewHeight === 300 ? 210 : 300;
      })
      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、测试、元服务和应用上架分发等。

更多推荐