前言

多目标识别封面

在图片编辑页面中,用户打开一张照片后,可能希望先找到照片里的动物、人物或者其他物体,再决定对哪个区域执行操作。应用如果只显示一串识别结果,用户很难知道每个结果对应画面里的什么位置。把检测范围直接画在图片上,才能让识别信息和用户看到的对象对应起来。

HarmonyOS 的 CoreVisionKit 提供多目标识别能力,能够返回对象类别、分数和边界框。我们先把图片交给检测器,再将边界框换算到页面中的实际显示位置。用户更换图片或调整显示区域时,标注也要继续跟随当前图片。

一、让检测器取得图片中的对象

页面需要先把图片解码成像素图,才能提交给视觉接口。工程中的默认图片放在 resources/rawfile,页面启动时读取并生成 PixelMap。用户也可以通过系统图片选择器更换照片,选中的文件会经过同样的解码过程,再交给检测器处理。

图片的宽、高都需要大于 100 像素且小于 10000 像素,目标物体需要占据足够的画面面积。接口对物体占比的要求大于千分之一,所以远处只有几个像素的小对象很难满足输入条件。页面会显示解码后的图片尺寸,遇到处理失败时可以先检查宽高,再考虑图片中对象是否足够清晰。

准备好输入后,调用 ObjectDetector.create() 创建检测器,再把像素图放入 process() 的 inputData。处理结束以后,页面需要的是对象列表,检测器本身不再承担显示任务,因此适配层在结束路径中调用 destroy() 释放它。

    const analyzer = await objectDetection.ObjectDetector.create();
    try {
      // 返回的对象列表包含边界框、类别与分数。
      const data = await analyzer.process({ inputData: { pixelMap: input } });
      result.objects = data.objects;
      result.text = JSON.stringify(data.objects);
      return result;
    } finally { await analyzer.destroy(); }

检测器面向的是当前输入图片。服务返回一个对象时,页面绘制一个框,返回多个对象时则逐个绘制。对象列表为空也可能是一次正常完成的处理,页面需要保留这种状态,不能仅凭结果数量推断接口调用失败。

图片从哪里来和检测器怎样工作可以分开考虑。默认图片便于初次检查调用,选图入口则让用户用自己的内容继续尝试。无论使用哪种入口,页面都在同一次任务中读取当前像素图和返回列表,避免把前一张图的结果误认为新图的检测结果。

初次接入时,可以先使用主体明显、背景相对简单的图片。这样更容易把返回的矩形与画面中的对象对应起来。确认页面能画出正确位置以后,再更换构图更复杂的图片,观察相邻对象或遮挡是否会影响返回结果。每次比较都应保留当次的原图与对象列表,才知道差异来自哪一次输入。

二、从返回结果中找到位置和类别

每个 VisionObject 都包含 boundingBox、labels、score 和 id。其中,boundingBox 描述对象所在的矩形区域,labels 保存类别编号,score 是检测返回的分数,id 用于标识结果中的对象。页面保留整份对象数据,绘制框时就可以从同一项读取相应位置。

boundingBox 的 left 和 top 是矩形左上角在原图中的位置,width 和 height 是矩形的宽高。例如左上角为 (40, 20),宽高为 100 × 80 时,矩形右下角就是 (140, 100)。先把这些值放回原图尺寸中理解,才能判断检测范围是否处在合理位置。

这些坐标属于输入图片,原点位于原图左上角。假如原图宽度为 400 像素,left = 40 表示对象距离图片左边缘 40 个原图像素。页面之后可能把整张图缩小显示,此时不能把数字 40 直接当作界面上的位置,否则框会随着显示尺寸变化而偏离对象。

labels 是数组,应用需要按照 SDK 定义解释其中的类别编号。例如某个编号对应猫头,也只能说明检测器返回了这个类别,不能直接推断猫的品种或身份。页面目前把完整结果显示在详情区,便于开发时查看实际返回的字段。

分数可以作为观察检测结果的一个信息,但业务是否接受某个结果,还取决于误检和漏检造成的影响。如果需要按分数筛选,应使用业务场景中的图片评估阈值。当前页面不增加额外筛选,先让用户看到接口返回的全部对象和位置。

例如,图片整理工具可能允许用户在结果里手动移除误识别的对象,而自动触发后续处理的功能需要更谨慎地对待错误结果。同一个阈值未必适合这两种交互。先观察不同图片的返回情况,再结合用户是否能够纠正结果决定筛选方式,会比直接套用固定数值更有依据。

id 随对象结果返回,可以帮助页面区分同一份列表中的条目。用户选择另一张图片以后,应重新读取新结果,不能把旧列表中的编号当成跨图片固定不变的对象身份。需要连续跟踪视频中的同一个物体时,还要另行处理帧与帧之间的关联。

目标检测与标注流程

取得字段以后,下一步是把原图中的矩形放到页面上。检测返回的位置即使正确,绘制时如果忽略图片缩放或居中留白,用户看到的框仍会偏离对象。因此我们把结果解析和页面坐标计算分开,排查时也能分别检查服务结果和绘制过程。

三、将原图边界框映射到页面

页面使用 ImageFit.Contain 显示图片。这个设置会保持原图宽高比,让完整图片放进容器。原图和容器的形状不同时,图片的一侧会先贴住容器边缘,另一侧则出现留白。标注要覆盖在真实图片上,就需要同时考虑缩放比例和留白。

缩放比例取容器宽度除以原图宽度、容器高度除以原图高度中的较小值。边界框的宽和高乘这个比例,就得到显示后的大小。左上角坐标也先乘相同比例,再加上图片相对于容器左边和上边的偏移量。

例如一张 400 × 200 的图片放进 300 × 300 的容器时,图片按比例显示为 300 × 150。上下各留下 75 个单位的空间。原图中的点 (40, 20) 缩放后是 (30, 15),加上顶部留白后,绘制位置就是容器中的 (30, 90)。

页面把图片和矩形放进同一个 Stack,并把容器左上角作为绘制原点。onAreaChange 用于获取实际容器宽度,切换显示高度后重新计算比例和偏移。这样标注使用的是图片所在区域的尺寸,不需要额外加上标题、按钮或状态栏的位置。

      ForEach(this.objects, (obj: objectDetection.VisionObject) => {
        // 检测框与图片共用同一个容器坐标系。
        Rect().width(obj.boundingBox.width * this.scaleValue()).height(obj.boundingBox.height * this.scaleValue())
          .fillOpacity(0).stroke('#126BD6').strokeWidth(2)
          .position({ x: this.x(obj.boundingBox.left), y: this.y(obj.boundingBox.top) })
      })

这里给矩形设置透明填充,只显示边线。用户依然能看清框内的对象,页面也不需要修改原图的像素。这个框属于界面上的覆盖层,如果以后需要导出带框图片,还要另外把标注绘制到输出图像中。

实际猫头检测结果

显示区域发生变化时,可以对照对象和矩形的位置。若原始 boundingBox 已经偏离对象,应先检查输入图片与检测结果是否属于同一次处理。若原始位置合理、缩放后的框出现整体偏移,则重点检查容器尺寸、图片缩放比例和居中留白。

还可以分别观察框的左上角和右下角。如果两个角偏移的方向和距离接近,通常需要先检查留白是否加错。如果左上角位置基本正确,框越往右下越偏,就要检查宽高是否使用了与图片相同的缩放比例。对照两个位置,比只盯着边线是否盖住对象更容易缩小问题范围。

这套计算建立在图片完整居中显示的前提上。如果页面改用裁切填充,图片有一部分会被容器遮住,缩放与偏移的计算也需要随显示方式调整。绘制层必须始终使用与图片组件一致的布局条件,才能让同一个边界框在不同尺寸下落到正确位置。

四、让标注与当前图片保持对应

用户选择新图片时,页面先解码新输入。解码成功以后,页面替换旧像素图,并清空对象列表和详情。这样新图片出现时不会沿用旧图片的框,等新的检测完成后,页面再根据新结果绘制标注。

如果用户打开选择器后没有选择图片,页面会保留原来的输入和标注。此时图片没有发生变化,旧结果仍然对应当前画面,不需要因为一次取消操作清空它。要是文件打开或解码失败,页面显示错误,用户可以继续选择其他图片。

开始处理时,页面也会先清空上一轮结果,再等待检测器返回。这样当检测失败或者结果为空时,画面不会残留上一次成功的标注。详情和错误信息分别保存,便于区分服务已经完成但没有对象,以及调用过程中发生异常这两种情况。

处理期间,页面暂时禁止重新选择图片和再次发起处理。检测器需要读取输入像素图,如果页面在处理过程中释放旧图并换成新图,正在运行的调用就可能失去它需要的数据。等待任务结束后再开放操作,可以让输入与结果的归属保持清楚。

用户离开页面时,任务层先让尚未返回的结果失效。页面等待当前操作结束,再释放持有的输入图。这里的取消控制的是页面是否接收结果,不承诺立即停止底层检测。这个顺序也让页面退出后的异步结果不会再更新已经离开的界面。

五、把检测结果交给后续操作

检测框为应用提供的是一个矩形范围。图片编辑工具可以据此提示用户选择需要处理的对象,商品页面也可以把区域与一张详情卡片关联。无论采用哪种交互,都应该让卡片内容和绘制所用的边界框来自同一个对象,避免类别与位置错配。

如果一张图中有多个对象,页面需要遍历对象列表,为每个对象分别计算位置。对象之间可能相邻或重叠,因此结果数组中的顺序不等同于画面从左到右的顺序。需要排序时,可以明确使用边界框的位置,或者按照业务自己的规则排列。

界面若增加对象卡片,可以在用户选择卡片时高亮对应的框,也可以从框反向定位卡片。两处状态都应引用同一条对象记录。这样即使页面按位置重新排序,也不会因为列表顺序改变而让选中的说明指向另一个对象。

当前页面只画出边框并显示返回字段,没有提供点击框后的跳转或对象编辑。增加点击能力时,需要把用户点击的位置转换到相同的容器坐标中,再判断点击落在哪个框内。多个框重叠时,还要决定优先选择哪一个对象,让页面反馈与选择结果一致。

边界框内部通常还包含对象周围的背景,因此直接按矩形裁剪会保留一部分背景。如果后续操作需要得到带透明背景的物体轮廓,可以结合上一篇介绍的主体分割。先明确页面需要的是大致位置还是前景像素,再选择相应输出,处理过程也会更容易理解。

总结

我们把输入图片交给多目标识别接口,读取对象列表,再根据图片的显示比例和居中留白绘制边界框。更换图片或调整显示区域以后,页面继续使用当前输入和当前布局计算标注位置。

检测结果中的类别、分数和边界框分别提供对象类型、检测信息与原图位置。ObjectDetector 负责检测,Stack 和 Rect 负责在页面上展示结果,任务管理则保证异步处理完成前输入图片仍然有效。

大家可以选择不同构图的照片,比较返回的对象列表与画面上的边界框。调整显示区域后,再观察标注是否仍然跟随原图,也可以在此基础上尝试增加对象选择入口。

核心关键代码

下面的代码整理为独立的目标检测页面。上面的截图来自包含功能目录的页面,下面的版本启动后直接进入目标检测。请在 entry/src/main/resources/rawfile 中准备 cat.png,并在页面配置中注册 pages/Index。

在 API 26 工程中,检查 entry/src/main/resources/base/profile/main_pages.json 的 src 是否包含 pages/Index,并让 entry/src/main/ets/entryability/EntryAbility.ets 中的 loadContent() 加载这个页面。缺少 services 目录时先创建,再添加以下文件。

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

检测服务:VisionAdapter.ets

将以下代码保存为 entry/src/main/ets/services/VisionAdapter.ets。

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

export class VisionResult {
  text: string = '';
  objects: objectDetection.VisionObject[] = [];
}

export class VisionAdapter {
  async process(kind: string, input: image.PixelMap): Promise<VisionResult> {
    if (kind !== '目标') { throw new Error('未支持的处理功能:' + kind); }
    const result = new VisionResult();
    const analyzer = await objectDetection.ObjectDetector.create();
    try {
      // 返回的对象列表包含边界框、类别与分数。
      const data = await analyzer.process({ inputData: { pixelMap: input } });
      result.objects = data.objects;
      result.text = JSON.stringify(data.objects);
      return result;
    } finally { await analyzer.destroy(); }
  }
}

图片读取: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。

import { image } from '@kit.ImageKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { objectDetection } 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 objects: objectDetection.VisionObject[] = [];
  @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.objects = [];
    this.busy = true;
    try {
      const pm = await this.io.raw('cat.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.objects = []; 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.objects = []; this.detail = ''; this.errorMessage = '';
    try {
      const result = await this.task.run('目标', this.input);
      if (!this.alive) { return; }
      if (result) { this.objects = result.objects; 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(); }

  @Builder
  private markedImage() {
    Stack({ alignContent: Alignment.TopStart }) {
      Image(this.input).width('100%').height(this.viewHeight).objectFit(ImageFit.Contain)
      ForEach(this.objects, (obj: objectDetection.VisionObject) => {
        // 检测框与图片共用同一个容器坐标系。
        Rect().width(obj.boundingBox.width * this.scaleValue()).height(obj.boundingBox.height * this.scaleValue())
          .fillOpacity(0).stroke('#126BD6').strokeWidth(2)
          .position({ x: this.x(obj.boundingBox.left), y: this.y(obj.boundingBox.top) })
      })
    }.width('100%').height(this.viewHeight).backgroundColor('#E8EDF4').borderRadius(12)
      .onAreaChange((_old: Area, area: Area) => { this.viewWidth = Number(area.width); })
  }

  build() {
    Column({ space: 12 }) {
      Text('多目标识别与区域标注').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、测试、元服务和应用上架分发等。

更多推荐