32 ImageReceiver 图像接收器

引言

上一篇文章中,"拍照识别文字"工程走的是 PhotoOutput 拍照链路:按下快门 → photoAvailable 回调 → 取出 JPEG Buffer → 交给 OCR 识别。这是一条"一拍一张、按需处理"的链路。但很多场景需要的是"连续帧":对准名片自动识别、扫码、实时取词翻译——此时不可能一秒钟按几十次快门,而是要把相机输出的每一帧图像都接住并处理。

接住连续帧的组件就是本文的主角:image.ImageReceiver(图像接收器)。它在"拍照识别文字"工程里同样存在(Camera.etsreceiver 字段),本文就沿着这个真实工程讲清楚 ImageReceiver 的创建、绑定、事件与读取,并给出"逐帧识别"的完整示例。

正文知识点

createImageReceiver:创建接收器

let receiver: image.ImageReceiver =
  image.createImageReceiver(width, height, format, capacity);

四个参数的含义(源码参考:entry/src/main/ets/common/constants/CommonConstants.ets 中的常量定义):

参数 工程取值 含义

width IMAGE_RECEIVER_WIDTH = 640 接收图像流的宽,决定了每帧图像分辨率
height IMAGE_RECEIVER_HEIGHT = 480 接收图像流的高
format image.ImageFormat.YUV420_SP(常用) 图像格式,也常用 JPEG
capacity IMAGE_RECEIVER_CAPACITY = 8 缓冲队列容量:最多缓存多少帧

工程里 640×480 的选择很讲究:OCR 官方建议输入 720p 以上,但那是针对"拍照大图";对逐帧识别而言,帧率远比单帧分辨率重要,640×480 既能保证文字可读,又能显著压低识别耗时与内存,让"每帧都来得及处理"。capacity = 8 意味着帧产生速度超过消费速度时,最多缓冲 8 帧,再新到的帧会覆盖旧帧——容量越大越不容易丢帧,但内存占用越高。

getReceivingSurfaceId:把 Surface 交给相机

ImageReceiver 本身并不产生图像,它只是提供一块"画布"。相机要往里画帧,需要拿到接收器的 Surface ID 并把它作为输出目标:

let surfaceId: string = await receiver.getReceivingSurfaceId();

工程中封装成了专门的方法(源码参考:entry/src/main/ets/common/utils/Camera.ets):

async getImageReceiverSurfaceId(receiver: image.ImageReceiver): Promise<string | undefined> {
  let photoSurfaceId: string | undefined = undefined;
  if (receiver !== undefined) {
    photoSurfaceId = await receiver.getReceivingSurfaceId();
    Logger.info(TAG, `getReceivingSurfaceId success`);
  }
  return photoSurfaceId;
}

拿到 surfaceId 后,既可以 cameraManager.createPreviewOutput(profile, surfaceId) 把预览画面"重定向"到接收器,也可以作为 PhotoOutput 的拍照目标(createPhotoOutput(profile, surfaceId) 重载)。XComponent 之所以能显示相机预览,也是同一个原理:xcomponentController.getXComponentSurfaceId() 拿到的是 XComponent 的 Surface ID。

imageArrival 事件:帧到了

监听图像到达有两种姿势:

// 姿势一:事件订阅,回调里直接拿 Image(AsyncCallback 形式)
receiver.on('imageArrival', (err: BusinessError, image: image.Image) => {
  // 处理这一帧,用完必须 image.release()
});

// 姿势二:事件只做"通知",帧用 readNextImage 主动取
receiver.on('imageArrival', () => {
  receiver.readNextImage().then((img: image.Image) => { /* ... */ });
});

官方建议:readNextImage 必须在 imageArrival 回调触发后再调用才能正确取到数据;同时存在一个 readLatestImage(),它永远取缓冲队列里最新的一帧,适合"只关心最新画面"的场景(如实时识别),而 readNextImage 按队列顺序取帧,适合"每一帧都要处理"的场景(如录像)。

帧的处理与释放

ImageReceiver 里取出的 image.Image 与普通图片不同,它是对"流内帧"的引用:

img.getComponent(image.ComponentType.JPEG, (err, component) => {
  let buffer = component.byteBuffer;   // 这一帧的编码数据
  // buffer 交给 OCR / 保存 / 编码……
});
img.release();  // 铁律:每帧用完必须 release,否则队列被占满、不再投递新帧

这和工程 photoAvailablephoto.main 的处理如出一辙:取 Component、拿 byteBuffer、用完释放。区别是 photo.main 是一次性照片,而 ImageReceiver 的帧会源源不断到来,每一帧都要释放,漏一帧就少一个缓冲位。

与 PhotoOutput 的职责划分

维度 PhotoOutput(拍照) ImageReceiver(帧流)

触发方式 capture() 主动拍照 imageArrival 被动收帧
数据粒度 单张照片 连续帧
典型用途 高分辨率成片、本文工程的 OCR 拍照 实时识别、扫码、预览分析
释放对象 photo.main 每帧 Image.release()

代码示例

基于工程的相机结构,给出"逐帧 OCR 识别"的完整示例:创建接收器 → 绑定到会话 → 帧到达即识别。

// 源码参考:entry/src/main/ets/common/utils/Camera.ets、CommonConstants.ets
import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';
import { textRecognition } from '@kit.CoreVisionKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import CommonConstants from '../constants/CommonConstants';

export class FrameOcrHelper {
  private receiver: image.ImageReceiver | undefined = undefined;
  private previewOutput: camera.PreviewOutput | undefined = undefined;
  private session: camera.PhotoSession | undefined = undefined;
  private recognizing = false; // 防重入:上一帧识别未完不处理下一帧

  // 1. 创建接收器(640×480,YUV 格式,缓冲 8 帧)
  private createReceiver(): void {
    this.receiver = image.createImageReceiver(
      CommonConstants.IMAGE_RECEIVER_WIDTH,
      CommonConstants.IMAGE_RECEIVER_HEIGHT,
      image.ImageFormat.YUV420_SP,
      CommonConstants.IMAGE_RECEIVER_CAPACITY);
  }

  // 2. 把接收器作为输出加入会话,并注册帧到达监听
  async start(manager: camera.CameraManager, device: camera.CameraDevice): Promise<void> {
    this.createReceiver();
    const surfaceId = await this.receiver.getReceivingSurfaceId(); // 工程同名方法

    // 预览画面输出到接收器(而非 XComponent),帧随预览实时流入
    const capability = manager.getSupportedOutputCapability(device, camera.SceneMode.NORMAL_PHOTO);
    this.previewOutput = manager.createPreviewOutput(capability.previewProfiles[0], surfaceId);

    this.session = manager.createSession(camera.SceneMode.NORMAL_PHOTO) as camera.PhotoSession;
    this.session.beginConfig();
    this.session.addInput(manager.createCameraInput(device));
    this.session.addOutput(this.previewOutput);
    await this.session.commitConfig();
    await this.session.start();

    // 3. 帧到达:读取 → 识别 → 释放,形成消费闭环
    this.receiver.on('imageArrival', () => {
      if (this.recognizing) {
        return; // 上一帧还没识别完,丢弃本帧,避免队列积压
      }
      this.recognizing = true;
      this.receiver?.readNextImage().then(async (frame: image.Image) => {
        try {
          frame.getComponent(image.ComponentType.JPEG, async (err: BusinessError, comp: image.Component) => {
            if (err || comp === undefined) {
              return;
            }
            const text = await this.recognizeFrame(comp.byteBuffer); // 复用工程的识别逻辑
            hilog.info(0x0000, 'FrameOcr', `frame text: ${text}`);
          });
        } finally {
          frame.release(); // 铁律:每帧必须释放
          this.recognizing = false;
        }
      });
    });
  }

  // 4. 复用工程的识别管线(简化,详见第 34 篇)
  private async recognizeFrame(buffer: ArrayBuffer): Promise<string> {
    const source = image.createImageSource(buffer);
    const pixelMap = await source.createPixelMap();
    const visionInfo: textRecognition.VisionInfo = { pixelMap };
    const config: textRecognition.TextRecognitionConfiguration = {
      isDirectionDetectionSupported: true
    };
    try {
      const result = await textRecognition.recognizeText(visionInfo, config);
      return result.value;
    } finally {
      pixelMap.release();
      source.release();
    }
  }

  // 5. 停止:释放接收器(与工程 releaseCamera 一致)
  async stop(): Promise<void> {
    await this.previewOutput?.release();
    await this.session?.release();
    if (this.receiver) {
      await this.receiver.release(); // 源码参考:Camera.ets releaseCamera
    }
  }
}

运行效果与注意事项

  • 必须配消费端:接收器只是缓冲队列,没有人 readNextImage/readLatestImage 消费,队列很快被写满,新帧将无法投递,imageArrival 也不再触发;
  • 每帧必 releaseImage.release() 漏调,队列占满后表现是"画面卡住、事件消失",且不报错,极难排查;
  • 防重入设计:OCR 是耗时操作,一帧没处理完下一帧就到,必须用 recognizing 标志做互斥,否则内存与耗时双双失控;
  • 分辨率与 capacity 是权衡:工程 640×480 + capacity 8 的组合适合逐帧识别;分辨率越高单帧越清晰但耗时越长,容量越大越流畅但内存越高,建议按"单帧处理耗时 < 1/期望帧率"来配置;
  • 与拍照链路共存:ImageReceiver 也可以与 PhotoOutput 同时挂在会话上——接收器负责实时帧分析,拍照输出负责留存高清原图,互不干扰,这正是很多扫描类 App 的做法。

总结

image.ImageReceiver 是鸿蒙相机输出体系中"面向连续帧"的组件:createImageReceiver 定义画布与缓冲,getReceivingSurfaceId 把 Surface 交给相机,imageArrival 通知帧的到来,readNextImage/readLatestImage 消费帧。工程中它作为 receiver 字段存在、在 releaseCamera 中被释放,常量 640×480×8 定义了逐帧识别的平衡点。与拍照链路的本质区别只有一条:照片是一次性的,帧是持续的,每帧必须释放。理解了这一点,拍照识别与实时识别两条链路就能在同一工程里自由切换。

Logo

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

更多推荐