云计算百科
云计算领域专业知识百科平台

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

AR Engine人脸表情跟踪实战封面

这次做的是一面能读懂表情的 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。它能区分“已经检测到人脸”和“微笑动作已经达到阈值”这两个状态。

84点跟踪中并已完成两项挑战

继续做出明显微笑后,双侧微笑参数达到 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 打包和签名均通过。

    这次结果验证的是“人脸关键点与表情参数跟踪”,不是姓名识别或身份比对。应用没有保存原始相机帧、人脸模板或长期表情记录;文章中的真人图片只来自本次真机截图。

    参考资料

    • 华为开发者联盟:AR Engine开发指导
    • 华为开发者联盟:人脸跟踪(ArkTS)
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » HarmonyOS AR Engine人脸表情跟踪实操——84个关键点与眨眼微笑张嘴挑战
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!