鸿蒙 BodyAR 实战:基于人体骨骼追踪的体感运动计数器开发全解
随着 HarmonyOS NEXT 的生态日趋成熟,华为 AR Engine 为开发者提供了强大的人体骨骼追踪能力。但在实际开发中,仅仅"看到骨架"远远不够——如何将骨骼数据转化为有意义的业务逻辑,才是从 Demo 到产品的关键一步。

一、前言
随着 HarmonyOS NEXT 的生态日趋成熟,华为 AR Engine 为开发者提供了强大的人体骨骼追踪能力。但在实际开发中,仅仅"看到骨架"远远不够——如何将骨骼数据转化为有意义的业务逻辑,才是从 Demo 到产品的关键一步。
本文将基于笔者在鸿蒙平台上的实战经验,详细讲解如何利用 AR Engine 的 Body Tracking 能力,实现深蹲和开合跳两种体感运动的自动计数。内容覆盖关节角度计算、状态机设计、帧数据处理管线以及完整的 ArkTS 代码实现。
读完本文你将掌握:
- 如何从 ARBody 中提取关节坐标并计算角度
- 如何用状态机检测重复动作并计数
- 如何将运动数据导出为 JSON 供后续分析
- 完整可运行的 ArkTS 代码
二、整体架构
运动检测系统建立在 AR Engine 的帧回调管线之上,数据流转如下:
ARFrame (30fps)
→ acquireBodySkeleton() // 获取检测到的人体列表
→ ARBody.getLandmarks2D() // 提取 20+ 骨骼关键点坐标
→ AngleCalculator // 计算关节角度
→ ActionCounter (状态机) // 判动作、计数
→ DataExporter // 数据收集、JSON 导出
模块职责
| 模块 | 职责 | 核心输出 |
|---|---|---|
BodyAREngine |
AR 会话生命周期、帧回调管理 | ARBody[] |
AngleCalculator |
纯数学计算:三点夹角 | 角度数值(°) |
ActionCounter |
有限状态机:动作识别与计数 | 深蹲数 / 开合跳数 |
DataExporter |
骨骼坐标录帧、JSON 导出 | JSON 文件 |
三、关节角度计算:从坐标到度数的数学转换
3.1 问题定义
AR Engine 返回的是二维屏幕坐标 (x, y)。要判断某个关节的弯曲程度,需要计算以该关节为顶点、相邻两骨骼连线为边所形成的夹角。
以膝关节角度为例:给定髋关节(H)、膝关节(K)、踝关节(A)三点的坐标,需要计算向量 KH 和向量 KA 之间的夹角。
3.2 数学推导
向量 BA = A - B,即从 B 指向 A
向量 BC = C - B,即从 B 指向 C
夹角 θ = arccos( (BA·BC) / (|BA| × |BC|) )
当 B 为铰链关节时,θ 即为该关节的弯曲角度。θ = 180° 表示完全伸直,θ ≈ 90° 表示直角弯曲。
3.3 完整实现
// AngleCalculator.ets
import { arEngine } from '@kit.AREngine';
import { LandmarkInfo } from './BodyTypes';
function dotProduct(ax: number, ay: number, bx: number, by: number): number {
return ax * bx + ay * by;
}
function vectorLength(x: number, y: number): number {
return Math.sqrt(x * x + y * y);
}
export function calcAngle(
a: LandmarkInfo,
b: LandmarkInfo,
c: LandmarkInfo
): number {
// 向量 BA: A点 → B点
const baX = a.x - b.x;
const baY = a.y - b.y;
// 向量 BC: C点 → B点
const bcX = c.x - b.x;
const bcY = c.y - b.y;
const lenBA = vectorLength(baX, baY);
const lenBC = vectorLength(bcX, bcY);
// 防止除零
if (lenBA < 0.001 || lenBC < 0.001) {
return 0;
}
const dot = dotProduct(baX, baY, bcX, bcY);
const cosAngle = dot / (lenBA * lenBC);
// 浮点误差保护:将余弦值限制在 [-1, 1]
const clampedCos = Math.max(-1, Math.min(1, cosAngle));
const radians = Math.acos(clampedCos);
// 弧度转角度
return radians * (180 / Math.PI);
}
3.4 批量计算关节角度
光有角度计算函数还不够,我们需要对每一帧检测到的每一个人体,同时计算多个关键关节的角度。以下函数接收骨骼点 Map,返回所有可用关节的角度标注信息:
export interface AngleInfo {
label: string; // 标注文字,如 "L肘"
angle: number; // 角度值
x: number; // 标注位置 X(像素坐标)
y: number; // 标注位置 Y
}
export function calcJointAngles(
lmMap: Map<arEngine.ARBodyLandmarkType, LandmarkInfo>
): AngleInfo[] {
const angles: AngleInfo[] = [];
// 左肘:左肩 → 左肘 → 左腕
const lSho = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_SHOULDER);
const lElb = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_ELBOW);
const lWri = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_WRIST);
if (lSho && lElb && lWri) {
angles.push({
label: 'L肘',
angle: Math.round(calcAngle(lSho, lElb, lWri)),
x: lElb.x, y: lElb.y - 20
});
}
// 左膝:左髋 → 左膝 → 左踝
const lHip = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_HIP);
const lKne = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_KNEE);
const lAnk = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_ANKLE);
if (lHip && lKne && lAnk) {
angles.push({
label: 'L膝',
angle: Math.round(calcAngle(lHip, lKne, lAnk)),
x: lKne.x, y: lKne.y - 20
});
}
// 对称处理右肘、右膝...
return angles;
}
采用 Map 而非数组索引查找骨骼点,是因为不同帧返回的骨骼点类型和数量可能不完全相同。Map 的 has/get 模式天然具备容错性——不存在的关节自动跳过。
四、深蹲计数:状态机设计
4.1 动作特征建模
深蹲的核心特征是膝关节角度的大幅变化:
- 站立时:膝关节接近伸直,角度 > 150°
- 下蹲到底:膝关节大幅弯曲,角度 < 100°
- 一次完整深蹲:站立 → 下蹲 → 站立
4.2 有限状态机
用一个简单的二状态机来追踪:
角度 < 100°
┌───────────────┐
│ STANDING │──────→ SQUATTING
└───────────────┘
↑
│ 角度 > 150° (计数 +1)
│
┌───────────────┐
│ SQUATTING │
└───────────────┘
状态转换条件中使用了左右膝角度的平均值,这样即使用户重心偏左或偏右,依然能准确判断。
4.3 代码实现
// ActionCounter.ets
const SQUAT_UP_ANGLE = 150; // 站立阈值
const SQUAT_DOWN_ANGLE = 100; // 下蹲阈值
export class ActionCounter {
private squatCount: number = 0;
private squatState: number = 0; // 0 = 站立, 1 = 下蹲
private detectSquat(
lmMap: Map<arEngine.ARBodyLandmarkType, LandmarkInfo>
): void {
const lHip = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_HIP);
const lKne = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_KNEE);
const lAnk = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_ANKLE);
const rHip = lmMap.get(arEngine.ARBodyLandmarkType.RIGHT_HIP);
const rKne = lmMap.get(arEngine.ARBodyLandmarkType.RIGHT_KNEE);
const rAnk = lmMap.get(arEngine.ARBodyLandmarkType.RIGHT_ANKLE);
// 左右膝关键点必须全部存在
if (!lHip || !lKne || !lAnk || !rHip || !rKne || !rAnk) {
return;
}
// 取左右膝角度平均值,增加鲁棒性
const lAngle = calcAngle(lHip, lKne, lAnk);
const rAngle = calcAngle(rHip, rKne, rAnk);
const avgAngle = (lAngle + rAngle) / 2;
// 状态转移
if (this.squatState === 0 && avgAngle < SQUAT_DOWN_ANGLE) {
this.squatState = 1; // 进入下蹲状态
} else if (this.squatState === 1 && avgAngle > SQUAT_UP_ANGLE) {
this.squatState = 0; // 恢复站立
this.squatCount++; // 计数 +1
}
}
}
4.4 阈值调参建议
| 参数 | 推荐值 | 说明 |
|---|---|---|
SQUAT_UP_ANGLE |
150° | 太低会导致"没站直就算"的误判 |
SQUAT_DOWN_ANGLE |
100° | 太高会连微微屈膝都计数 |
| 滞后区间 | 150 - 100 = 50° | 足够大避免临界值抖动 |
50° 的滞后区间(Hysteresis)是刻意设计的——在传感器数据存在帧间波动的情况下,滞后可以避免状态在阈值附近来回跳变。
五、开合跳计数:多条件联合判断
5.1 动作特征
开合跳涉及上下肢的协同动作:
- 手臂:从体侧上举过头(腕关节 Y 坐标 < 肩关节 Y 坐标)
- 双腿:从并拢到分开(踝间距 > 髋间距 × 1.5)
- 一次完整开合跳:收拢 → 张开 → 收拢
5.2 联合判断逻辑
单一条件容易误判(比如只抬手不跳也算?),因此采用 AND 联合:手臂和腿部必须同时满足条件才算"张开"状态。
private detectJumpingJack(
lmMap: Map<arEngine.ARBodyLandmarkType, LandmarkInfo>
): void {
// 获取所有必需的骨骼点
const lWri = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_WRIST);
const rWri = lmMap.get(arEngine.ARBodyLandmarkType.RIGHT_WRIST);
const lSho = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_SHOULDER);
const rSho = lmMap.get(arEngine.ARBodyLandmarkType.RIGHT_SHOULDER);
const lHip = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_HIP);
const rHip = lmMap.get(arEngine.ARBodyLandmarkType.RIGHT_HIP);
const lAnk = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_ANKLE);
const rAnk = lmMap.get(arEngine.ARBodyLandmarkType.RIGHT_ANKLE);
if (!lWri || !rWri || !lSho || !rSho ||
!lHip || !rHip || !lAnk || !rAnk) {
return;
}
// 双臂上举:双腕 Y < 肩平均 Y(屏幕坐标 Y 向下增长)
const avgShoY = (lSho.y + rSho.y) / 2;
const armsUp = lWri.y < avgShoY && rWri.y < avgShoY;
// 双腿分开:踝间距 > 1.5 倍髋间距
const hipSpan = Math.abs(lHip.x - rHip.x);
const ankleSpan = Math.abs(lAnk.x - rAnk.x);
const legsSpread = ankleSpan > hipSpan * 1.5;
// AND 联合判断:手臂和双腿必须同时满足
const isOpen = armsUp && legsSpread;
if (this.jackState === 0 && isOpen) {
this.jackState = 1; // 进入张开状态
} else if (this.jackState === 1 && !isOpen) {
this.jackState = 0; // 恢复收拢
this.jackCount++; // 计数 +1
}
}
六、帧处理管线:把所有模块串联起来
在 BodyARPage 的每帧回调中,我们将角度计算、动作检测和数据导出串联为统一管线:
processBodies(bodies: arEngine.ARBody[]): void {
const screenW = display.getDefaultDisplaySync().width;
const screenH = display.getDefaultDisplaySync().height;
// 1. 坐标映射:归一化 [0,1] → 屏幕像素
this.bodyInfos = bodies.map((body: arEngine.ARBody) => {
const landmarks = body.getLandmarks2D().map(lm => ({
x: lm.x * screenW,
y: lm.y * screenH,
type: lm.type
}));
return { trackId: body.trackId, landmarks: landmarks };
});
// 2. 对每个检测到的人体,执行数据分析管线
this.angleInfos = [];
for (const bodyInfo of this.bodyInfos) {
const lmMap = landmarksToMap(bodyInfo.landmarks);
// 2a. 计算关节角度
const angles = calcJointAngles(lmMap);
for (const a of angles) {
this.angleInfos.push(a);
}
// 2b. 动作检测(深蹲 + 开合跳)
this.actionCounter.processFrame(lmMap);
// 2c. 数据采集(供导出)
this.dataExporter.addFrame(lmMap, bodyInfo.trackId);
}
// 3. 更新 UI 状态
this.actionStates = this.actionCounter.getAllStates();
this.frameCount++;
}
七、运动数据 JSON 导出
运动分析离不开数据。我们在每帧将骨骼坐标存入 DataExporter,点击按钮即可导出完整的运动数据:
export class DataExporter {
private frames: MotionFrame[] = [];
addFrame(
lmMap: Map<arEngine.ARBodyLandmarkType, LandmarkInfo>,
bodyIndex: number
): void {
const landmarks: LandmarkRecord[] = [];
lmMap.forEach((lm, type) => {
landmarks.push({
type: type as number,
typeName: this.landmarkTypeName(type),
x: Math.round(lm.x * 100) / 100,
y: Math.round(lm.y * 100) / 100
});
});
this.frames.push({
timestamp: Date.now(),
bodyIndex: bodyIndex,
landmarks: landmarks
});
}
async saveToFile(): Promise<string> {
const json: string = /* 序列化 this.frames */;
const fileName = 'bodyar_data_' +
new Date().toISOString().replace(/[:.]/g, '-') + '.json';
const context = getContext();
const filePath = context.filesDir + '/' + fileName;
const file = fileIo.openSync(
filePath,
fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY
);
fileIo.writeSync(file.fd, json);
fileIo.closeSync(file);
return filePath;
}
}
导出的 JSON 结构:
{
"exportTime": "2026-05-22T12:00:00.000Z",
"totalFrames": 300,
"frames": [
{
"timestamp": 1716300000000,
"bodyIndex": 0,
"landmarks": [
{ "type": 0, "typeName": "NOSE", "x": 512.3, "y": 204.5 },
{ "type": 2, "typeName": "LEFT_SHOULDER", "x": 480.1, "y": 310.8 },
{ "type": 9, "typeName": "LEFT_KNEE", "x": 420.5, "y": 680.2 }
]
}
]
}
每帧记录 20+ 个骨骼点的像素坐标,可直接用 Python/Excel 做后续的轨迹分析和运动评估。
八、UI 渲染:让数据可见
运动计数器和角度标注通过声明式 ArkUI 直接渲染在 AR 相机画面上:
// 动作计数器(左上角)
@Builder
drawActionCounter() {
Column() {
ForEach(this.actionStates, (state: ActionState) => {
Text(state.type + ': ' + state.count + ' (' + state.phase + ')')
.fontSize(16)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
})
}
.position({ x: 16, y: 16 })
.hitTestBehavior(HitTestMode.None)
}
// 关节角度标注(各关节点旁)
@Builder
drawAngleLabels() {
ForEach(this.angleInfos, (ai: AngleInfo) => {
Text(ai.label + ' ' + ai.angle + '°')
.fontSize(11)
.fontColor('#00FF44')
.position({
x: this.uiContext.px2vp(ai.x - 15),
y: this.uiContext.px2vp(ai.y - 10)
})
.hitTestBehavior(HitTestMode.None)
})
}
所有叠加层均设置 HitTestMode.None,确保触摸事件穿透到 ARView。
九、调优与踩坑记录
9.1 摄像头方向
Body Tracking 在后置摄像头上的稳定性和精度远超前置。默认使用 cameraLensFacing: 0(后置),需要另一个真人站在镜头前。
9.2 检测距离
实测最佳检测距离为 1.5-3 米。太近(< 1 米)时全身无法入镜,部分关节会丢失;太远(> 5 米)时骨骼点置信度下降。
9.3 光线条件
暗光环境下检测率和跟踪稳定性显著下降。建议在 200 lux 以上的照度下使用。如果光线不足,关节角度计算可能因关键点缺失而中断。
9.4 帧率影响
AR Engine 的帧回调频率约 30fps。在高强度运动(如快速开合跳)场景下,帧间骨骼位移较大。30fps 对深蹲这类中速动作完全足够,但如果做波比跳等极速动作,可能需要提高采样率或改用插值算法。
9.5 ArkTS 编码约束
HarmonyOS 的 ArkTS 是 TypeScript 的严格子集,开发中需注意:
- 不支持解构声明:
const { x, y } = point会编译报错(arkts-no-destruct-decls),必须改为const X = point.x; const Y = point.y - 不支持 any/unknown:所有变量必须显式声明类型(
arkts-no-any-unknown) - @Builder 内只能写 UI 组件:
const声明、变量赋值、复杂运算都不允许。数据处理逻辑外移到processBodies等方法中,@Builder 只负责渲染 - Object 字面量必须对应已声明的 interface:不能用匿名对象(
arkts-no-untyped-obj-literals),必须显式声明类型并赋值 - 不支持索引访问类型:
Type['prop']写法不合法(arkts-no-aliases-by-index),直接声明独立 interface - 不支持 Object 字面量作为类型声明:
{ x: number; y: number }[]这种内联类型声明不合法,必须提取为 interface
这些约束初看繁琐,但实际上是 ArkTS 编译器为了生成高性能 ArkUI 渲染代码所做的必要限制。习惯了反而会让代码更加规范。
9.6 权限陷阱
AR Engine 初始化需要三个权限同时就绪:CAMERA、GYROSCOPE、ACCELEROMETER。其中 GYROSCOPE 和 ACCELEROMETER 属于"普通权限"(system_grant),在 module.json5 中声明后安装时自动授予,不需要代码中额外处理。但 CAMERA 是"受限权限"(user_grant),必须通过 abilityAccessCtrl.requestPermissionsFromUser() 弹窗请求用户手动授权。
常见错误码及排查方向:
| 错误码 | 原因 | 排查方向 |
|---|---|---|
| 201 | 权限不足 | 检查三个权限是否在 module.json5 中全部声明;确认 CAMERA 已弹窗授权 |
| 301 | AR Engine 未安装 | 在手机上打开 AppGallery 搜索"AR Engine"并安装 |
| 401 | 设备不支持骨骼追踪 | 确认芯片型号(需麒麟 9000+);尝试简单平面检测是否能工作 |
9.7 调试技巧
如果在真机上骨骼检测始终无反应,可以用以下方法逐步排查:
第一步:确认 AR 会话是否启动。在 onFrameUpdate 回调中打日志,如果从未触发,说明 AR 会话初始化失败,检查 error code。
第二步:确认帧回调频率。在状态栏显示帧计数器(frameCount),正常情况下约 30fps 递增。如果帧数不增长,检查是否忘记调用 frame.release() 导致底层缓冲区阻塞。
第三步:确认人体检测是否工作。acquireBodySkeleton() 返回空数组意味着当前帧未检测到人体。换个光线更好的环境、调整站位距离(1.5-3 米)、尝试后置摄像头。
第四步:确认骨骼点是否完整。打印 getLandmarks2D() 返回的数组长度和类型,正常情况下应有 20+ 个关键点。如果只有几个关键点,可能人体被部分遮挡或超出检测范围。
第五步:确认角度计算是否正确。在角度标注中观察数值变化,正常站立时膝关节角度应在 160-180° 之间。如果始终为 0° 或异常值,检查坐标转换逻辑。
十、产品化改造建议
10.1 阈值自适应校准
固定阈值(如深蹲 100°/150°)在不同用户身上表现差异很大。矮个子用户的下蹲幅度天然更大,而高个子用户的膝关节活动范围可能偏小。一个实用的改进方案是加入初始校准阶段:
用户开始运动前,先站着不动 2 秒记录"站立参考角度",再下蹲到底保持 2 秒记录"深蹲参考角度"。后续计数以这两个个性化阈值为准,而非全局固定值。这样不同身高、体型的用户都能获得准确的计数体验。
10.2 动作质量评估
仅计数还不够,动作质量同样关键。可以基于骨骼数据衍生出以下评估维度:
- 对称性评分:对比左右膝关节角度的差异,差异 > 10° 说明身体倾斜
- 深度评分:深蹲时膝关节角度越接近 90°,说明下蹲越到位
- 节奏稳定性:计算连续两次深蹲的时间间隔方差,评估运动节奏
每个维度给出 0-100 的分数,综合计算动作质量分,帮助用户纠正不良运动姿势。
10.3 防作弊策略
运动计数应用面临的最大产品风险是用户作弊。常见的作弊手段包括:
- 半程动作:蹲到一半就起立,利用阈值区间绕过计数
- 快速抖动:在阈值附近快速抖动身体,触发多次计数
针对半程动作,可以引入角度积分而非简单阈值——记录膝关节在整个运动周期中的最小角度,只对"真正达到目标深度"的动作计数。针对快速抖动,可以设置最小状态持续时间(如 200ms),状态转换必须维持该时长才生效。
10.4 语音交互集成
在运动过程中,用户不可能一直盯着屏幕。可以利用鸿蒙的 @kit.IntentsKit 实现语音反馈:
- 每完成 10 个深蹲,语音播报"已完成 10 个,加油!"
- 深蹲角度不到位时提示"请再蹲低一些"
- 开合跳节奏不均时提示"请保持节奏"
语音交互让用户专注于运动本身,体验更自然。
十一、性能优化实战
11.1 帧数据采样率控制
AR Engine 以约 30fps 的频率回调 onFrameUpdate。对于 JSON 导出功能,每帧都写入内存会导致数据量快速膨胀。以 30fps 为例,1 分钟就产生 1800 帧数据,每帧约 20 个骨骼点,总计约 72KB。10 分钟就是 7.2MB。
优化方案:降采样到 10fps(每 3 帧采样 1 帧),数据量削减 67%,但对运动轨迹分析几乎没有影响。深蹲这类动作的频率远低于 10Hz,10fps 的采样率完全满足奈奎斯特采样定理的要求。
11.2 内存管理
AR Engine 每帧返回的 ARFrame 对象必须调用 release() 释放,否则帧数据会堆积在底层缓冲区中。在实际测试中,如果不释放帧数据,30 秒内应用就会因为底层缓冲区耗尽而崩溃。务必在 onFrameUpdate 回调的最后调用 await frame.release()。
另外,页面退出时调用 viewContext.destroy() 释放相机和 AR 引擎资源。ARView 组件在销毁前需要先置空引用,避免引擎已释放而 UI 组件还持有旧引用的双重释放问题。
11.3 渲染管线优化
骨骼叠加层使用 ArkUI 的 Shape + Line + Circle 方案,而非传统的 Canvas 命令式绘制。这个选择的优劣对比如下:
- 优点:与 ArkUI 声明式范式一致,组件树自动 diff 重渲染,代码简洁
- 缺点:单帧生成约 14 条 Line + 20 个 Circle = 34 个组件,在双人模式下加倍
实测中 30fps 下 34 个 Shape 子组件的渲染开销极小(约 0.5ms),瓶颈不在 UI 而在 AR Engine 的 NPU 推理。因此 Shape 方案在当前场景下是最优解。如果未来扩展到同时追踪多人并渲染更多视觉元素,可以考虑切换到 XComponent + Canvas 方案。
十二、总结与展望
本文从头实现了一套基于鸿蒙 BodyAR 的体感运动计数系统,核心模块包括:
- 关节角度计算器:将 AR 骨骼坐标转化为角度数值
- 动作状态机:用滞后双阈值识别深蹲和开合跳
- 数据录制与导出:完整记录每帧骨骼数据并导出 JSON
- 实时 UI 标注:角度数值和计数叠加在 AR 相机画面上
这套架构具有良好的可扩展性——添加新动作类型只需实现新的 detectXxx() 方法并扩展状态输出即可。
更多推荐


所有评论(0)