HarmonyOS AR Engine人脸表情跟踪实操——84个关键点与眨眼微笑张嘴挑战

这次做的是一面能读懂表情的 AR 镜子:前置相机找到人脸后,把 AR Engine 返回的 84 个关键点叠加到五官上,同时读取 64 项表情参数,依次完成眨眼、张嘴和微笑三个动作。
真机最终完成了全部三项挑战。页面锁定在第 218 次采样,保留 84/84 个关键点、64/64 项表情参数、10446 个网格顶点和 6698 个三角索引;本轮峰值为眨眼 0.98、微笑 1.00、张嘴 0.60。

画面里的彩色点不是预先摆放的装饰。它们来自当前 ARFace 的关键点缓冲;三个“已捕获”状态也不是按钮直接点亮,而是 BlendShapes 参数连续达到阈值后的结果。
本次结果
| 项目 | 真机结果 |
|---|---|
| 人脸数量 | 1 |
| 2D 关键点 | 84/84 |
| 表情参数 | 64/64 |
| 人脸网格顶点 | 10446 |
| 三角形索引 | 6698 |
| 坐标模式 | NORMALIZED_01 |
| 眨眼峰值 | 0.98 |
| 微笑峰值 | 1.00 |
| 张嘴峰值 | 0.60 |
| 表情挑战 | 3/3 |
| 采样异常 | 0 |
页面上的内容怎么看
页面只保留三层信息:
- 人脸上的彩色点:当前帧返回的 84 个 2D 关键点;
- 顶部三个徽章:眨眼、微笑和张嘴的实时值、会话峰值及完成状态;
- 底部紧凑操作条:人脸数量、关键点、表情参数、网格统计、锁定、暂停和退出。
下面这张过程图里,眨眼和张嘴已经完成,微笑峰值只有 0.24,所以进度是 2/3。它能区分“已经检测到人脸”和“微笑动作已经达到阈值”这两个状态。

继续做出明显微笑后,双侧微笑参数达到 1.00,第三项被点亮:

实验准备
本次实际环境如下:
| 项目 | 实际环境 |
|---|---|
| 设备 | HUAWEI Mate 60 Pro |
| 系统 | HarmonyOS 7.0 |
| SDK | API 26 |
| 工程 | ArkTS Phone 应用,targetSdkVersion 26 |
| 必需权限 | CAMERA |
| 必需能力 | ARENGINE_FEATURE_TYPE_FACE |
| AR 会话类型 | ARType.FACE |
| 相机 | 前置相机 |
| 多人模式 | 关闭,只跟踪一张脸 |
实验可以直接对着真人操作,也可以在另一块屏幕播放相册中的人脸视频,再让手机前置相机对准视频。后者仍然是对屏幕画面的实时相机跟踪,并不是把 MP4 文件直接传给 AR Engine。
先检查相机权限和 FACE 能力
相机权限和 FACE 能力检查放在进入实验页之前。任一条件不满足,页面只显示拦截原因,不创建 AR 会话。
private startFaceTracking(): void {
if (!this.cameraGranted) {
this.latestMessage =
'拦截成功:未获得相机权限,不启动人脸跟踪。';
hilog.info(DOMAIN, TAG,
'FACE_GUARD camera=false');
return;
}
if (!this.canStartFace()) {
this.latestMessage =
'拦截成功:当前设备不支持人脸跟踪,不创建 AR 会话。';
hilog.info(DOMAIN, TAG,
'FACE_GUARD face=false');
return;
}
hilog.info(DOMAIN, TAG,
'FACE_GUARD camera=true face=true');
this.showFaceTracking = true;
}
本次真机门禁通过:
FACE_GUARD camera=true face=true
建立前置人脸会话
这次只读取人脸,不需要平面、语义、深度或 Mesh,因此这些模式全部关闭。ARType.FACE 选择人脸会话,FRONT 选择前置摄像头,MULTIFACE_DISABLE 让页面只处理一张主脸。
private async initARView(): Promise<void> {
let pendingContext:
arViewController.ARViewContext | undefined = undefined;
try {
const startTime: number = Date.now();
const scene: Scene = await Scene.load();
pendingContext = new arViewController.ARViewContext();
this.callback = new FaceTrackingCallback(
(context: arViewController.ARViewContext,
timestamp: number) => {
this.handleFrameUpdate(context, timestamp);
}
);
pendingContext.scene = scene;
pendingContext.callback = this.callback;
pendingContext.config = {
type: arEngine.ARType.FACE,
planeFindingMode:
arEngine.ARPlaneFindingMode.DISABLED,
powerMode: arEngine.ARPowerMode.NORMAL,
semanticMode: arEngine.ARSemanticMode.NONE,
poseMode: arEngine.ARPoseMode.GRAVITY,
depthMode: arEngine.ARDepthMode.DISABLED,
meshMode: arEngine.ARMeshMode.DISABLED,
focusMode: arEngine.ARFocusMode.AUTO,
cameraLensFacing:
arEngine.ARCameraLensFacing.FRONT,
multiFaceMode:
arEngine.ARMultiFaceMode.MULTIFACE_DISABLE
};
await pendingContext.init();
this.arContext = pendingContext;
this.sessionReadyTime = Date.now();
hilog.info(DOMAIN, TAG,
'FACE_SESSION_INIT success costMs=%{public}d ' +
'type=1 lens=1 multiFace=disabled focus=1',
Date.now() - startTime);
} catch (error) {
const err: BusinessError = error as BusinessError;
hilog.error(DOMAIN, TAG,
'FACE_SESSION_INIT error=%{public}d %{public}s',
err.code, err.message);
if (pendingContext) {
await pendingContext.destroy();
}
}
}
真机初始化只用了 14 ms:
FACE_SESSION_INIT success costMs=14 type=1 lens=1 multiFace=disabled focus=1
从相机帧取得 ARFace
整个数据流是“前置相机—ARFrame—ARFace—关键点与表情参数—三项动作”。

相机回调频率很高,页面按 100 ms 节流。上一轮还没释放、页面暂停、证据锁定或页面已经销毁时,不开启新的读取。
private handleFrameUpdate(
context: arViewController.ARViewContext,
timestamp: number
): void {
const now: number = Date.now();
if (this.isDestroyed || this.isPaused ||
this.evidenceLocked || this.samplingInFlight ||
now - this.lastSampleTime < SAMPLE_INTERVAL_MS) {
return;
}
this.lastSampleTime = now;
this.samplingInFlight = true;
this.sampleFace(context).finally(() => {
this.samplingInFlight = false;
});
}
每轮先取得 ARFrame,再从当前会话查询 FACE Trackable。只有状态为 TRACKING 的人脸才继续读取。
private async sampleFace(
context: arViewController.ARViewContext
): Promise<void> {
const session: arEngine.ARSession | undefined =
context.session;
if (!session) {
return;
}
let frame: arEngine.ARFrame | undefined = undefined;
let landmark: arEngine.ARLandmark | undefined = undefined;
let geometry: arEngine.ARGeometry | undefined = undefined;
let blendShapes: arEngine.ARBlendShapes | undefined = undefined;
let trackables: Array<arEngine.ARTrackable> = [];
try {
frame = session.getFrame();
trackables = session.getAllTrackables(
arEngine.ARTrackableType.FACE);
const faces = trackables.filter(
(trackable: arEngine.ARTrackable) =>
trackable.state ===
arEngine.ARTrackingState.TRACKING);
this.sampleCount += 1;
this.faceCount = faces.length;
if (faces.length === 0) {
this.resetLiveFace();
this.drawWaitingFrame();
return;
}
const face: arEngine.ARFace =
faces[0] as arEngine.ARFace;
landmark = face.getLandmark();
geometry = face.getGeometry();
blendShapes = face.getBlendShapes();
const landmarkValues = new Float32Array(
landmark.getVertices2D());
const result: LandmarkResult =
this.convertLandmarks(
landmarkValues, landmark.count);
this.landmarkCount = result.points.length;
this.geometryVertices = geometry.verticesSize;
this.triangleIndices = geometry.triangleIndicesCount;
this.blendShapeCount = blendShapes.count;
const expressions: ExpressionValues =
this.readExpressions(blendShapes);
this.blinkValue = Math.max(
expressions.blinkLeft, expressions.blinkRight);
this.smileValue =
(expressions.smileLeft + expressions.smileRight) / 2;
this.jawValue = expressions.jawOpen;
this.updateChallenges();
this.drawFacePoints(result.points);
this.logExpressions(expressions);
} finally {
await this.releaseFaceResources(
landmark, geometry, blendShapes,
trackables, frame);
}
}
首次识别返回了完整数据:
FACE_FIRST_TRACK latencyMs=909 faces=1 landmarks=84 floats2D=168 geometryVertices=10446 triangleIndices=6698 blendCount=64
84 × 2 = 168,正好对应每个关键点的一组 X、Y 坐标。
把84个关键点画回相机画面
本机返回的是 0~1 归一化坐标。为了避免把另一种坐标格式误当成归一化值,页面先记录最小值与最大值,再选择映射方式。
private convertLandmarks(
values: Float32Array,
count: number
): LandmarkResult {
const pairCount: number = Math.min(
84, count, Math.floor(values.length / 2));
let minX: number = Number.POSITIVE_INFINITY;
let minY: number = Number.POSITIVE_INFINITY;
let maxX: number = Number.NEGATIVE_INFINITY;
let maxY: number = Number.NEGATIVE_INFINITY;
for (let index = 0; index < pairCount; index += 1) {
const x: number = values[index * 2];
const y: number = values[index * 2 + 1];
if (!Number.isFinite(x) || !Number.isFinite(y)) {
continue;
}
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
const normalized: boolean =
minX >= -0.05 && minY >= -0.05 &&
maxX <= 1.05 && maxY <= 1.05;
const points: FacePoint[] = [];
if (!normalized) {
return { points: points, mode: 'UNRESOLVED', range: '--' };
}
for (let index = 0; index < pairCount; index += 1) {
let x: number = values[index * 2];
const y: number = values[index * 2 + 1];
if (this.mirrorOverlay) {
x = 1 - x;
}
points.push({
x: x * this.overlayContext.width,
y: y * this.overlayContext.height
});
}
return {
points: points,
mode: 'NORMALIZED_01',
range: `x ${minX.toFixed(3)}~${maxX.toFixed(3)} / ` +
`y ${minY.toFixed(3)}~${maxY.toFixed(3)}`
};
}
本次真机范围如下:
FACE_LANDMARK_RANGE mode=NORMALIZED_01 points=84 range=x 0.412~0.814 / y 0.398~0.817 mirror=false
点阵绘制只使用转换后的真实点。无效点、未识别坐标和空缓冲都不会用手工位置补齐。
private drawFacePoints(points: FacePoint[]): void {
if (!this.canvasReady || this.evidenceLocked) {
return;
}
this.overlayContext.clearRect(
0, 0,
this.overlayContext.width,
this.overlayContext.height);
points.forEach((point: FacePoint, index: number) => {
const phase: number = index % 3;
const color: string = phase === 0
? '#72FFE1'
: (phase === 1 ? '#D694FF' : '#FFD36E');
this.overlayContext.beginPath();
this.overlayContext.arc(
point.x, point.y,
index % 7 === 0 ? 2.7 : 1.8,
0, Math.PI * 2);
this.overlayContext.fillStyle = color;
this.overlayContext.fill();
});
}
从64项参数读取眨眼、微笑和张嘴
getTypes() 返回每个参数的类型,getData() 返回对应数值。两组数据按同一索引写入 Map,再读取这次需要的五项:左右眼闭合、左右嘴角微笑和下颌张开。
private readExpressions(
blendShapes: arEngine.ARBlendShapes
): ExpressionValues {
const types: Array<arEngine.ARBlendShapeType> =
blendShapes.getTypes();
const data: Float32Array =
new Float32Array(blendShapes.getData());
const values = new Map<
arEngine.ARBlendShapeType, number>();
const count: number = Math.min(
blendShapes.count, types.length, data.length);
for (let index = 0; index < count; index += 1) {
values.set(types[index], data[index]);
}
return {
blinkLeft: this.expressionValue(
values, arEngine.ARBlendShapeType.EYE_BLINK_LEFT),
blinkRight: this.expressionValue(
values, arEngine.ARBlendShapeType.EYE_BLINK_RIGHT),
smileLeft: this.expressionValue(
values, arEngine.ARBlendShapeType.MOUTH_SMILE_LEFT),
smileRight: this.expressionValue(
values, arEngine.ARBlendShapeType.MOUTH_SMILE_RIGHT),
jawOpen: this.expressionValue(
values, arEngine.ARBlendShapeType.JAW_OPEN)
};
}
为了避免一帧噪声直接点亮挑战,每个动作连续达到阈值两次才完成。完成状态会锁存,动作恢复后不会立即熄灭。
private updateChallenges(): void {
this.blinkHold = this.advanceChallenge(
this.blinkValue >= 0.55,
this.blinkHold, this.blinkDone, 'blink');
this.smileHold = this.advanceChallenge(
this.smileValue >= 0.42,
this.smileHold, this.smileDone, 'smile');
this.jawHold = this.advanceChallenge(
this.jawValue >= 0.35,
this.jawHold, this.jawDone, 'jaw');
}
private advanceChallenge(
active: boolean,
hold: number,
done: boolean,
challenge: string
): number {
if (done) {
return 2;
}
const nextHold: number = active
? Math.min(2, hold + 1) : 0;
if (nextHold < 2) {
return nextHold;
}
if (challenge === 'blink') {
this.blinkDone = true;
} else if (challenge === 'smile') {
this.smileDone = true;
} else {
this.jawDone = true;
}
this.completedCount += 1;
return nextHold;
}
三个动作在同一会话中依次完成:
FACE_CHALLENGE_COMPLETE challenge=blink completed=1 blink=0.953 smile=0.000 jaw=0.000 sample=64
FACE_CHALLENGE_COMPLETE challenge=jaw completed=2 blink=0.078 smile=0.004 jaw=0.517 sample=81
FACE_CHALLENGE_COMPLETE challenge=smile completed=3 blink=0.316 smile=1.000 jaw=0.000 sample=166
真机操作过程
- 进入“实验 12:人脸表情镜”,让正脸占据画面中间;
- 等页面显示“84 点跟踪中”,先检查彩色点是否落在额头、眼、鼻、嘴和下巴附近;
- 双眼明显闭合片刻,看到“眨眼”变成“已捕获”;
- 张大嘴保持约半秒,看到“张嘴”变成“已捕获”;
- 明显扬起两侧嘴角,保持到“微笑”变成“已捕获”;
- 三项都完成后先保持手机和人脸位置不变,再点击“锁定当前画面”;
- 截图完成后点击“继续跟踪”,最后验证暂停、恢复和销毁退出。
动作不必严格按上述顺序。当前页面会保留每项会话峰值,表情恢复后仍能看到刚才是否达到过阈值。
实操中遇到的三个问题
1. 人脸已经出现,三个表情却一直显示0.00
首轮后台日志已经读到眨眼 0.98、微笑 1.00、张嘴 0.93,顶部总进度也到 3/3,但自定义 Builder 中的三个值仍停在 0.00。
原因是 Builder 接收了调用时计算好的 value/done/hold 参数,内部没有直接读取对应的 @State。修订后 Builder 只接收标签,显示值在 Builder 内实时取得:
@Builder
private expressionBadge(
label: string,
color: string
): void {
Column({ space: 1 }) {
Text(this.expressionDone(label)
? '✓ 已捕获'
: this.expressionLiveValue(label).toFixed(2))
Text(`${label} · 峰 ` +
this.expressionPeakValue(label).toFixed(2))
}
.backgroundColor(
this.expressionDone(label) ? color : '#152331')
}
第二轮页面上的完成状态、实时值、峰值和日志开始同步变化。
2. 控制区占了接近半屏,人脸效果不完整
旧页面从屏幕约 54% 的位置开始放置六张统计卡、三条进度和四个按钮,脸部取景被明显压缩。
修订后顶部只保留三项表情徽章,底部只保留一行统计和四个操作按钮,操作条从屏幕约 78% 处开始。完整正脸、84 点和挑战状态可以同时进入一张截图。
3. 锁定后移动手机,点阵留在墙上
证据锁定会停止新数据和 Canvas 重绘,因此锁定时的点不会继续跟着相机移动。这是冻结证据的预期效果,不是新的跟踪结果。
最终截图的顺序改为:保持正脸位置—确认 3/3—点击锁定—立即截图—最后再移动手机。墙面上的冻结点阵没有作为文章结果图。
释放每一轮人脸资源
人脸读取涉及 Landmark、Geometry、BlendShapes、Trackable 和 ARFrame。无论本轮成功、数据未就绪还是发生异常,它们都在 finally 中释放。
private async releaseFaceResources(
landmark: arEngine.ARLandmark | undefined,
geometry: arEngine.ARGeometry | undefined,
blendShapes: arEngine.ARBlendShapes | undefined,
trackables: Array<arEngine.ARTrackable>,
frame: arEngine.ARFrame | undefined
): Promise<void> {
if (landmark) {
await landmark.release();
}
if (geometry) {
await geometry.release();
}
if (blendShapes) {
await blendShapes.release();
}
for (let index = 0;
index < trackables.length; index += 1) {
await trackables[index].release();
}
if (frame) {
await frame.release();
}
}
实际工程对每次 release() 还分别做了异常捕获,避免一个资源释放失败后跳过后续资源。
暂停、恢复和销毁
暂停调用 ARViewContext.pause();恢复调用 resume() 并重置节流时间;退出页面销毁整个 ARViewContext。
private pauseARView(): void {
if (!this.arContext || this.isPaused) {
return;
}
this.arContext.pause();
this.isPaused = true;
hilog.info(DOMAIN, TAG,
'FACE_SESSION_PAUSE sample=%{public}d',
this.sampleCount);
}
private resumeARView(): void {
if (!this.arContext || !this.isPaused) {
return;
}
this.arContext.resume();
this.isPaused = false;
this.lastSampleTime = 0;
hilog.info(DOMAIN, TAG,
'FACE_SESSION_RESUME sample=%{public}d locked=%{public}s',
this.sampleCount, `${this.evidenceLocked}`);
}
private async destroyARView(): Promise<void> {
if (!this.arContext || this.isDestroyed) {
return;
}
this.isDestroyed = true;
const context = this.arContext;
this.arContext = undefined;
await context.destroy();
}
暂停和恢复都停在第 218 次采样,销毁时没有待处理数据和异常:
FACE_SESSION_PAUSE sample=218
FACE_SESSION_RESUME sample=218 locked=true
FACE_SESSION_DESTROY sample=218 pending=0 errors=0 completed=3 locked=true
完整真机日志
下面是本次文章使用的脱敏日志,已经删除应用包名:
FACE_GUARD camera=true face=true
FACE_SESSION_INIT success costMs=14 type=1 lens=1 multiFace=disabled focus=1
FACE_FIRST_TRACK latencyMs=909 faces=1 landmarks=84 floats2D=168 geometryVertices=10446 triangleIndices=6698 blendCount=64
FACE_LANDMARK_RANGE mode=NORMALIZED_01 points=84 range=x 0.412~0.814 / y 0.398~0.817 mirror=false
FACE_CHALLENGE_COMPLETE challenge=blink completed=1 blink=0.953 smile=0.000 jaw=0.000 sample=64
FACE_CHALLENGE_COMPLETE challenge=jaw completed=2 blink=0.078 smile=0.004 jaw=0.517 sample=81
FACE_STATS sample=166 faces=1 landmarks=84 geometryVertices=10446 triangleIndices=6698 blendCount=64 blinkLeft=0.288 blinkRight=0.316 smileLeft=1.000 smileRight=1.000 jawOpen=0.000 completed=3
FACE_CHALLENGE_COMPLETE challenge=smile completed=3 blink=0.316 smile=1.000 jaw=0.000 sample=166
FACE_EVIDENCE_LOCK sample=218 landmarks=84 blendCount=64 completed=3 mode=NORMALIZED_01
FACE_SESSION_PAUSE sample=218
FACE_SESSION_RESUME sample=218 locked=true
FACE_SESSION_DESTROY sample=218 pending=0 errors=0 completed=3 locked=true
最终验证
本次完整验证得到以下结果:
- CAMERA 权限和 FACE 能力门禁通过;
- 前置 FACE 会话初始化成功;
- 真机返回 1 张人脸、84 个关键点和 64 项表情参数;
- Geometry 返回 10446 个网格顶点和 6698 个三角索引;
- 84 点与真人额头、眼、鼻、嘴和下巴位置对应;
- 眨眼、张嘴和微笑分别完成,最终进度为 3/3;
- 锁定画面保留第 218 次真实数据;
- 暂停期间采样不增加,恢复后保持锁定状态;
- 销毁时
pending=0 / errors=0; - 静态回归、ArkTS 编译、HAP 打包和签名均通过。
这次结果验证的是“人脸关键点与表情参数跟踪”,不是姓名识别或身份比对。应用没有保存原始相机帧、人脸模板或长期表情记录;文章中的真人图片只来自本次真机截图。
参考资料
更多推荐


所有评论(0)