AR Engine人体骨骼跟踪实战封面

这次做的是一个可以直接看到结果的人体动作实验:前置相机找到真人后,把 AR Engine 返回的 20 个骨骼关键点叠加到身体上,再根据手腕和肩膀的位置判断是否举手。

真机最终稳定取得 16 个有效点。左手、右手可以分别识别,两只手同时举起并连续保持 3 次采样后,完成次数增加 1。下面这张结果图中,青色是画面左侧肢体,紫色是画面右侧肢体,黄色是头部和躯干;两侧动作状态都已经点亮,完成次数为 6。

双手举起动作识别成功

这不是预先画好的骨架动画。点的位置、有效状态、置信度和动作结果都来自同一轮真机人体跟踪。

本次结果

项目 真机结果
人体数量 1
返回关键点 20 个
稳定有效点 15~16 个
动作判定最低有效点 12 个
有效置信度阈值 0.45
双手举起保持 连续 3 次采样
本轮完成次数 6 次
会话镜头 前置相机

页面上的内容分别表示什么

页面中真正需要关注的是四块信息:

  • 真人身上的圆点和连线:AR Engine 当前返回的有效骨骼点;
  • 顶部“左手 / 右手”:同侧手腕是否已经高于肩膀;
  • “有效点”:20 个点里有多少个通过了有效性和置信度检查;
  • “完成”:双手同时举起并保持后累计的成功次数。

自然站立时,两侧都不满足举手条件,但骨架已经贴合头、肩、手臂和躯干,可以先用这个状态检查坐标有没有偏移。

自然站立时的真实骨架

单独举起画面左侧的手,页面只点亮左手:

左手举起识别结果

换另一侧后,只点亮右手:

右手举起识别结果

实验准备

本次实际环境如下:

项目 实际环境
设备 HUAWEI Mate 60 Pro
系统 HarmonyOS 7.0
SDK API 26
工程 ArkTS Phone 应用,targetSdkVersion 26
必需权限 CAMERA
必需能力 ARENGINE_FEATURE_TYPE_BODY
AR 会话类型 ARType.BODY
相机 前置相机
测试空间 能后退到全身入镜的明亮区域

手机采用竖屏固定,镜头高度接近胸口,人站在镜头前约 2~3 米的位置。头、双手和脚尽量同时进入画面;只看到上半身时,人体可能已经出现,但有效点不足以完成动作。

实验工作台增加“实验 11:人体动作镜”入口。进入前先确认相机权限和人体能力都可用。

人体骨骼跟踪实验入口

进入页面前先检查能力

能力检查放在创建 AR 会话之前。相机权限或 BODY 能力缺少时,页面直接提示原因,不启动相机。

private startBodyTracking(): void {
  if (!this.cameraGranted) {
    this.latestMessage =
      '拦截成功:未获得相机权限,不启动人体骨骼跟踪。';
    hilog.info(DOMAIN, TAG, 'BODY_GUARD camera=false');
    return;
  }
  if (!this.canStartBody()) {
    this.latestMessage =
      '拦截成功:当前设备不支持人体骨骼跟踪,不创建 AR 会话。';
    hilog.info(DOMAIN, TAG, 'BODY_GUARD body=false');
    return;
  }

  this.latestMessage = '正在进入人体动作镜…';
  hilog.info(DOMAIN, TAG,
    'BODY_GUARD camera=true body=true');
  this.showBodyTracking = true;
}

真机进入时记录:

BODY_GUARD camera=true body=true

建立前置人体会话

这次只读人体骨骼,不需要平面、深度、Mesh 或普通语义,所以这些模式全部关闭。ARType.BODY 选择人体会话,FRONT 选择前置相机,最多检测两个人。

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 BodyTrackingCallback(
      (context: arViewController.ARViewContext,
        timestamp: number) => {
        this.handleFrameUpdate(context, timestamp);
      }
    );

    pendingContext.scene = scene;
    pendingContext.callback = this.callback;
    pendingContext.config = {
      type: arEngine.ARType.BODY,
      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,
      maxDetectedBodyNum: 2
    };

    await pendingContext.init();
    this.arContext = pendingContext;
    this.sessionReadyTime = Date.now();

    hilog.info(DOMAIN, TAG,
      'BODY_SESSION_INIT success costMs=%{public}d ' +
      'type=2 lens=1 maxBodies=2 focus=1',
      Date.now() - startTime);
  } catch (error) {
    const err: BusinessError = error as BusinessError;
    hilog.error(DOMAIN, TAG,
      'BODY_SESSION_INIT error=%{public}d %{public}s',
      err.code, err.message);
    if (pendingContext) {
      await pendingContext.destroy();
    }
  }
}

本次初始化耗时 38 ms:

BODY_SESSION_INIT success costMs=38 type=2 lens=1 maxBodies=2 focus=1

从当前帧读取人体和 20 个关键点

数据链路可以压缩成四步:前置相机产生当前帧,从帧中取得 ARBody,从人体对象读取 20 个点,最后比较手腕与肩膀的 Y 坐标。

从相机帧到举手动作判定

相机回调频率较高,页面按 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.sampleBody(context).finally(() => {
    this.samplingInFlight = false;
  });
}

每轮先取得当前 ARFrame,然后调用 acquireBodySkeleton()。即使这帧没有看到人,也会得到空数组,页面回到“寻找人体”;如果取得人体,就继续读取关键点。

private async sampleBody(
  context: arViewController.ARViewContext
): Promise<void> {
  const session: arEngine.ARSession | undefined =
    context.session;
  if (!session) {
    return;
  }

  let frame: arEngine.ARFrame | undefined = undefined;
  try {
    frame = session.getFrame();
    const camera: arEngine.ARCamera = frame.getCamera();
    this.trackingState =
      camera.state === arEngine.ARTrackingState.TRACKING
        ? 'TRACKING' : 'BODY_ACTIVE';

    const bodies: Array<arEngine.ARBody> =
      frame.acquireBodySkeleton();
    this.sampleCount += 1;
    this.bodyCount = bodies.length;

    const snapshots: Array<BodySnapshot> =
      this.createSnapshots(bodies);
    if (snapshots.length === 0) {
      this.validCount = 0;
      this.averageConfidence = 0;
      this.currentTrackId = -1;
      this.leftRaised = false;
      this.rightRaised = false;
      this.holdFrames = 0;
      this.drawBodies([]);
      return;
    }

    snapshots.sort((first: BodySnapshot,
      second: BodySnapshot) =>
      second.averageConfidence - first.averageConfidence);
    const primary: BodySnapshot = snapshots[0];

    this.validCount = primary.validCount;
    this.averageConfidence = primary.averageConfidence;
    this.currentTrackId = primary.trackId;
    this.updateAction(primary.landmarks);
    this.drawBodies(snapshots);
    this.logBodyStats(primary);
  } catch (error) {
    const err: BusinessError = error as BusinessError;
    if (err.code === DATA_NOT_READY_CODE) {
      this.pendingCount += 1;
    } else {
      this.errorCount += 1;
      hilog.error(DOMAIN, TAG,
        'BODY_SAMPLE error=%{public}d %{public}s',
        err.code, err.message);
    }
  } finally {
    if (frame) {
      await frame.release();
    }
  }
}

首次看到完整人体之前,模型需要一些时间。本轮首次取得人体时,接口返回 20 个点,其中 11 个通过当前阈值:

BODY_FIRST_TRACK latencyMs=35514 bodies=1 trackId=969146005 landmarks=20 valid=11 confidence=0.719

继续调整距离后,有效点稳定增加到 15~16 个。

过滤无效点并计算平均置信度

关键点数组长度是 20,不等于 20 个点都可以立即使用。绘制和动作判断前同时检查:

  • isValidtrue
  • confidence 不低于 0.45;
  • X、Y 是有限数值;
  • 坐标位于预览区域附近。
private createSnapshots(
  bodies: Array<arEngine.ARBody>
): Array<BodySnapshot> {
  const snapshots: Array<BodySnapshot> = [];

  bodies.forEach((body: arEngine.ARBody) => {
    const landmarks: Array<arEngine.ARBodyLandmark2D> =
      body.getLandmarks2D();
    let validCount: number = 0;
    let confidenceSum: number = 0;

    landmarks.forEach(
      (landmark: arEngine.ARBodyLandmark2D) => {
        if (this.isUsableLandmark(landmark)) {
          validCount += 1;
          confidenceSum += landmark.confidence;
        }
      }
    );

    snapshots.push({
      trackId: body.trackId,
      timeStamp: body.timeStamp,
      landmarks: landmarks,
      validCount: validCount,
      averageConfidence: validCount > 0
        ? confidenceSum / validCount : 0
    });
  });
  return snapshots;
}

private isUsableLandmark(
  landmark: arEngine.ARBodyLandmark2D
): boolean {
  return landmark.isValid &&
    landmark.confidence >= MIN_CONFIDENCE &&
    Number.isFinite(landmark.x) &&
    Number.isFinite(landmark.y) &&
    landmark.x >= -0.05 && landmark.x <= 1.05 &&
    landmark.y >= -0.05 && landmark.y <= 1.05;
}

把归一化坐标画回真人画面

人体关键点的 X、Y 是相对于预览区域的归一化坐标。Canvas 和相机预览使用同一个区域后,位置换算只需要乘以宽高:

private previewPoint(
  landmark: arEngine.ARBodyLandmark2D,
  width: number,
  height: number
): number[] {
  const normalizedX: number = this.mirrorOverlay
    ? 1 - landmark.x : landmark.x;
  return [normalizedX * width, landmark.y * height];
}

绘制时先按人体结构连接耳朵、眼睛、鼻子、颈部、肩、肘、腕、脊柱、髋、膝和踝;一条线的任一端无效,这条线就不画。随后再绘制有效关节点,点的半径随置信度略微变化。

private drawSingleBody(
  landmarks: Array<arEngine.ARBodyLandmark2D>,
  width: number,
  height: number,
  bodyIndex: number
): void {
  const map = this.landmarkMap(landmarks);

  BODY_EDGES.forEach((edge: number[]) => {
    const start = map.get(
      edge[0] as arEngine.ARBodyLandmarkType);
    const end = map.get(
      edge[1] as arEngine.ARBodyLandmarkType);
    if (start === undefined || end === undefined) {
      return;
    }

    const startPoint = this.previewPoint(start, width, height);
    const endPoint = this.previewPoint(end, width, height);
    this.overlayContext.beginPath();
    this.overlayContext.moveTo(startPoint[0], startPoint[1]);
    this.overlayContext.lineTo(endPoint[0], endPoint[1]);
    this.overlayContext.strokeStyle =
      bodyIndex === 0
        ? this.edgeColor(edge[0], edge[1]) : '#FF9AD5';
    this.overlayContext.lineWidth = bodyIndex === 0 ? 4 : 2.6;
    this.overlayContext.stroke();
  });

  landmarks.forEach(
    (landmark: arEngine.ARBodyLandmark2D) => {
      if (!this.isUsableLandmark(landmark)) {
        return;
      }
      const point = this.previewPoint(
        landmark, width, height);
      const radius: number =
        3.8 + landmark.confidence * 2.4;
      this.overlayContext.beginPath();
      this.overlayContext.arc(
        point[0], point[1], radius, 0, Math.PI * 2);
      this.overlayContext.fillStyle =
        this.pointColor(landmark.type);
      this.overlayContext.fill();
    }
  );
}

前置相机在不同预览实现中可能呈现镜像。页面保留“坐标:原始 / 镜像”切换,只用于检查骨架是否和真人对齐。当前真机使用原始坐标时已经贴合,不需要额外翻转。

用手腕和肩膀判断举手

屏幕坐标从上向下增大,所以手腕在肩膀上方时,手腕的 Y 小于肩膀的 Y。左右两侧分别比较,不使用图片分类,也不通过按钮直接改变结果。

private updateAction(
  landmarks: Array<arEngine.ARBodyLandmark2D>
): void {
  const map = this.landmarkMap(landmarks);
  const leftWrist = map.get(
    arEngine.ARBodyLandmarkType.LEFT_WRIST);
  const leftShoulder = map.get(
    arEngine.ARBodyLandmarkType.LEFT_SHOULDER);
  const rightWrist = map.get(
    arEngine.ARBodyLandmarkType.RIGHT_WRIST);
  const rightShoulder = map.get(
    arEngine.ARBodyLandmarkType.RIGHT_SHOULDER);

  this.leftRaised = leftWrist !== undefined &&
    leftShoulder !== undefined &&
    leftWrist.y < leftShoulder.y;
  this.rightRaised = rightWrist !== undefined &&
    rightShoulder !== undefined &&
    rightWrist.y < rightShoulder.y;

  if (this.validCount < MIN_ACTION_LANDMARKS) {
    this.holdFrames = 0;
    return;
  }

  if (this.leftRaised && this.rightRaised) {
    this.holdFrames = Math.min(
      ACTION_HOLD_FRAMES, this.holdFrames + 1);

    if (this.holdFrames >= ACTION_HOLD_FRAMES &&
      this.challengeArmed) {
      this.completedCount += 1;
      this.challengeArmed = false;
      hilog.info(DOMAIN, TAG,
        'BODY_CHALLENGE_COMPLETE count=%{public}d ' +
        'trackId=%{public}d valid=%{public}d ' +
        'confidence=%{public}s sample=%{public}d',
        this.completedCount, this.currentTrackId,
        this.validCount,
        this.averageConfidence.toFixed(3),
        this.sampleCount);
    }
  } else {
    this.holdFrames = 0;
    this.challengeArmed = true;
  }
}

第一次完成动作时记录了 16 个有效点和 0.838 的平均置信度:

BODY_CHALLENGE_COMPLETE count=1 trackId=969146005 valid=16 confidence=0.838 sample=766

challengeArmed 用来避免双手一直举着时连续加分。双手放下后重新解锁,下一次举手才会再次计数。

真机操作过程

  1. 进入“实验 11:人体动作镜”,把手机竖屏固定;
  2. 后退到头、双手和脚都进入画面,等骨架贴到身体上;
  3. 自然站立,确认有效点不少于 12;
  4. 单独举起一只手,确认只点亮对应一侧;
  5. 换另一只手,检查另一侧状态;
  6. 两只手同时举起,保持约半秒,看到两侧都点亮且“完成”增加;
  7. 点击“锁定当前骨架证据”,冻结当前骨架和数字;
  8. 点击“继续人体跟踪”后可重复动作;
  9. 最后依次验证暂停、恢复和销毁退出。

本轮从自然站立到双手举起共完成 6 次,四张关键过程图已经分别保存,动作状态和画面相互对应。

实操中遇到的三个问题

1. 相机已经打开,但一直是 0 帧、人体 0

首版代码沿用了 WORLD 会话的判断:只有 ARCamera.state === TRACKING 才读取人体。BODY 会话并不依赖 SLAM 相机进入 TRACKING,结果是人体模型已经运行,应用却在读取前提前返回。

修订后,相机状态只作为观察值,不再拦截 acquireBodySkeleton()

const camera: arEngine.ARCamera = frame.getCamera();
this.trackingState =
  camera.state === arEngine.ARTrackingState.TRACKING
    ? 'TRACKING' : 'BODY_ACTIVE';

// BODY 会话直接读取当前帧的人体数组。
const bodies: Array<arEngine.ARBody> =
  frame.acquireBodySkeleton();

改完后首次取得人体,随后稳定得到 15~16 个有效点。

2. 已经找到人,但动作一直不成功

首次识别只有 11 个有效点,低于动作要求的 12 个。把手机固定、继续后退并让头、双手和脚完整入镜后,有效点增加到 16,动作判定正常。

光线太暗、手臂出画面或身体被家具挡住时,有效点也会下降。这里没有把阈值无限降低,而是先调整取景范围。

3. 顶部动作更新,底部统计卡仍显示初始值

第二轮真机中,骨架、顶部左右手状态和日志都在变化,但底部卡片一直显示 0 / --。原因是自定义 Builder 接收了调用时已经计算好的字符串,内部没有直接读取页面状态。

修订后 Builder 只接收标签,显示值在 Builder 内读取 @State

@Builder
private metricCard(label: string): void {
  Column({ space: 3 }) {
    Text(this.metricValue(label))
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
    Text(label)
      .fontSize(9)
  }
}

private metricValue(label: string): string {
  switch (label) {
    case '人体':
      return `${this.bodyCount}`;
    case '有效点':
      return this.validCount > 0
        ? `${this.validCount}/20` : '--';
    case '平均置信度':
      return this.validCount > 0
        ? this.averageConfidence.toFixed(2) : '--';
    case '跟踪 ID':
      return this.currentTrackId >= 0
        ? `${this.currentTrackId}` : '--';
    case '首个人体':
      return this.firstBodyLatency;
    case '举手保持':
      return `${this.holdFrames}/${ACTION_HOLD_FRAMES}`;
    default:
      return '--';
  }
}

暂停、恢复和退出时释放资源

证据锁定只冻结新数据,AR 会话仍在运行。暂停会真正停止会话;恢复时清空节流时间,让下一帧可以立即读取;退出页面则销毁 ARViewContext

private pauseARView(): void {
  if (!this.arContext || this.isPaused) {
    return;
  }
  this.arContext.pause();
  this.isPaused = true;
  hilog.info(DOMAIN, TAG,
    'BODY_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,
    'BODY_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();
  this.overlayContext.clearRect(
    0, 0,
    this.overlayContext.width,
    this.overlayContext.height);
}

真机暂停与恢复使用的是同一个采样序号,说明暂停期间没有继续增加采样:

BODY_SESSION_PAUSE sample=1443
BODY_SESSION_RESUME sample=1443 locked=false

每次 ARFrame 也在 finally 中执行 release(),异常分支不会跳过释放。

最终验证

本次完整验证得到以下结果:

  • BODY 能力门禁通过,前置人体会话初始化成功;
  • 真机返回 1 个 ARBody 和 20 个骨骼关键点;
  • 有效点稳定达到 15~16 个,骨架与真人头、肩、手臂和躯干位置一致;
  • 左手、右手分别举起时,只点亮对应一侧;
  • 双手同时举起并保持 3 次采样后,完成次数正常增加;
  • 本轮累计完成 6 次动作;
  • 暂停期间采样序号不变,恢复后继续更新;
  • 页面退出后销毁会话,当前帧每轮释放;
  • 静态回归、ArkTS 编译、HAP 打包和签名均通过。

人体跟踪的结果不是“画面里出现了一个人”,而是当前帧确实返回了可用关键点,并且这些点能和真人动作同步变化。本次自然站立、左右单手和双手举起四种状态都已在真机上对应验证。

参考资料

Logo

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

更多推荐