在这里插入图片描述

一、前言

随着 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 初始化需要三个权限同时就绪:CAMERAGYROSCOPEACCELEROMETER。其中 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 的体感运动计数系统,核心模块包括:

  1. 关节角度计算器:将 AR 骨骼坐标转化为角度数值
  2. 动作状态机:用滞后双阈值识别深蹲和开合跳
  3. 数据录制与导出:完整记录每帧骨骼数据并导出 JSON
  4. 实时 UI 标注:角度数值和计数叠加在 AR 相机画面上

这套架构具有良好的可扩展性——添加新动作类型只需实现新的 detectXxx() 方法并扩展状态输出即可。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐