前言

本篇博客所讲的这个案例要解决的问题是:在 HarmonyOS7 上,如何用 AREngine 的人脸 BlendShape 追踪能力实现实时微表情识别,并保证数据平滑和分类准确性?

AREngine 的 ARFace.getBlendShapes() 返回当前帧所有 BlendShape 的类型数组和数据缓冲区,数据类型是 Float32Array,每个值 0~1 表示该面部区域的形变程度。这组数据有几个特点需要处理:

  1. 帧间抖动:单帧数据不稳定,需要时域平滑
  2. 语义组合:单个 BlendShape 不等于一个表情,需要多参数加权组合
  3. 互斥判定:不同表情之间可能共享参数,需要优先级和互斥条件

本案例的 SmoothedBlendShapes 类处理第一个问题,detectExpressions() 方法处理后两个。最终实现了从原始 BlendShape 数据到"微笑😊""惊讶😲"这种人类可读标签的完整链路。

案例效果演示

在这里插入图片描述

项目准备

创建工程

在 DevEco Studio 中新建 Empty Ability 工程,选择 Stage 模型,API 版本设为 26(HarmonyOS7)。本文代码使用 ArkTS。

权限配置

人脸跟踪需要摄像头权限,AR 场景需要陀螺仪和加速度计权限。在 entry/src/main/module.json5 中声明:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.CAMERA",
        "reason": "$string:camera_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.GYROSCOPE",
        "reason": "$string:gyroscope_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.ACCELEROMETER",
        "reason": "$string:accelerometer_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

三个权限都是用户授权权限(user_grant):

  • CAMERA:前置摄像头采集人脸图像
  • GYROSCOPE:陀螺仪,AREngine 用于设备姿态估计
  • ACCELEROMETER:加速度计,AREngine 用于运动补偿

运行时系统会在首次启动应用时弹出授权弹窗。CAMERA 权限被拒绝时,AREngine 无法初始化,应用会显示"初始化失败"。

检查清单

检查项要求出错表现
API 版本API 26无法识别 AREngine 相关类型
设备支持isARTypeSupported(FACE) 返回 true初始化失败或提示"设备不支持"
摄像头权限用户授权AREngine 初始化抛异常
真机测试必须真机,模拟器无摄像头无法获取人脸数据

AREngine 的人脸跟踪必须在真机上测试——模拟器没有摄像头,也没有 NPU 加速能力。部分低配置设备可能不支持人脸 BlendShape 追踪,代码中通过 isARTypeSupported 做了前置检测。

核心实现

导入与常量

import { arEngine, ARView, arViewController } from '@kit.AREngine';
import { Scene, Node } from '@kit.ArkGraphics3D';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

各 Kit 的职责:

  • @kit.AREngine:核心——arEngine 提供人脸追踪的底层类型(ARSessionARFaceARBlendShapesARBlendShapeType),ARView 是摄像头预览组件,arViewController 提供 ARViewContextARViewCallback 用于帧回调
  • @kit.ArkGraphics3D:3D 场景管理——Scene 是 AREngine 渲染所需的 3D 场景根对象,Node 是回调参数中的场景节点类型
  • @kit.PerformanceAnalysisKit:日志输出
  • @kit.BasicServicesKitBusinessError 类型用于错误处理
const DOMAIN = 0x0000;
const TAG = 'FaceTracking';
const SMOOTH_FACTOR = 0.4;
  • SMOOTH_FACTOR = 0.4:指数移动平均的平滑因子。0 表示完全不更新(过度平滑,响应迟钝),1 表示不做平滑(原始数据,抖动剧烈)。0.4 是一个经验值——响应速度和稳定性之间的折中。如果你发现表情切换仍然太敏感,可以降到 0.2~0.3;如果反应太慢,可以升到 0.5~0.6

BlendShape 类型定义

interface BlendShapeItem {
  type: arEngine.ARBlendShapeType;
  label: string;
  category: string;
}

const ALL_BLENDSHAPES: BlendShapeItem[] = [
  { type: arEngine.ARBlendShapeType.EYE_BLINK_LEFT, label: '左眼闭合', category: '眼睛' },
  { type: arEngine.ARBlendShapeType.EYE_BLINK_RIGHT, label: '右眼闭合', category: '眼睛' },
  { type: arEngine.ARBlendShapeType.EYE_WIDE_LEFT, label: '左眼睁大', category: '眼睛' },
  // ... 共 27 种
  { type: arEngine.ARBlendShapeType.TONGUE_OUT_SLIGHT, label: '吐舌', category: '嘴巴' },
];

ALL_BLENDSHAPES 定义了本案例关注的 27 种 BlendShape,按眼睛、眉毛、嘴巴、脸颊、鼻子五个类别分组。AREngine 的 ARBlendShapeType 枚举实际提供了 52 种类型,本案例选取了最常用的 27 种。每种类型包含:

  • type:AREngine 的枚举值,用于从 ARBlendShapes 中查询数据
  • label:中文标签,用于界面显示
  • category:分类,后续可以按类别分组展示

为什么不全部用上?52 种 BlendShape 中有相当一部分是细微的嘴唇形变(比如"下唇左偏"“上唇右翻”),对表情识别贡献不大,而且全部展示会让界面过于密集。选取 27 种是覆盖主要表情所需的最小集合。

表快照与数据结构

interface ExpressionSnapshot {
  name: string;
  emoji: string;
  intensity: number;
}

表情识别结果的数据结构:

  • name:表情名称,如"微笑"“惊讶”
  • emoji:对应的 emoji 图标,用于界面快速识别
  • intensity:表情强度(0~1),由 detectExpressions() 的加权评分计算而来,用于排序和展示

SmoothedBlendShapes:指数移动平均平滑

这是本案例第一个核心技术点。先看完整实现:

class SmoothedBlendShapes {
  private smoothed: Map<arEngine.ARBlendShapeType, number> = new Map();

  update(types: Array<arEngine.ARBlendShapeType>, values: Float32Array): void {
    let newValues: Map<arEngine.ARBlendShapeType, number> = new Map();
    for (let i = 0; i < types.length && i < values.length; i++) {
      newValues.set(types[i], values[i]);
    }

    let activeKeys: Set<arEngine.ARBlendShapeType> = new Set();
    newValues.forEach((_v: number, k: arEngine.ARBlendShapeType) => { activeKeys.add(k); });
    this.smoothed.forEach((_v: number, k: arEngine.ARBlendShapeType) => { activeKeys.add(k); });

    activeKeys.forEach((k: arEngine.ARBlendShapeType) => {
      let newVal: number = newValues.get(k) ?? 0;
      let oldVal: number = this.smoothed.get(k) ?? 0;
      this.smoothed.set(k, oldVal * (1 - SMOOTH_FACTOR) + newVal * SMOOTH_FACTOR);
    });
  }

  get(type: arEngine.ARBlendShapeType): number {
    return this.smoothed.get(type) ?? 0;
  }

  avg(a: arEngine.ARBlendShapeType, b: arEngine.ARBlendShapeType): number {
    return (this.get(a) + this.get(b)) / 2;
  }

  triple(a: arEngine.ARBlendShapeType, b: arEngine.ARBlendShapeType, c: arEngine.ARBlendShapeType): number {
    return (this.get(a) + this.get(b) + this.get(c)) / 3;
  }

  reset(): void {
    this.smoothed.clear();
  }
}
update 方法:EMA 核心逻辑

每帧调用一次,输入是 AREngine 返回的 types 数组和 values 数组。执行流程:

  1. 构建当前帧的值映射:把 types[i]values[i] 配对存入 newValues
  2. 收集活跃 key:当前帧出现过的 key + 之前平滑过的 key,合并为一个集合。这样即使某个 BlendShape 在当前帧消失了(值为 0),它的平滑值也会逐步衰减而非突然归零
  3. 对每个 key 执行 EMAsmoothed = old * (1 - α) + new * α,其中 α = SMOOTH_FACTOR = 0.4

EMA 的直觉理解:新值占 40%权重,旧值占 60%权重。如果某帧数据突然跳变(比如左眼闭合度从 0.1 跳到 0.8),平滑后的值只会到 0.1 * 0.6 + 0.8 * 0.4 = 0.38,不会一步到位。连续几帧都保持 0.8 后,平滑值才会逐步逼近 0.8。这就是"平滑"的效果——延迟响应,但避免闪烁。

辅助方法

avgtriple 是快捷方法,用于后续表情分类中对左右对称的 BlendShape 取均值(比如左眼闭合 + 右眼闭合 = 整体眨眼度)。reset 在人脸丢失时清空所有平滑数据,避免残留值影响下次检测。

FaceTrackingCallback:帧回调与表情识别

这是本案例逻辑最重的类,继承自 arViewController.ARViewCallback,在每帧更新时被 AREngine 调用。

class FaceTrackingCallback extends arViewController.ARViewCallback {
  private owner: WeakRef<A3> | undefined = undefined;
  private smoothed: SmoothedBlendShapes = new SmoothedBlendShapes();

  setOwner(owner: A3): void {
    this.owner = new WeakRef(owner);
  }
为什么用 WeakRef 而不是直接持有组件引用?

FaceTrackingCallback 的生命周期由 AREngine 管理,可能比组件活得更长。如果 callback 直接持有组件的强引用,组件销毁时无法被 GC 回收,造成内存泄漏。WeakRef 是弱引用——GC 可以正常回收组件,callback 通过 deref() 获取引用时如果组件已被回收则返回 undefined

onFrameUpdate:每帧回调
  onFrameUpdate(ctx: arViewController.ARViewContext, _sysBootTs: number): void {
    let session: arEngine.ARSession | undefined = ctx.session;
    if (!session) {
      return;
    }

    try {
      let trackables: arEngine.ARTrackable[] = session.getAllTrackables(arEngine.ARTrackableType.FACE);
      let owner: A3 | undefined = this.owner !== undefined ? this.owner.deref() : undefined;
      if (!owner) {
        return;
      }

      if (trackables.length === 0) {
        owner.faceDetected = false;
        owner.blendShapeData = [];
        owner.currentExpressions = [];
        return;
      }

      owner.faceDetected = true;

AREngine 每帧调用 onFrameUpdate,参数 ctx 包含当前 AR 会话。核心流程:

  1. ctx.session 获取 ARSession
  2. session.getAllTrackables(FACE) 获取当前帧检测到的所有人脸
  3. 无人脸时重置所有状态(faceDetected = false、清空数据)
  4. 有人脸时,遍历找到处于 TRACKING 状态的人脸
      for (let i = 0; i < trackables.length; i++) {
        if (trackables[i].state !== arEngine.ARTrackingState.TRACKING) {
          continue;
        }
        let face: arEngine.ARFace = trackables[i] as arEngine.ARFace;
        let blendShapes: arEngine.ARBlendShapes = face.getBlendShapes();
        if (blendShapes.count === 0) {
          continue;
        }
        let types: Array<arEngine.ARBlendShapeType> = blendShapes.getTypes();
        let dataBuffer: ArrayBuffer = blendShapes.getData();
        let data: Float32Array = new Float32Array(dataBuffer);

        this.smoothed.update(types, data);

对人脸执行 getBlendShapes() 获取 BlendShape 数据。getTypes() 返回类型数组,getData() 返回 ArrayBuffer,需要转为 Float32Array 才能按索引读取。然后把原始数据喂给 SmoothedBlendShapes.update() 做平滑。

        let weightMap: Map<string, number> = this.buildWeightMap();
        owner.blendShapeWeights = weightMap;
        owner.activeBlendCount = this.countActive(weightMap);
        owner.blendShapeData = updatedShapes;
        owner.currentExpressions = this.detectExpressions();
        break;
      }

平滑后,构建权重映射、统计活跃项数量、执行表情识别,然后把结果写入组件状态。break 只处理第一张有效人脸——多人脸场景下,本案例只展示最前面那张脸的表情数据。

detectExpressions:加权评分表情分类算法

这是本案例第二个核心技术点。完整实现约 130 行,核心思路:对每个预定义的表情,计算一个加权评分,评分超过阈值且满足互斥条件时,判定为该表情

先看几个典型表情的评分公式:

微笑 vs 大笑
    let laughScore: number = smile * 0.5 + jawOpen * 0.3 + cheekSquint * 0.2;
    if (laughScore > 0.2 && smile > 0.2 && jawOpen > 0.15) {
      expressions.push({ name: '大笑', emoji: '😆', intensity: laughScore });
    }

    let smileScore: number = smile * 0.6 + cheekSquint * 0.2 + (1 - Math.max(frown, browDown)) * 0.2;
    if (smileScore > 0.15 && smile > 0.12 && frown < 0.08) {
      if (expressions.length === 0 || expressions[0].name !== '大笑') {
        expressions.push({ name: '微笑', emoji: '😊', intensity: smileScore });
      }
    }

大笑的公式:嘴角笑 * 0.5 + 张嘴 * 0.3 + 脸颊抬 * 0.2。大笑需要同时满足嘴角上扬、嘴巴张开、脸颊上抬三个条件,其中嘴角权重最高。阈值条件 smile > 0.2 && jawOpen > 0.15 确保"嘴没张开不算大笑"。

微笑的公式:嘴角笑 * 0.6 + 脸颊抬 * 0.2 + (1 - max(撇嘴, 眉下压)) * 0.2。注意第三个因子 (1 - Math.max(frown, browDown))——撇嘴和眉下压是负面表情的信号,如果它们同时很活跃,说明这不是微笑。frown < 0.08 也是互斥条件:嘴角撇的幅度大时不算微笑。

微笑判定中有一行关键代码:if (expressions.length === 0 || expressions[0].name !== '大笑')——大笑已经在列表里时,不再追加微笑。因为大笑是微笑的加强版,两者不应该同时出现。

惊讶 vs 恐惧
    let surpriseScore: number = eyeWide * 0.35 + jawOpen * 0.3 + browUp * 0.35;
    if (surpriseScore > 0.2 && eyeWide > 0.1 && browUp > 0.08) {
      expressions.push({ name: '惊讶', emoji: '😲', intensity: surpriseScore });
    }

    let fearScore: number = eyeWide * 0.3 + browInnerUp * 0.3 + (1 - squint) * 0.2 + mouthStretch * 0.2;
    if (fearScore > 0.2 && eyeWide > 0.1 && browInnerUp > 0.1 && browDown < 0.08) {
      expressions.push({ name: '恐惧', emoji: '😨', intensity: fearScore });
    }

惊讶和恐惧都包含"睁大眼"的成分,区别在于:

  • 惊讶眼睛睁大 + 张嘴 + 眉毛上扬,三个条件权重接近,是典型的"惊讶"面部组合
  • 恐惧眼睛睁大 + 眉心上扬 + 嘴角拉伸,眉心上扬(browInnerUp)是恐惧的特征性动作,嘴角拉伸(mouthStretch)也是紧张的表现。互斥条件 browDown < 0.08 排除皱眉——皱眉时更可能是愤怒而非恐惧
生气 vs 厌恶
    let angryScore: number = browDown * 0.4 + frown * 0.3 + squint * 0.2 + (1 - smile) * 0.1;
    if (angryScore > 0.2 && browDown > 0.1 && smile < 0.08) {
      expressions.push({ name: '生气', emoji: '😠', intensity: angryScore });
    }

    let disgustScore: number = noseUp * 0.3 + squint * 0.2 + browDown * 0.2 + frown * 0.15 + mouthStretch * 0.15;
    if (disgustScore > 0.2 && noseUp > 0.1) {
      expressions.push({ name: '厌恶', emoji: '🤢', intensity: disgustScore });
    }

生气的核心信号是眉下压(browDown 权重 0.4),厌恶的核心信号是皱鼻(noseUp 权重 0.3)。皱鼻是厌恶最独特的面部动作——生气、悲伤、恐惧都不会皱鼻。所以 noseUp > 0.1 作为厌恶的必要条件非常有效。

生气的互斥条件 smile < 0.08 很关键:一个人皱眉+眯眼但嘴角带笑,那更可能是"开玩笑的假装生气",不是真生气。

简单表情:闭眼、眯眼、嘟嘴等
    if (blink > 0.45) {
      expressions.push({ name: '闭眼', emoji: '😵', intensity: blink });
    } else if (blinkL > 0.3) {
      expressions.push({ name: '左眨', emoji: '😉', intensity: blinkL });
    } else if (blinkR > 0.3) {
      expressions.push({ name: '右眨', emoji: '😉', intensity: blinkR });
    }

这类表情只依赖 1-2 个 BlendShape,逻辑简单。注意 else if 的优先级:闭眼 > 单侧眨眼。两只眼都闭合度 > 0.45 时只报"闭眼",不重复报"左眨+右眨"。

排序与兜底
    expressions.sort((a: ExpressionSnapshot, b: ExpressionSnapshot) => b.intensity - a.intensity);

    if (expressions.length === 0) {
      expressions.push({ name: '平静', emoji: '😐', intensity: 1.0 });
    }

    return expressions;

所有识别到的表情按 intensity 降序排列,强度最高的排第一。如果没有任何表情超过阈值,兜底返回"平静"——这是一种合理的默认状态,不代表识别失败。

AR 上下文初始化

  async aboutToAppear(): Promise<void> {
    this.callback.setOwner(this);
    try {
      let ret: boolean = arViewController.isARTypeSupported(arEngine.ARFeatureType.ARENGINE_FEATURE_TYPE_FACE);
      hilog.info(DOMAIN, TAG, `Face tracking supported: ${ret}`);
      if (!ret) {
        this.supported = false;
        this.statusText = '设备不支持人脸跟踪';
        return;
      }

初始化的第一步是能力检测isARTypeSupported(ARENGINE_FEATURE_TYPE_FACE) 查询当前设备是否支持人脸追踪。不支持时直接设置 supported = false,后续 UI 不会渲染 ARView,而是显示提示信息。这个检测必须在 context.init() 之前做——如果设备不支持还强行初始化,会抛异常。

      let scene: Scene = await Scene.load();
      let context: arViewController.ARViewContext = new arViewController.ARViewContext();
      context.scene = scene;
      context.callback = this.callback;
      context.config = {
        type: arEngine.ARType.FACE,
        planeFindingMode: arEngine.ARPlaneFindingMode.DISABLED,
        powerMode: arEngine.ARPowerMode.NORMAL,
        focusMode: arEngine.ARFocusMode.AUTO,
        cameraLensFacing: arEngine.ARCameraLensFacing.FRONT,
        multiFaceMode: arEngine.ARMultiFaceMode.MULTIFACE_ENABLE,
      };
      await context.init();
      this.arContext = context;
      this.serviceReady = true;
      this.statusText = '请面向摄像头';

AR 上下文的配置项逐一说明:

  • type: FACE:AR 类型设为人脸追踪,AREngine 会启用人脸检测和 BlendShape 提取管线
  • planeFindingMode: DISABLED:禁用平面检测。本案例不需要在桌面上放 3D 模型,关闭平面检测可以节省计算资源
  • powerMode: NORMAL:正常功耗模式。还有 LOW_POWER 选项,会降低帧率和检测精度
  • focusMode: AUTO:自动对焦。人脸跟踪需要清晰的面部图像,自动对焦在距离变化时保持图像清晰度
  • cameraLensFacing: FRONT:使用前置摄像头。人脸跟踪通常用于自拍场景
  • multiFaceMode: MULTIFACE_ENABLE:启用多人脸检测。虽然本案例只处理第一张脸,但多人脸模式下 AREngine 会同时追踪视野中的所有人脸

Scene.load() 创建一个空的 3D 场景。AREngine 需要 Scene 对象来管理 AR 渲染管线,即使我们不主动在场景中放 3D 内容。context.callback = this.callback 把帧回调注册到上下文,此后 AREngine 每帧都会调用 onFrameUpdate

  async aboutToDisappear(): Promise<void> {
    if (this.arContext) {
      try {
        await this.arContext.destroy();
        hilog.info(DOMAIN, TAG, 'AR context destroyed');
      } catch (error) {
        let err: BusinessError = error as BusinessError;
        hilog.error(DOMAIN, TAG, `Destroy failed: ${err.code}`);
      }
    }
  }

页面销毁时调用 arContext.destroy() 释放 AREngine 资源。AREngine 占用摄像头和 NPU,不释放会导致其他应用无法使用 AR 功能。SmoothedBlendShapes 中的数据不需要手动释放——它只持有基本类型的 Map,GC 会自动回收。

相似度颜色辅助

  private getBarColor(weight: number): string {
    if (weight >= 0.5) {
      return '#6366F1';
    }
    if (weight >= 0.2) {
      return '#8B5CF6';
    }
    return '#4B5563';
  }

  private getExpressionBg(expr: ExpressionSnapshot): string {
    if (expr.name === '微笑' || expr.name === '大笑') {
      return '#065F4620';
    }
    if (expr.name === '皱眉' || expr.name === '生气') {
      return '#7C2D1220';
    }
    // ... 其他表情
    return '#1A1A33';
  }

getBarColor 给 BlendShape 进度条着色:≥0.5 高亮紫色、≥0.2 中等紫色、<0.2 灰色。视觉上一眼就能看出哪些面部区域活跃。

getExpressionBg 给表情卡片设置背景色,每种表情用不同的半透明色调:绿色系对应正面表情(微笑/大笑),红色系对应负面表情(生气/皱眉),蓝色系对应惊讶/挑眉。20 是十六进制的 alpha 值(约 12% 不透明度),保证背景色不会太浓影响文字阅读。

UI 布局:标题栏

  @Builder
  headerBar() {
    Row() {
      Column() {
        Text('AI')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('微表情')
          .fontSize(10)
          .fontColor('#FFFFFFAA')
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Center)

      Blank()

      Text('人脸微表情跟踪')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .layoutWeight(1)
        .textAlign(TextAlign.Center)

      Blank()

      Row() {
        Text(this.faceDetected ? '●' : '○')
          .fontSize(10)
          .fontColor(this.faceDetected ? '#4ADE80' : '#EF4444')
          .margin({ right: 4 })
        Text(this.faceDetected ? '跟踪中' : '搜索中')
          .fontSize(12)
          .fontColor(this.faceDetected ? '#4ADE80' : '#888899')
      }
      .alignItems(VerticalAlign.Center)
    }
    .width('100%')
    .height(56)
    .padding({ left: 20, right: 20 })
    .alignItems(VerticalAlign.Center)
    .backgroundColor('#0F0F23')
  }

标题栏三段式布局:左侧"AI 微表情"品牌标识 + 中间标题 + 右侧人脸检测状态指示器。状态指示器用绿色圆点 + "跟踪中"表示人脸已被检测到,红色空心圆 + "搜索中"表示正在寻找人脸。这个状态直接绑定 faceDetected,由 onFrameUpdate 实时更新。

UI 布局:摄像头预览

  @Builder
  cameraPreview() {
    Stack() {
      if (this.arContext) {
        ARView({ context: this.arContext })
          .width('100%')
          .height('100%')
      } else {
        Column() {
          LoadingProgress()
            .width(40)
            .height(40)
            .color('#6366F1')
          Text(this.statusText)
            .fontSize(14)
            .fontColor('#888899')
            .margin({ top: 12 })
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
        .backgroundColor('#0A0A1A')
      }
    }
    .width('100%')
    .layoutWeight(1)
  }

ARView 是 AREngine 提供的专用组件,接收 ARViewContext 参数,自动渲染摄像头预览画面和 AR 叠加层。初始化完成前显示加载进度和状态文本。layoutWeight(1) 让预览区占满标题栏和底部面板之间的所有空间。

UI 布局:表情卡片

  @Builder
  expressionCard() {
    Column() {
      Row() {
        Text('实时微表情')
          .fontSize(14)
          .fontWeight(FontWeight.Medium)
          .fontColor('#FFFFFF')
        Blank()
        if (this.currentExpressions.length > 0) {
          Text(this.currentExpressions[0].emoji)
            .fontSize(24)
        }
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 4 })

      if (this.currentExpressions.length > 0) {
        Row() {
          ForEach(this.currentExpressions, (expr: ExpressionSnapshot, index: number) => {
            Column() {
              Text(expr.emoji)
                .fontSize(22)
              Text(expr.name)
                .fontSize(10)
                .fontColor('#AAAACC')
                .margin({ top: 2 })
              Row() {
                Row()
                  .width(Math.max(2, expr.intensity * 32))
                  .height(3)
                  .borderRadius(2)
                  .backgroundColor(this.getExpressionBg(expr) === '#1A1A33' ? '#6366F1' : '#4ADE80')
              }
              .width(36)
              .height(3)
              .borderRadius(2)
              .backgroundColor('#1A1A33')
              .margin({ top: 4 })
            }
            .padding({ left: 8, right: 8, top: 8, bottom: 8 })
            .backgroundColor(this.getExpressionBg(expr))
            .borderRadius(12)
            .margin({ left: index === 0 ? 16 : 6 })
            .alignItems(HorizontalAlign.Center)
          }, (_expr: ExpressionSnapshot, index: number) => `${index}`)
        }
        .width('100%')
        .padding({ top: 4, bottom: 12 })
      } else {
        Text('面向摄像头开始检测微表情')
          .fontSize(12)
          .fontColor('#666688')
          .padding({ left: 16, top: 4, bottom: 12 })
      }
    }
    .width('100%')
    .backgroundColor('#141428')
    .borderRadius({ topLeft: 20, topRight: 20 })
    .shadow({ radius: 12, color: '#00000044', offsetY: -4 })
  }

表情卡片分两层:标题行(“实时微表情” + 最强表情的 emoji)+ 表情列表行。

每个表情卡片包含:emoji 图标 + 表情名称 + 强度进度条。进度条用外层固定宽度 36 的灰色条 + 内层宽度为 expr.intensity * 32 的彩色条实现。Math.max(2, ...) 保证强度极低时也能看到一条细线。

标题行右侧的大号 emoji(24px)显示当前最强表情,让用户不用看下面的列表就能知道主要表情。

UI 布局:BlendShape 参数面板

  @Builder
  blendShapePanel() {
    Column() {
      Row() {
        Text('表情参数')
          .fontSize(13)
          .fontWeight(FontWeight.Medium)
          .fontColor('#AAAACC')
        Blank()
        Text(`${this.activeBlendCount} 项活跃`)
          .fontSize(11)
          .fontColor('#6366F1')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 10, bottom: 6 })

      Scroll() {
        Column() {
          ForEach(this.blendShapeData, (shape: BlendShapeItem, index: number) => {
            Row() {
              Text(shape.label)
                .fontSize(11)
                .fontColor((this.blendShapeWeights.get(shape.label) ?? 0) > 0.05 ? '#FFFFFF' : '#666688')
                .width(72)
                .flexShrink(0)

              Row() {
                Row()
                  .width(`${Math.max(1, (this.blendShapeWeights.get(shape.label) ?? 0) * 100)}%`)
                  .height('100%')
                  .borderRadius(3)
                  .backgroundColor(this.getBarColor(this.blendShapeWeights.get(shape.label) ?? 0))
              }
              .width('100%')
              .height(6)
              .borderRadius(3)
              .backgroundColor('#1A1A33')
              .layoutWeight(1)

              Text(`${Math.round((this.blendShapeWeights.get(shape.label) ?? 0) * 100)}%`)
                .fontSize(10)
                .fontColor((this.blendShapeWeights.get(shape.label) ?? 0) > 0.05 ? '#6366F1' : '#4B5563')
                .width(36)
                .textAlign(TextAlign.End)
                .margin({ left: 8 })
            }
            .width('100%')
            .padding({ left: 16, right: 16, top: 5, bottom: 5 })
            .alignItems(VerticalAlign.Center)
          }, (_shape: BlendShapeItem, index: number) => `${index}`)
        }
        .width('100%')
      }
      .width('100%')
      .height(180)
      .edgeEffect(EdgeEffect.Spring)
    }
    .width('100%')
    .backgroundColor('#0F0F23')
    .borderRadius({ topLeft: 20, topRight: 20 })
    .shadow({ radius: 8, color: '#00000033', offsetY: -2 })
  }

参数面板用三列布局展示每个 BlendShape:标签名 + 进度条 + 百分比数值。

活跃项的标签白色、数值紫色,非活跃项灰色——阈值 0.05,低于此值的 BlendShape 视为静默。标题行右侧显示活跃项总数 activeBlendCount,这是 countActive 方法统计的——权重 > 0.05 的 BlendShape 个数。

进度条的宽度用百分比字符串 ${weight * 100}% 设置,颜色由 getBarColor 根据 weight 值分三档。Math.max(1, ...) 保证进度条至少有 1% 宽度,避免完全不可见。

面板固定高度 180,内容超出时弹性滚动(edgeEffect(EdgeEffect.Spring))。

主布局

  build() {
    Column() {
      this.headerBar()

      this.cameraPreview()

      this.expressionCard()

      this.blendShapePanel()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0A0A1A')
  }

纵向四层:标题栏(固定 56)→ 摄像头预览(layoutWeight(1) 填满)→ 表情卡片(自适应高度)→ BlendShape 参数面板(固定高度 180 + 内部滚动)。摄像头预览占据大部分屏幕空间,确保人脸画面足够大,方便调整面部角度。

常见问题与排查

初始化失败,提示"设备不支持"

isARTypeSupported(FACE) 返回 false。常见于模拟器或低配置设备。AREngine 人脸追踪需要设备支持 ARCore/AREngine 硬件加速,模拟器不具备此能力。必须在真机上测试,且设备系统版本不低于 HarmonyOS7。

初始化成功但检测不到人脸

检查以下几点:前置摄像头是否被其他应用占用、环境光线是否充足(暗光下人脸检测精度大幅下降)、人脸是否正对摄像头(侧脸超过 45° 可能丢失追踪)、人脸距离是否合适(过近或过远都影响检测)。faceDetected 始终为 false 时,先确认摄像头权限已授权。

表情识别结果闪烁/跳变

如果已经使用了 SmoothedBlendShapes 但仍然闪烁,可能需要降低 SMOOTH_FACTOR。0.4 是平衡响应速度和稳定性的折中值,如果更看重稳定性可以降到 0.2。但注意平滑因子越低,表情切换的延迟越大——用户已经笑了,界面还要过几帧才显示"微笑"。

另一种方案是对 currentExpressions 也做时域滤波:只有连续 N 帧检测到同一表情才更新 UI。但这会增加代码复杂度,本案例没有实现。

某些表情总是误识别

表情分类的权重和阈值是经验值,不同人的面部特征差异很大。比如有些人笑的时候眉毛也会上扬,导致"微笑"和"惊讶"同时出现。调整建议:

  • 误报"惊讶":提高 eyeWide > 0.1 的阈值到 0.15
  • 误报"微笑":降低 smileScore 阈值到 0.2,或增加 frown < 0.05 更严格的互斥条件
  • 漏报"生气":降低 browDown > 0.1 的阈值到 0.08

多人脸时表情数据混乱

本案例只处理第一张处于 TRACKING 状态的人脸(break 跳出循环)。如果需要同时展示多人脸的表情,需要把 currentExpressions 改为 Map<number, ExpressionSnapshot[]>,按人脸索引分别存储。

完整代码

以下是完整代码,可直接复制使用:

import { arEngine, ARView, arViewController } from '@kit.AREngine';
import { Scene, Node } from '@kit.ArkGraphics3D';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN = 0x0000;
const TAG = 'FaceTracking';
const SMOOTH_FACTOR = 0.4;

interface BlendShapeItem {
  type: arEngine.ARBlendShapeType;
  label: string;
  category: string;
}

const ALL_BLENDSHAPES: BlendShapeItem[] = [
  { type: arEngine.ARBlendShapeType.EYE_BLINK_LEFT, label: '左眼闭合', category: '眼睛' },
  { type: arEngine.ARBlendShapeType.EYE_BLINK_RIGHT, label: '右眼闭合', category: '眼睛' },
  { type: arEngine.ARBlendShapeType.EYE_WIDE_LEFT, label: '左眼睁大', category: '眼睛' },
  { type: arEngine.ARBlendShapeType.EYE_WIDE_RIGHT, label: '右眼睁大', category: '眼睛' },
  { type: arEngine.ARBlendShapeType.EYE_SQUINT_LEFT, label: '左眼眯眼', category: '眼睛' },
  { type: arEngine.ARBlendShapeType.EYE_SQUINT_RIGHT, label: '右眼眯眼', category: '眼睛' },
  { type: arEngine.ARBlendShapeType.BROW_DOWN_LEFT, label: '左眉下压', category: '眉毛' },
  { type: arEngine.ARBlendShapeType.BROW_DOWN_RIGHT, label: '右眉下压', category: '眉毛' },
  { type: arEngine.ARBlendShapeType.BROW_INNER_UP, label: '眉心上扬', category: '眉毛' },
  { type: arEngine.ARBlendShapeType.BROW_OUTER_UP_LEFT, label: '左眉外扬', category: '眉毛' },
  { type: arEngine.ARBlendShapeType.BROW_OUTER_UP_RIGHT, label: '右眉外扬', category: '眉毛' },
  { type: arEngine.ARBlendShapeType.JAW_OPEN, label: '张嘴', category: '嘴巴' },
  { type: arEngine.ARBlendShapeType.MOUTH_SMILE_LEFT, label: '左嘴角笑', category: '嘴巴' },
  { type: arEngine.ARBlendShapeType.MOUTH_SMILE_RIGHT, label: '右嘴角笑', category: '嘴巴' },
  { type: arEngine.ARBlendShapeType.MOUTH_FROWN_LEFT, label: '左嘴角撇', category: '嘴巴' },
  { type: arEngine.ARBlendShapeType.MOUTH_FROWN_RIGHT, label: '右嘴角撇', category: '嘴巴' },
  { type: arEngine.ARBlendShapeType.MOUTH_FUNNEL, label: '嘴拢圆', category: '嘴巴' },
  { type: arEngine.ARBlendShapeType.MOUTH_PUCKER, label: '撅嘴', category: '嘴巴' },
  { type: arEngine.ARBlendShapeType.MOUTH_STRETCH_LEFT, label: '左嘴角拉', category: '嘴巴' },
  { type: arEngine.ARBlendShapeType.MOUTH_STRETCH_RIGHT, label: '右嘴角拉', category: '嘴巴' },
  { type: arEngine.ARBlendShapeType.MOUTH_ROLL_LOWER, label: '下唇内卷', category: '嘴巴' },
  { type: arEngine.ARBlendShapeType.MOUTH_ROLL_UPPER, label: '上唇内卷', category: '嘴巴' },
  { type: arEngine.ARBlendShapeType.CHEEK_PUFF, label: '鼓腮', category: '脸颊' },
  { type: arEngine.ARBlendShapeType.CHEEK_SQUINT_LEFT, label: '左颊抬', category: '脸颊' },
  { type: arEngine.ARBlendShapeType.CHEEK_SQUINT_RIGHT, label: '右颊抬', category: '脸颊' },
  { type: arEngine.ARBlendShapeType.FROWN_NOSE_MOUTH_UP, label: '皱鼻', category: '鼻子' },
  { type: arEngine.ARBlendShapeType.TONGUE_OUT_SLIGHT, label: '吐舌', category: '嘴巴' },
];

interface ExpressionSnapshot {
  name: string;
  emoji: string;
  intensity: number;
}

class SmoothedBlendShapes {
  private smoothed: Map<arEngine.ARBlendShapeType, number> = new Map();

  update(types: Array<arEngine.ARBlendShapeType>, values: Float32Array): void {
    let newValues: Map<arEngine.ARBlendShapeType, number> = new Map();
    for (let i = 0; i < types.length && i < values.length; i++) {
      newValues.set(types[i], values[i]);
    }

    let activeKeys: Set<arEngine.ARBlendShapeType> = new Set();
    newValues.forEach((_v: number, k: arEngine.ARBlendShapeType) => { activeKeys.add(k); });
    this.smoothed.forEach((_v: number, k: arEngine.ARBlendShapeType) => { activeKeys.add(k); });

    activeKeys.forEach((k: arEngine.ARBlendShapeType) => {
      let newVal: number = newValues.get(k) ?? 0;
      let oldVal: number = this.smoothed.get(k) ?? 0;
      this.smoothed.set(k, oldVal * (1 - SMOOTH_FACTOR) + newVal * SMOOTH_FACTOR);
    });
  }

  get(type: arEngine.ARBlendShapeType): number {
    return this.smoothed.get(type) ?? 0;
  }

  avg(a: arEngine.ARBlendShapeType, b: arEngine.ARBlendShapeType): number {
    return (this.get(a) + this.get(b)) / 2;
  }

  triple(a: arEngine.ARBlendShapeType, b: arEngine.ARBlendShapeType, c: arEngine.ARBlendShapeType): number {
    return (this.get(a) + this.get(b) + this.get(c)) / 3;
  }

  reset(): void {
    this.smoothed.clear();
  }
}

class FaceTrackingCallback extends arViewController.ARViewCallback {
  private owner: WeakRef<A3> | undefined = undefined;
  private smoothed: SmoothedBlendShapes = new SmoothedBlendShapes();

  setOwner(owner: A3): void {
    this.owner = new WeakRef(owner);
  }

  onAnchorAdd(_ctx: arViewController.ARViewContext, _node: Node, _anchor: arEngine.ARAnchor): void {
  }

  onAnchorUpdate(_ctx: arViewController.ARViewContext, _node: Node, _anchor: arEngine.ARAnchor): void {
  }

  onFrameUpdate(ctx: arViewController.ARViewContext, _sysBootTs: number): void {
    let session: arEngine.ARSession | undefined = ctx.session;
    if (!session) {
      return;
    }

    try {
      let trackables: arEngine.ARTrackable[] = session.getAllTrackables(arEngine.ARTrackableType.FACE);
      let owner: A3 | undefined = this.owner !== undefined ? this.owner.deref() : undefined;
      if (!owner) {
        return;
      }

      if (trackables.length === 0) {
        owner.faceDetected = false;
        owner.blendShapeData = [];
        owner.currentExpressions = [];
        return;
      }

      owner.faceDetected = true;

      for (let i = 0; i < trackables.length; i++) {
        if (trackables[i].state !== arEngine.ARTrackingState.TRACKING) {
          continue;
        }
        let face: arEngine.ARFace = trackables[i] as arEngine.ARFace;
        let blendShapes: arEngine.ARBlendShapes = face.getBlendShapes();
        if (blendShapes.count === 0) {
          continue;
        }
        let types: Array<arEngine.ARBlendShapeType> = blendShapes.getTypes();
        let dataBuffer: ArrayBuffer = blendShapes.getData();
        let data: Float32Array = new Float32Array(dataBuffer);

        this.smoothed.update(types, data);

        let updatedShapes: BlendShapeItem[] = [];
        for (let j = 0; j < ALL_BLENDSHAPES.length; j++) {
          let shape: BlendShapeItem = ALL_BLENDSHAPES[j];
          let weight: number = this.smoothed.get(shape.type);
          updatedShapes.push({
            type: shape.type,
            label: shape.label,
            category: shape.category,
          });
        }
        let weightMap: Map<string, number> = this.buildWeightMap();
        owner.blendShapeWeights = weightMap;
        owner.activeBlendCount = this.countActive(weightMap);
        owner.blendShapeData = updatedShapes;
        owner.currentExpressions = this.detectExpressions();
        break;
      }
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG, `Frame update failed: ${err.code}`);
    }
  }

  private buildWeightMap(): Map<string, number> {
    let map: Map<string, number> = new Map();
    for (let i = 0; i < ALL_BLENDSHAPES.length; i++) {
      let shape: BlendShapeItem = ALL_BLENDSHAPES[i];
      map.set(shape.label, this.smoothed.get(shape.type));
    }
    return map;
  }

  private countActive(map: Map<string, number>): number {
    let count: number = 0;
    map.forEach((v: number) => { if (v > 0.05) { count++; } });
    return count;
  }

  private detectExpressions(): ExpressionSnapshot[] {
    let expressions: ExpressionSnapshot[] = [];
    let bs: SmoothedBlendShapes = this.smoothed;

    let blinkL: number = bs.get(arEngine.ARBlendShapeType.EYE_BLINK_LEFT);
    let blinkR: number = bs.get(arEngine.ARBlendShapeType.EYE_BLINK_RIGHT);
    let blink: number = (blinkL + blinkR) / 2;
    let eyeWideL: number = bs.get(arEngine.ARBlendShapeType.EYE_WIDE_LEFT);
    let eyeWideR: number = bs.get(arEngine.ARBlendShapeType.EYE_WIDE_RIGHT);
    let eyeWide: number = (eyeWideL + eyeWideR) / 2;
    let squintL: number = bs.get(arEngine.ARBlendShapeType.EYE_SQUINT_LEFT);
    let squintR: number = bs.get(arEngine.ARBlendShapeType.EYE_SQUINT_RIGHT);
    let squint: number = (squintL + squintR) / 2;

    let browDownL: number = bs.get(arEngine.ARBlendShapeType.BROW_DOWN_LEFT);
    let browDownR: number = bs.get(arEngine.ARBlendShapeType.BROW_DOWN_RIGHT);
    let browDown: number = (browDownL + browDownR) / 2;
    let browInnerUp: number = bs.get(arEngine.ARBlendShapeType.BROW_INNER_UP);
    let browOuterUpL: number = bs.get(arEngine.ARBlendShapeType.BROW_OUTER_UP_LEFT);
    let browOuterUpR: number = bs.get(arEngine.ARBlendShapeType.BROW_OUTER_UP_RIGHT);
    let browUp: number = (browInnerUp + browOuterUpL + browOuterUpR) / 3;

    let jawOpen: number = bs.get(arEngine.ARBlendShapeType.JAW_OPEN);
    let smileL: number = bs.get(arEngine.ARBlendShapeType.MOUTH_SMILE_LEFT);
    let smileR: number = bs.get(arEngine.ARBlendShapeType.MOUTH_SMILE_RIGHT);
    let smile: number = (smileL + smileR) / 2;
    let frownL: number = bs.get(arEngine.ARBlendShapeType.MOUTH_FROWN_LEFT);
    let frownR: number = bs.get(arEngine.ARBlendShapeType.MOUTH_FROWN_RIGHT);
    let frown: number = (frownL + frownR) / 2;
    let mouthFunnel: number = bs.get(arEngine.ARBlendShapeType.MOUTH_FUNNEL);
    let mouthPucker: number = bs.get(arEngine.ARBlendShapeType.MOUTH_PUCKER);
    let stretchL: number = bs.get(arEngine.ARBlendShapeType.MOUTH_STRETCH_LEFT);
    let stretchR: number = bs.get(arEngine.ARBlendShapeType.MOUTH_STRETCH_RIGHT);
    let mouthStretch: number = (stretchL + stretchR) / 2;
    let mouthRollLower: number = bs.get(arEngine.ARBlendShapeType.MOUTH_ROLL_LOWER);
    let mouthRollUpper: number = bs.get(arEngine.ARBlendShapeType.MOUTH_ROLL_UPPER);

    let cheekPuff: number = bs.get(arEngine.ARBlendShapeType.CHEEK_PUFF);
    let cheekSquintL: number = bs.get(arEngine.ARBlendShapeType.CHEEK_SQUINT_LEFT);
    let cheekSquintR: number = bs.get(arEngine.ARBlendShapeType.CHEEK_SQUINT_RIGHT);
    let cheekSquint: number = (cheekSquintL + cheekSquintR) / 2;

    let noseUp: number = bs.get(arEngine.ARBlendShapeType.FROWN_NOSE_MOUTH_UP);
    let tongueOut: number = bs.get(arEngine.ARBlendShapeType.TONGUE_OUT_SLIGHT);

    let laughScore: number = smile * 0.5 + jawOpen * 0.3 + cheekSquint * 0.2;
    if (laughScore > 0.2 && smile > 0.2 && jawOpen > 0.15) {
      expressions.push({ name: '大笑', emoji: '😆', intensity: laughScore });
    }

    let smileScore: number = smile * 0.6 + cheekSquint * 0.2 + (1 - Math.max(frown, browDown)) * 0.2;
    if (smileScore > 0.15 && smile > 0.12 && frown < 0.08) {
      if (expressions.length === 0 || expressions[0].name !== '大笑') {
        expressions.push({ name: '微笑', emoji: '😊', intensity: smileScore });
      }
    }

    let surpriseScore: number = eyeWide * 0.35 + jawOpen * 0.3 + browUp * 0.35;
    if (surpriseScore > 0.2 && eyeWide > 0.1 && browUp > 0.08) {
      expressions.push({ name: '惊讶', emoji: '😲', intensity: surpriseScore });
    }

    let angryScore: number = browDown * 0.4 + frown * 0.3 + squint * 0.2 + (1 - smile) * 0.1;
    if (angryScore > 0.2 && browDown > 0.1 && smile < 0.08) {
      expressions.push({ name: '生气', emoji: '😠', intensity: angryScore });
    }

    let sadScore: number = frown * 0.35 + browInnerUp * 0.25 + mouthRollLower * 0.15 +
      (1 - smile) * 0.15 + (1 - cheekSquint) * 0.1;
    if (sadScore > 0.2 && frown > 0.1 && smile < 0.08) {
      expressions.push({ name: '悲伤', emoji: '😢', intensity: sadScore });
    }

    let fearScore: number = eyeWide * 0.3 + browInnerUp * 0.3 + (1 - squint) * 0.2 + mouthStretch * 0.2;
    if (fearScore > 0.2 && eyeWide > 0.1 && browInnerUp > 0.1 && browDown < 0.08) {
      expressions.push({ name: '恐惧', emoji: '😨', intensity: fearScore });
    }

    let disgustScore: number = noseUp * 0.3 + squint * 0.2 + browDown * 0.2 + frown * 0.15 + mouthStretch * 0.15;
    if (disgustScore > 0.2 && noseUp > 0.1) {
      expressions.push({ name: '厌恶', emoji: '🤢', intensity: disgustScore });
    }

    if (blink > 0.45) {
      expressions.push({ name: '闭眼', emoji: '😵', intensity: blink });
    } else if (blinkL > 0.3) {
      expressions.push({ name: '左眨', emoji: '😉', intensity: blinkL });
    } else if (blinkR > 0.3) {
      expressions.push({ name: '右眨', emoji: '😉', intensity: blinkR });
    }

    let squintScore: number = squint * 0.6 + cheekSquint * 0.4;
    if (squintScore > 0.2 && squint > 0.1 && smile < 0.1) {
      expressions.push({ name: '眯眼', emoji: '😏', intensity: squintScore });
    }

    let openMouthScore: number = jawOpen * 0.7 + (1 - smile) * 0.3;
    if (openMouthScore > 0.2 && jawOpen > 0.15 && smile < 0.1) {
      expressions.push({ name: '张嘴', emoji: '😮', intensity: openMouthScore });
    } else if (jawOpen > 0.1 && smile < 0.1) {
      expressions.push({ name: '微张', emoji: '😯', intensity: jawOpen });
    }

    let puckerScore: number = (mouthPucker + mouthFunnel) / 2;
    if (puckerScore > 0.2 && jawOpen < 0.1) {
      expressions.push({ name: '嘟嘴', emoji: '😙', intensity: puckerScore });
    }

    if (cheekPuff > 0.2) {
      expressions.push({ name: '鼓腮', emoji: '😤', intensity: cheekPuff });
    }

    if (browUp > 0.2 && browDown < 0.08) {
      expressions.push({ name: '挑眉', emoji: '🤨', intensity: browUp });
    }

    if (tongueOut > 0.2) {
      expressions.push({ name: '吐舌', emoji: '😛', intensity: tongueOut });
    }

    if (noseUp > 0.2 && browDown < 0.08 && frown < 0.08) {
      expressions.push({ name: '皱鼻', emoji: '🤧', intensity: noseUp });
    }

    let poutScore: number = mouthRollLower * 0.6 + mouthRollUpper * 0.4;
    if (poutScore > 0.2 && smile < 0.08) {
      expressions.push({ name: '抿嘴', emoji: '😣', intensity: poutScore });
    }

    expressions.sort((a: ExpressionSnapshot, b: ExpressionSnapshot) => b.intensity - a.intensity);

    if (expressions.length === 0) {
      expressions.push({ name: '平静', emoji: '😐', intensity: 1.0 });
    }

    return expressions;
  }
}

@Entry
@Component
struct A3 {
  @State arContext: arViewController.ARViewContext | undefined = undefined;
  @State faceDetected: boolean = false;
  @State blendShapeData: BlendShapeItem[] = [];
  @State blendShapeWeights: Map<string, number> = new Map();
  @State activeBlendCount: number = 0;
  @State currentExpressions: ExpressionSnapshot[] = [];
  @State serviceReady: boolean = false;
  @State supported: boolean = true;
  @State statusText: string = '正在初始化...';

  private callback: FaceTrackingCallback = new FaceTrackingCallback();

  async aboutToAppear(): Promise<void> {
    this.callback.setOwner(this);
    try {
      let ret: boolean = arViewController.isARTypeSupported(arEngine.ARFeatureType.ARENGINE_FEATURE_TYPE_FACE);
      hilog.info(DOMAIN, TAG, `Face tracking supported: ${ret}`);
      if (!ret) {
        this.supported = false;
        this.statusText = '设备不支持人脸跟踪';
        return;
      }
      let scene: Scene = await Scene.load();
      let context: arViewController.ARViewContext = new arViewController.ARViewContext();
      context.scene = scene;
      context.callback = this.callback;
      context.config = {
        type: arEngine.ARType.FACE,
        planeFindingMode: arEngine.ARPlaneFindingMode.DISABLED,
        powerMode: arEngine.ARPowerMode.NORMAL,
        focusMode: arEngine.ARFocusMode.AUTO,
        cameraLensFacing: arEngine.ARCameraLensFacing.FRONT,
        multiFaceMode: arEngine.ARMultiFaceMode.MULTIFACE_ENABLE,
      };
      await context.init();
      this.arContext = context;
      this.serviceReady = true;
      this.statusText = '请面向摄像头';
      hilog.info(DOMAIN, TAG, 'AR context initialized');
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG, `Init failed: ${err.code}, ${err.message}`);
      this.statusText = `初始化失败: ${err.code}`;
      this.supported = false;
    }
  }

  async aboutToDisappear(): Promise<void> {
    if (this.arContext) {
      try {
        await this.arContext.destroy();
        hilog.info(DOMAIN, TAG, 'AR context destroyed');
      } catch (error) {
        let err: BusinessError = error as BusinessError;
        hilog.error(DOMAIN, TAG, `Destroy failed: ${err.code}`);
      }
    }
  }

  private getBarColor(weight: number): string {
    if (weight >= 0.5) {
      return '#6366F1';
    }
    if (weight >= 0.2) {
      return '#8B5CF6';
    }
    return '#4B5563';
  }

  private getExpressionBg(expr: ExpressionSnapshot): string {
    if (expr.name === '微笑' || expr.name === '大笑') {
      return '#065F4620';
    }
    if (expr.name === '皱眉' || expr.name === '生气') {
      return '#7C2D1220';
    }
    if (expr.name === '挑眉' || expr.name === '惊讶') {
      return '#1E40AF20';
    }
    if (expr.name === '张嘴' || expr.name === '微张') {
      return '#92400E20';
    }
    if (expr.name === '悲伤' || expr.name === '恐惧') {
      return '#581C8720';
    }
    if (expr.name === '厌恶') {
      return '#36531420';
    }
    return '#1A1A33';
  }

  @Builder
  headerBar() {
    Row() {
      Column() {
        Text('AI')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('微表情')
          .fontSize(10)
          .fontColor('#FFFFFFAA')
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Center)

      Blank()

      Text('人脸微表情跟踪')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .layoutWeight(1)
        .textAlign(TextAlign.Center)

      Blank()

      Row() {
        Text(this.faceDetected ? '●' : '○')
          .fontSize(10)
          .fontColor(this.faceDetected ? '#4ADE80' : '#EF4444')
          .margin({ right: 4 })
        Text(this.faceDetected ? '跟踪中' : '搜索中')
          .fontSize(12)
          .fontColor(this.faceDetected ? '#4ADE80' : '#888899')
      }
      .alignItems(VerticalAlign.Center)
    }
    .width('100%')
    .height(56)
    .padding({ left: 20, right: 20 })
    .alignItems(VerticalAlign.Center)
    .backgroundColor('#0F0F23')
  }

  @Builder
  cameraPreview() {
    Stack() {
      if (this.arContext) {
        ARView({ context: this.arContext })
          .width('100%')
          .height('100%')
      } else {
        Column() {
          LoadingProgress()
            .width(40)
            .height(40)
            .color('#6366F1')
          Text(this.statusText)
            .fontSize(14)
            .fontColor('#888899')
            .margin({ top: 12 })
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
        .backgroundColor('#0A0A1A')
      }
    }
    .width('100%')
    .layoutWeight(1)
  }

  @Builder
  expressionCard() {
    Column() {
      Row() {
        Text('实时微表情')
          .fontSize(14)
          .fontWeight(FontWeight.Medium)
          .fontColor('#FFFFFF')
        Blank()
        if (this.currentExpressions.length > 0) {
          Text(this.currentExpressions[0].emoji)
            .fontSize(24)
        }
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 4 })

      if (this.currentExpressions.length > 0) {
        Row() {
          ForEach(this.currentExpressions, (expr: ExpressionSnapshot, index: number) => {
            Column() {
              Text(expr.emoji)
                .fontSize(22)
              Text(expr.name)
                .fontSize(10)
                .fontColor('#AAAACC')
                .margin({ top: 2 })
              Row() {
                Row()
                  .width(Math.max(2, expr.intensity * 32))
                  .height(3)
                  .borderRadius(2)
                  .backgroundColor(this.getExpressionBg(expr) === '#1A1A33' ? '#6366F1' : '#4ADE80')
              }
              .width(36)
              .height(3)
              .borderRadius(2)
              .backgroundColor('#1A1A33')
              .margin({ top: 4 })
            }
            .padding({ left: 8, right: 8, top: 8, bottom: 8 })
            .backgroundColor(this.getExpressionBg(expr))
            .borderRadius(12)
            .margin({ left: index === 0 ? 16 : 6 })
            .alignItems(HorizontalAlign.Center)
          }, (_expr: ExpressionSnapshot, index: number) => `${index}`)
        }
        .width('100%')
        .padding({ top: 4, bottom: 12 })
      } else {
        Text('面向摄像头开始检测微表情')
          .fontSize(12)
          .fontColor('#666688')
          .padding({ left: 16, top: 4, bottom: 12 })
      }
    }
    .width('100%')
    .backgroundColor('#141428')
    .borderRadius({ topLeft: 20, topRight: 20 })
    .shadow({ radius: 12, color: '#00000044', offsetY: -4 })
  }

  @Builder
  blendShapePanel() {
    Column() {
      Row() {
        Text('表情参数')
          .fontSize(13)
          .fontWeight(FontWeight.Medium)
          .fontColor('#AAAACC')
        Blank()
        Text(`${this.activeBlendCount} 项活跃`)
          .fontSize(11)
          .fontColor('#6366F1')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 10, bottom: 6 })

      Scroll() {
        Column() {
          ForEach(this.blendShapeData, (shape: BlendShapeItem, index: number) => {
            Row() {
              Text(shape.label)
                .fontSize(11)
                .fontColor((this.blendShapeWeights.get(shape.label) ?? 0) > 0.05 ? '#FFFFFF' : '#666688')
                .width(72)
                .flexShrink(0)

              Row() {
                Row()
                  .width(`${Math.max(1, (this.blendShapeWeights.get(shape.label) ?? 0) * 100)}%`)
                  .height('100%')
                  .borderRadius(3)
                  .backgroundColor(this.getBarColor(this.blendShapeWeights.get(shape.label) ?? 0))
              }
              .width('100%')
              .height(6)
              .borderRadius(3)
              .backgroundColor('#1A1A33')
              .layoutWeight(1)

              Text(`${Math.round((this.blendShapeWeights.get(shape.label) ?? 0) * 100)}%`)
                .fontSize(10)
                .fontColor((this.blendShapeWeights.get(shape.label) ?? 0) > 0.05 ? '#6366F1' : '#4B5563')
                .width(36)
                .textAlign(TextAlign.End)
                .margin({ left: 8 })
            }
            .width('100%')
            .padding({ left: 16, right: 16, top: 5, bottom: 5 })
            .alignItems(VerticalAlign.Center)
          }, (_shape: BlendShapeItem, index: number) => `${index}`)
        }
        .width('100%')
      }
      .width('100%')
      .height(180)
      .edgeEffect(EdgeEffect.Spring)
    }
    .width('100%')
    .backgroundColor('#0F0F23')
    .borderRadius({ topLeft: 20, topRight: 20 })
    .shadow({ radius: 8, color: '#00000033', offsetY: -2 })
  }

  build() {
    Column() {
      this.headerBar()

      this.cameraPreview()

      this.expressionCard()

      this.blendShapePanel()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0A0A1A')
  }
}

总结

这个微表情跟踪案例的核心可以归纳为三条主线:

1. AREngine 人脸 BlendShape 追踪的使用范式。 isARTypeSupported 前置检测 → Scene.load 创建 3D 场景 → 构造 ARViewContext(配置 AR 类型为 FACE、前置摄像头、禁用平面检测)→ context.init() 初始化 → 注册 ARViewCallbackonFrameUpdate 帧回调 → 在回调中通过 session.getAllTrackables(FACE) 获取 ARFaceface.getBlendShapes() 获取 BlendShape 数据。这套流程是 AREngine 所有 AR 类型(人脸、手势、平面等)的通用模式,区别只在 ARTypeARTrackableType 的选择。

2. 指数移动平均(EMA)数据平滑。 AREngine 每帧输出的 BlendShape 值存在帧间抖动,直接用于 UI 渲染会导致进度条和表情标签闪烁。SmoothedBlendShapessmoothed = old * (1 - α) + new * α 的 EMA 公式做时域平滑,α = 0.4 是响应速度和稳定性的折中值。关键设计细节:平滑不只针对当前帧出现的 key,还包括之前平滑过的所有 key——这样 BlendShape 值消失时是逐步衰减而非突然归零。

3. 加权评分表情分类算法。 单个 BlendShape 不等于一个表情,需要多参数加权组合。每个表情定义一个评分公式(如大笑 = 嘴角笑 * 0.5 + 张嘴 * 0.3 + 脸颊抬 * 0.2),加上阈值条件和互斥条件(如微笑判定要求 frown < 0.08)。表情之间通过优先级排序(强度降序)和互斥检查(如"大笑"存在时不追加"微笑")避免冲突。权重和阈值是经验值,不同人群的面部特征差异可能需要调参。

后续可以在当前结构上扩展:驱动 3D 虚拟角色的面部 BlendShape 实现表情同步、基于表情触发互动效果(如检测到微笑自动拍照)、多人脸同时追踪各自独立展示表情、表情数据录制与回放。无论扩展哪一项,建议保留本文的两个关键设计:EMA 平滑保证数据稳定性,加权评分 + 互斥条件保证分类准确性。

Logo

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

更多推荐