【共创稿事节】HarmonyOS7 人脸微表情跟踪实战:AREngine 与 BlendShape 实时驱动表情识别
前言
本篇博客所讲的这个案例要解决的问题是:在 HarmonyOS7 上,如何用 AREngine 的人脸 BlendShape 追踪能力实现实时微表情识别,并保证数据平滑和分类准确性?
AREngine 的 ARFace.getBlendShapes() 返回当前帧所有 BlendShape 的类型数组和数据缓冲区,数据类型是 Float32Array,每个值 0~1 表示该面部区域的形变程度。这组数据有几个特点需要处理:
- 帧间抖动:单帧数据不稳定,需要时域平滑
- 语义组合:单个 BlendShape 不等于一个表情,需要多参数加权组合
- 互斥判定:不同表情之间可能共享参数,需要优先级和互斥条件
本案例的 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提供人脸追踪的底层类型(ARSession、ARFace、ARBlendShapes、ARBlendShapeType),ARView是摄像头预览组件,arViewController提供ARViewContext和ARViewCallback用于帧回调@kit.ArkGraphics3D:3D 场景管理——Scene是 AREngine 渲染所需的 3D 场景根对象,Node是回调参数中的场景节点类型@kit.PerformanceAnalysisKit:日志输出@kit.BasicServicesKit:BusinessError类型用于错误处理
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 数组。执行流程:
- 构建当前帧的值映射:把
types[i]和values[i]配对存入newValues - 收集活跃 key:当前帧出现过的 key + 之前平滑过的 key,合并为一个集合。这样即使某个 BlendShape 在当前帧消失了(值为 0),它的平滑值也会逐步衰减而非突然归零
- 对每个 key 执行 EMA:
smoothed = 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。这就是"平滑"的效果——延迟响应,但避免闪烁。
辅助方法
avg 和 triple 是快捷方法,用于后续表情分类中对左右对称的 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 会话。核心流程:
- 从
ctx.session获取ARSession session.getAllTrackables(FACE)获取当前帧检测到的所有人脸- 无人脸时重置所有状态(
faceDetected = false、清空数据) - 有人脸时,遍历找到处于
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() 初始化 → 注册 ARViewCallback 的 onFrameUpdate 帧回调 → 在回调中通过 session.getAllTrackables(FACE) 获取 ARFace → face.getBlendShapes() 获取 BlendShape 数据。这套流程是 AREngine 所有 AR 类型(人脸、手势、平面等)的通用模式,区别只在 ARType 和 ARTrackableType 的选择。
2. 指数移动平均(EMA)数据平滑。 AREngine 每帧输出的 BlendShape 值存在帧间抖动,直接用于 UI 渲染会导致进度条和表情标签闪烁。SmoothedBlendShapes 用 smoothed = old * (1 - α) + new * α 的 EMA 公式做时域平滑,α = 0.4 是响应速度和稳定性的折中值。关键设计细节:平滑不只针对当前帧出现的 key,还包括之前平滑过的所有 key——这样 BlendShape 值消失时是逐步衰减而非突然归零。
3. 加权评分表情分类算法。 单个 BlendShape 不等于一个表情,需要多参数加权组合。每个表情定义一个评分公式(如大笑 = 嘴角笑 * 0.5 + 张嘴 * 0.3 + 脸颊抬 * 0.2),加上阈值条件和互斥条件(如微笑判定要求 frown < 0.08)。表情之间通过优先级排序(强度降序)和互斥检查(如"大笑"存在时不追加"微笑")避免冲突。权重和阈值是经验值,不同人群的面部特征差异可能需要调参。
后续可以在当前结构上扩展:驱动 3D 虚拟角色的面部 BlendShape 实现表情同步、基于表情触发互动效果(如检测到微笑自动拍照)、多人脸同时追踪各自独立展示表情、表情数据录制与回放。无论扩展哪一项,建议保留本文的两个关键设计:EMA 平滑保证数据稳定性,加权评分 + 互斥条件保证分类准确性。
更多推荐



所有评论(0)