《HarmonyOS技术精讲-Core Vision Kit》第8篇:图像标签分类

《HarmonyOS技术精讲-Core Vision Kit》第8篇:图像标签分类

实际开发中的问题

HarmonyOS NEXT 开发里,Core Vision Kit 的图像标签分类能力经常被误解。很多人第一次接触这个 API 时,会觉得"不就是调用个接口返回几个标签吗",但实际项目里,问题往往出在模型加载时机、结果解析粒度、以及如何把分类结果跟 UI 状态正确同步上。

传统做法是用离线模型库做分类,比如 TensorFlow Lite 之类的方案,但 HarmonyOS NEXT 自带的 Core Vision Kit 在设备端推理时,天然适配了鸿蒙的线程调度机制,不需要额外处理模型文件的分发和版本管理问题。不过,它的 API 设计风格跟传统 CV 库有差异,如果不熟悉 ArkUI 的状态管理机制,很容易写出"能运行但交互卡顿"的代码。

这篇文章就从实际开发角度,把 ImageClassifier 的全流程拆开讲清楚。

图像标签分类解决什么问题

图像标签分类,核心能力是:输入一张图片,输出一组描述图片内容的标签及对应的置信度

比如一张海滩照片,模型可能输出:beach (0.95)ocean (0.88)sky (0.82)sand (0.76)

这个能力在以下几个场景中比较实用:

适用场景

场景 说明
智能相册 自动给照片打标签,按场景/物体维度检索
内容审核 根据标签判断图片是否包含违规内容
图像搜索 用标签做语义索引,支持文字搜图
广告推荐 根据图片内容推荐相关商品或素材

不适用场景

  • 需要指定目标的检测:如果需求是"图片里有没有猫,猫在哪里",应该用目标检测,不是标签分类。
  • 细粒度分类:比如区分不同品种的猫,默认模型粒度不够,需要自定义模型支持。

跟其他方案对比

方案 优点 缺点
Core Vision Kit ImageClassifier 系统自带、零分发、端侧推理、隐私好 标签固定、不可扩展自定义标签(仅支持系统模型)
自研模型 + NNAPI 可定制 开发成本高、模型文件管理复杂
云端 API 准确率高 需联网、延迟高、费用敏感

如果你的需求是"给每张照片自动归类到相册场景",ImageClassifier 是最合适的选择,不需要额外引入任何第三方库。

环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机(含 HarmonyOS NEXT 真机)

核心实现:完整功能开发

下面从头实现一个完整的图像标签分类功能。流程分四步:

  1. 权限申请与校验
  2. ImageClassifier 初始化
  3. 图片选择与分类调用
  4. 结果排序与展示

第一步:权限配置

Core Vision Kit 需要读取图片文件权限。在 module.json5 中添加:

json 复制代码
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.READ_MEDIA"
      },
      {
        "name": "ohos.permission.READ_IMAGEVIDEO"
      }
    ]
  }
}

第二步:ImageClassifier 初始化

这是最容易出问题的地方。很多人直接在 aboutToAppear 里初始化,但 ImageClassifier 的创建是异步的,而且依赖设备模型文件是否就绪。

核心逻辑:

typescript 复制代码
import { imageClassifier, vision } from '@kit.CoreVisionKit';

export class ImageClassifierManager {
  private classifier: imageClassifier.ImageClassifier | null = null;
  private isInitializing: boolean = false;

  async initClassifier(): Promise<void> {
    if (this.classifier) {
      return;
    }
    if (this.isInitializing) {
      // 防止重复初始化
      await new Promise<void>((resolve) => {
        const check = () => {
          if (this.classifier) {
            resolve();
          } else {
            setTimeout(check, 100);
          }
        };
        check();
      });
      return;
    }

    this.isInitializing = true;
    try {
      // 使用系统默认模型
      const context = getContext(this);
      const initConfig: imageClassifier.InitConfig = {
        // 不指定 modelPath,使用系统内置模型
        runtime: vision.Runtime.CPU_HIGH_PERFORMANCE,
      };
      this.classifier = await imageClassifier.ImageClassifier.create(initConfig);
      console.info('ImageClassifier initialized successfully');
    } catch (error) {
      console.error(`Failed to init classifier: ${JSON.stringify(error)}`);
      throw error;
    } finally {
      this.isInitializing = false;
    }
  }

  getClassifier(): imageClassifier.ImageClassifier | null {
    return this.classifier;
  }

  release(): void {
    if (this.classifier) {
      this.classifier.release();
      this.classifier = null;
    }
  }
}

注意点

  • ImageClassifier.create 必须传入 InitConfig,目前推荐 runtime.CPU_HIGH_PERFORMANCE,在大多数中端设备上表现稳定。
  • 不要@Componentbuild() 里调用初始化,ArkUI 的 build 过程中不允许异步操作。
  • 初始化只执行一次,页面销毁时记得 release

第三步:图片选择与分类调用

用户需要选择一张图片,然后调用分类接口。这里用 PhotoViewPicker 获取图片 URI。

完整页面代码:

typescript 复制代码
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { imageClassifier } from '@kit.CoreVisionKit';
import { image } from '@kit.ImageKit';

@Entry
@Component
struct ImageClassifierDemo {
  @State selectedImageUri: string = '';
  @State labels: LabelItem[] = [];
  @State isLoading: boolean = false;
  private classifierManager: ImageClassifierManager = new ImageClassifierManager();

  aboutToAppear(): void {
    // 页面即将显示时初始化
    this.classifierManager.initClassifier().catch((err) => {
      console.error(`Init error: ${JSON.stringify(err)}`);
    });
  }

  async pickImage(): Promise<void> {
    try {
      const picker = new photoAccessHelper.PhotoViewPicker(getContext(this));
      const result = await picker.select({
        MIME: ['image/jpeg', 'image/png'],
        maxCount: 1
      });
      if (result.photoUris.length > 0) {
        this.selectedImageUri = result.photoUris[0];
        await this.classifyImage(this.selectedImageUri);
      }
    } catch (error) {
      console.error(`Picker error: ${JSON.stringify(error)}`);
    }
  }

  async classifyImage(imageUri: string): Promise<void> {
    if (!imageUri) {
      return;
    }

    this.isLoading = true;
    this.labels = [];

    try {
      // 确保分类器已初始化
      await this.classifierManager.initClassifier();
      const classifier = this.classifierManager.getClassifier();
      if (!classifier) {
        console.error('Classifier is not ready');
        return;
      }

      // 构建输入:从URI获取PixelMap
      const context = getContext(this);
      const file = await context.getContentResolver().openFile(imageUri, 'r');
      const imageSource = image.createImageSource(file.fd);
      const pixelMap = await imageSource.createPixelMap();

      // 创建分类输入
      const classifierInput: imageClassifier.ClassifierInput = {
        pixelMap: pixelMap
      };

      // 执行分类:限制返回前10个结果
      const result: imageClassifier.ClassifierResult = await classifier.classifyImage(
        classifierInput,
        {
          topK: 10
        }
      );

      // 解析结果
      const top5 = this.processResult(result.labels, 5);
      this.labels = top5;
    } catch (error) {
      console.error(`Classify error: ${JSON.stringify(error)}`);
    } finally {
      this.isLoading = false;
    }
  }

  processResult(labels: imageClassifier.ClassifierLabel[], topK: number): LabelItem[] {
    // 过滤掉置信度低于0.1的结果
    const filtered = labels.filter((label) => label.confidence >= 0.1);
    // 按置信度降序排序
    const sorted = filtered.sort((a, b) => b.confidence - a.confidence);
    // 取前topK个
    return sorted.slice(0, topK).map((label) => ({
      name: label.labelName,
      confidence: label.confidence
    }));
  }

  build() {
    Column() {
      // 图片预览区域
      if (this.selectedImageUri) {
        Image(this.selectedImageUri)
          .width('90%')
          .height(300)
          .objectFit(ImageFit.Cover)
          .margin({ top: 20 })
      } else {
        Text('请选择一张图片')
          .fontSize(16)
          .fontColor('#999')
          .margin({ top: 80 })
      }

      Button('选择图片并分类')
        .onClick(() => this.pickImage())
        .margin({ top: 20 })

      if (this.isLoading) {
        LoadingProgress()
          .width(40)
          .height(40)
          .margin({ top: 20 })
      }

      // 标签列表
      List({ space: 8 }) {
        ForEach(this.labels, (item: LabelItem, index: number) => {
          ListItem() {
            Row() {
              Text(`#${index + 1}`)
                .fontSize(14)
                .fontColor('#666')
              Text(item.name)
                .fontSize(14)
                .fontWeight(FontWeight.Medium)
                .margin({ left: 12 })
              Text(`${(item.confidence * 100).toFixed(1)}%`)
                .fontSize(14)
                .fontColor('#999')
              Blank()
              // 简单的置信度条
              Progress({ value: item.confidence * 100, total: 100 })
                .width(60)
                .height(6)
                .color('#007DFF')
            }
            .width('100%')
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .borderRadius(8)
            .backgroundColor('#F5F5F5')
          }
        }, (item: LabelItem) => item.name)
      }
      .width('100%')
      .margin({ top: 20 })
    }
    .width('100%')
    .height('100%')
    .padding({ left: 16, right: 16 })
  }
}

interface LabelItem {
  name: string;
  confidence: number;
}

这段代码解决的核心问题

  • classifyImage 方法接收一个 URI,内部通过 ContentResolver 拿到 file descriptor,再构建 PixelMap。这里不能直接用 Image 组件读取到的 PixelMap,因为 Image 组件的 PixelMap 可能不是可写可读的模式。
  • topK 参数控制返回结果数量,但不能直接用它限制前端展示。因为 topK 的值不宜过大,会消耗推理时间,所以设为 10,前端再取前 5。
  • 置信度过滤阈值设为 0.1,低于 10% 的结果通常没有参考价值。

第四步:结果排序与展示

processResult 方法做了两件事:

  1. 过滤低置信度结果
  2. 降序排序后取前 5

输出格式:

复制代码
#1 海滩 95.2%
#2 海洋 88.7%
#3 天空 82.1%
#4 沙滩 76.3%
#5 云 65.8%

这里用 Progress 组件展示置信度条,比纯文字更直观。

常见问题 1:初始化失败或返回空结果

现象

classifier.classifyImage 调用后,result.labels 为空数组,或者初始化直接抛异常。

原因

  • 模型文件尚未下载完成。首次使用 Core Vision Kit 的模型时,系统会自动下载,这个过程可能耗时 10-30 秒。
  • 设备不支持的相机或图像传感器,集成后模型加载失败。

解决方案

  • initClassifier 前后输出日志,打印成功或失败信息。
  • 如果返回空结果,检查图片是否为系统支持格式(JPEG/PNG)。
  • 在真机上测试,模拟器上模型可能不完整。
typescript 复制代码
// 在 initClassifier 里增加状态回调
async initClassifier(onProgress?: (progress: number) => void): Promise<void> {
  // ... 初始化逻辑
  // 系统会自动触发下载,无法手动控制
}

常见问题 2:页面返回后状态丢失

现象

从 ImageClassifierDemo 页面返回到上一页,再进入时,分类结果消失了。

原因

@State 修饰的 labels 数组在页面销毁时被清空,但 classifierManager 是实例属性,在 aboutToAppear 里重新初始化后,之前的分类结果没有同步。

解决方案

  • 使用 @StorageLinkAppStorage 在页面间共享分类结果
  • 或者将分类结果持久化到 PersistentStorage
typescript 复制代码
// 用 AppStorage 保存结果
@Entry
@Component
struct ImageClassifierDemo {
  @StorageLink('labels') labels: LabelItem[] = [];
  // ...
}

但更推荐的做法是:页面返回时不销毁,用 @KeepAlive 或路由参数传递。不过复杂的状态管理不在本文讨论范围内,这里仅指出问题。

最佳实践

1. 不要在 build() 中频繁创建对象

ArkUI 的 build() 方法每次状态变化都会重新执行。如果直接在 build() 里创建 ImageClassifierManager 实例,会导致每次渲染都生成新对象,破坏单例模式。

typescript 复制代码
// ❌ 错误做法
build() {
  let manager = new ImageClassifierManager(); // 每次渲染都创建
}

正确做法:在 aboutToAppear 或构造函数中初始化,存储在成员变量。

2. 异步回调里不要直接修改 UI 状态?

这句话本身没错,但经常被误解。实际上,ArkTS 中的异步回调(比如 Promise.then)是可以直接修改 @State 变量的,因为 ArkUI 的变更检测机制会自动触发重新渲染。真正的问题是:在回调中修改了页面销毁后的状态

typescript 复制代码
// 问题代码
aboutToDisappear(): void {
  this.classifierManager.release();
}
// 如果 classfiy 回调在页面销毁后才执行,labels 已经被清空,不会报错但无意义

解决方案:在回调前检查页面是否存活,或者使用 isActive 标记。

3. 推荐使用单例管理分类器

如果多个页面都需要图像分类功能,建议把 ImageClassifierManager 做成全局单例,避免重复创建和释放。

typescript 复制代码
export class ClassifierService {
  private static instance: ClassifierService;
  private manager: ImageClassifierManager;

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

  async classify(uri: string): Promise<LabelItem[]> {
    // 复用同一个分类器
  }
}

完整 Demo 入口

typescript 复制代码
// entry/src/main/ets/pages/Index.ets
import { ImageClassifierDemo } from './ImageClassifierDemo';

@Entry
@Component
struct Index {
  build() {
    ImageClassifierDemo()
  }
}

示例代码地址:项目地址

FAQ

Q:为什么真机正常,模拟器上分类结果为空?

A:模拟器可能没有完整的 Core Vision Kit 模型库,或者模型文件版本跟真机不同。建议在真机上测试。如果必须用模拟器,确认 SDK 版本和 API 版本匹配。

Q:为什么第一次运行时很慢,第二次就快了?

A:首次使用需要下载模型文件,这个过程由系统自动完成,开发者无法干预。第二次运行时模型已缓存,速度会快很多。建议在 aboutToAppear 中提前初始化,不要等用户点击时才初始化。

Q:能自定义标签吗?

A:ImageClassifier 仅支持系统预置模型,无法自定义标签。如果需要自定义标签,需要训练自己的模型,通过 ModelLoader 加载。这超出了 Core Vision Kit 的范围。

Q:返回的标签全是英文,能改成中文吗?

A:系统模型输出的是英文标签(如 "beach")。可以通过映射表转成中文,但官方没有提供中文标签映射。如果需要多语言支持,需要在应用层做映射。

typescript 复制代码
const labelMap: Record<string, string> = {
  'beach': '海滩',
  'ocean': '海洋',
  'sky': '天空'
};

Q:为什么我的图片一直返回 "unknown" 标签?

A:模型无法识别当前图片内容时,会返回 "unknown" 标签。检查图片是否遮挡严重、分辨率太低、或者内容过于抽象。也有可能是图片格式问题,建议用 JPEG 或 PNG。

相关推荐
在书中成长2 小时前
HarmonyOS 小游戏《对战五子棋》开发第35篇 - 列表渲染性能优化
harmonyos
xianjixiance_2 小时前
鸿蒙实战:SharePage 报告分享——雷达图预览与 pasteboard 剪贴板
华为·harmonyos
全堆鸿蒙12 小时前
27 RdbPredicates 条件查询详解:EqualTo、OrderBy、组合条件
华为·harmonyos·鸿蒙系统
OpenApi.cc12 小时前
猫狗分类模型 GPU版
pytorch·深度学习·分类
心中有国也有家12 小时前
AtomGit Flutter 鸿蒙客户端: ChangeNotifier 模式
学习·flutter·华为·harmonyos
国服第二切图仔13 小时前
HarmonyOS APP《画伴梦工厂》开发第59篇-碰一碰精准分享——跨设备素材插入
华为·harmonyos
hqzing13 小时前
在鸿蒙 PC 上使用 Claude Code(最新的 Bun 版本)
华为·harmonyos
xd18557855514 小时前
鸿蒙AI菜谱生成器:基于ArkTS原生开发与鸿蒙PC适配实战
人工智能·华为·harmonyos·鸿蒙
GitCode官方15 小时前
开源鸿蒙跨平台直播|QRN 跨端鸿蒙一体化实战
人工智能·华为·开源·harmonyos·atomgit
ZZZMMM.zip16 小时前
演示架构师-PPT大纲生成的HarmonyOS开发实践
人工智能·华为·powerpoint·harmonyos·鸿蒙·鸿蒙系统