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

真机操作过程

  1. 进入“实验 12:人脸表情镜”,让正脸占据画面中间;
  2. 等页面显示“84 点跟踪中”,先检查彩色点是否落在额头、眼、鼻、嘴和下巴附近;
  3. 双眼明显闭合片刻,看到“眨眼”变成“已捕获”;
  4. 张大嘴保持约半秒,看到“张嘴”变成“已捕获”;
  5. 明显扬起两侧嘴角,保持到“微笑”变成“已捕获”;
  6. 三项都完成后先保持手机和人脸位置不变,再点击“锁定当前画面”;
  7. 截图完成后点击“继续跟踪”,最后验证暂停、恢复和销毁退出。

动作不必严格按上述顺序。当前页面会保留每项会话峰值,表情恢复后仍能看到刚才是否达到过阈值。

实操中遇到的三个问题

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 打包和签名均通过。

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

参考资料

Logo

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

更多推荐