HarmonyOS AR Engine人体骨骼跟踪实操——把20个关键点画到真人身上

这次做的是一个可以直接看到结果的人体动作实验:前置相机找到真人后,把 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 个点都可以立即使用。绘制和动作判断前同时检查:
isValid为true;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 用来避免双手一直举着时连续加分。双手放下后重新解锁,下一次举手才会再次计数。
真机操作过程
- 进入“实验 11:人体动作镜”,把手机竖屏固定;
- 后退到头、双手和脚都进入画面,等骨架贴到身体上;
- 自然站立,确认有效点不少于 12;
- 单独举起一只手,确认只点亮对应一侧;
- 换另一只手,检查另一侧状态;
- 两只手同时举起,保持约半秒,看到两侧都点亮且“完成”增加;
- 点击“锁定当前骨架证据”,冻结当前骨架和数字;
- 点击“继续人体跟踪”后可重复动作;
- 最后依次验证暂停、恢复和销毁退出。
本轮从自然站立到双手举起共完成 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 打包和签名均通过。
人体跟踪的结果不是“画面里出现了一个人”,而是当前帧确实返回了可用关键点,并且这些点能和真人动作同步变化。本次自然站立、左右单手和双手举起四种状态都已在真机上对应验证。
参考资料
更多推荐


所有评论(0)