BodyAR 骨骼数据解析:从 ARBody 到 23 个关键点的完整数据链路

一、前言

AR 会话启动后,每帧约 30 次地通过 onFrameUpdate 回调向应用层输送数据。但原始帧数据如何变成可用的骨骼坐标?归一化坐标如何映射到屏幕像素?每帧数据有哪些校验必要?

本文将深度解析从 ARFrame 到最终可渲染坐标的完整数据链路------覆盖每个 API 的数据格式、坐标系统、常见异常场景处理和性能优化策略。

二、数据流转全景

一条完整的帧数据处理链路:

复制代码
ARFrame (每帧原始快照)
  ↓ acquireBodySkeleton()
ARBody[] (当前帧检测到的所有人体,通常 0-2 个)
  ↓ getLandmarks2D()
ARBodyLandmark2D[] (每个体的 20+ 个骨骼关键点)
  ↓ 坐标转换 (归一化 [0,1] → 屏幕像素)
LandmarkInfo[] (像素坐标 + 类型标记)
  ↓ 数据校验 (合法性 / 完整性 / 连续性)
有效数据 → UI 渲染 / 动作分析 / 数据导出

三、第一层:ARFrame

3.1 获取帧

typescript 复制代码
async onFrameUpdate(ctx: arViewController.ARViewContext,
                    sysBootTs: number): Promise<void> {
  if (!ctx.session) {
    return;  // 会话未初始化或已销毁
  }

  const arSession: arEngine.ARSession = ctx.session;
  const frame: arEngine.ARFrame = arSession.getFrame();

  // ... 提取数据 ...

  await frame.release();  // ★ 关键:必须释放
}

3.2 frame.release() 的重要性

getFrame() 从底层缓冲区中获取帧快照的引用。如果不调用 release(),该帧的缓冲区永远不会被归还给底层驱动。在 30fps 下,30 秒就会积累 900 帧未释放的缓冲区,直接导致相机 HAL 层缓冲耗尽并触发应用崩溃。

黄金法则getFrame()release() 必须成对出现,且 release() 必须在同一个 onFrameUpdate 回调结束前调用。

3.3 sysBootTs 参数

onFrameUpdate 的第二个参数 sysBootTs 是系统启动以来的纳秒级时间戳。可以用来计算真实帧率:

typescript 复制代码
private lastTs: number = 0;

onFrameUpdate(ctx, sysBootTs): void {
  if (this.lastTs > 0) {
    const deltaMs = (sysBootTs - this.lastTs) / 1_000_000;
    // deltaMs ≈ 33ms → 30fps
  }
  this.lastTs = sysBootTs;
}

四、第二层:ARBody

4.1 获取人体列表

typescript 复制代码
const bodies: arEngine.ARBody[] = frame.acquireBodySkeleton();

返回值可能的情况:

返回值 含义 后续处理
[] 空数组 当前帧未检测到任何人 清空渲染,显示引导文字
[body1] 单元素 检测到 1 人 处理单人骨骼
[body1, body2] 双元素 检测到 2 人 遍历处理(需要 maxDetectedBodyNum: 2

4.2 ARBody 结构

typescript 复制代码
interface ARBody {
  trackId: number;  // 追踪唯一标识,同一人在连续帧中 ID 不变
  // 核心方法
  getLandmarks2D(): ARBodyLandmark2D[];
  // 其他属性(C API 可访问,ArkTS 不一定暴露)
  // skeletonPoint3D: number[];  // 3D 骨骼坐标
  // skeletonConfidence: number[];  // 置信度
}

trackId 是跨帧追踪同一人的关键。当画面中出现多人时,通过 trackId 区分每个人的骨骼数据。这个人离开画面再回来时,trackId 会改变。

五、第三层:ARBodyLandmark2D

5.1 单个骨骼点的数据结构

typescript 复制代码
interface ARBodyLandmark2D {
  x: number;   // 归一化 X 坐标 (0.0 ~ 1.0)
  y: number;   // 归一化 Y 坐标 (0.0 ~ 1.0)
  type: ARBodyLandmarkType;  // 骨骼点类型枚举
}

坐标系统 :原点在画面左上角,X 向右增长,Y 向下增长。这是显示坐标系,与数学中常见的"Y 向上"相反。

5.2 骨骼点类型枚举(完整清单)

ARBodyLandmarkType 枚举包含 20+ 个骨骼关键点:

typescript 复制代码
enum ARBodyLandmarkType {
  NOSE = 0,           // 鼻子
  NECK = 1,           // 颈部
  LEFT_SHOULDER = 2,  // 左肩
  LEFT_ELBOW = 3,     // 左肘
  LEFT_WRIST = 4,     // 左腕
  RIGHT_SHOULDER = 5, // 右肩
  RIGHT_ELBOW = 6,    // 右肘
  RIGHT_WRIST = 7,    // 右腕
  LEFT_HIP = 8,       // 左髋
  LEFT_KNEE = 9,      // 左膝
  LEFT_ANKLE = 10,    // 左踝
  RIGHT_HIP = 11,     // 右髋
  RIGHT_KNEE = 12,    // 右膝
  RIGHT_ANKLE = 13,   // 右踝
  LEFT_EYE = 14,      // 左眼
  RIGHT_EYE = 15,     // 右眼
  LEFT_EAR = 16,      // 左耳
  RIGHT_EAR = 17,     // 右耳
  HEAD_TOP = 18,      // 头顶
  NECK_BASE = 19,     // 颈根
}

注意:不同帧返回的骨骼点类型和数量不保证一致。尤其在以下场景中:

  • 人体被部分遮挡 → 被遮挡的关节可能不返回
  • 身体部分超出画面 → 画面外的关节不返回
  • 侧面朝向摄像头 → 远侧的关节可能无法定位

这正是为什么推荐用 Map 而非固定索引来访问骨骼点数据。

5.3 将数组转为 Map

typescript 复制代码
function landmarksToMap(
  landmarks: arEngine.ARBodyLandmark2D[]
): Map<arEngine.ARBodyLandmarkType, arEngine.ARBodyLandmark2D> {
  const map = new Map<arEngine.ARBodyLandmarkType, arEngine.ARBodyLandmark2D>();
  for (const lm of landmarks) {
    map.set(lm.type, lm);
  }
  return map;
}

使用 Map 后,可以安全地按类型访问:

typescript 复制代码
const lmMap = landmarksToMap(landmarks);
const nose = lmMap.get(arEngine.ARBodyLandmarkType.NOSE);
if (nose) {
  // 安全使用 nose.x, nose.y
}

六、坐标转换:归一化 → 像素 → VP

6.1 归一化 [0,1] → 屏幕像素

typescript 复制代码
const screenW: number = display.getDefaultDisplaySync().width;
const screenH: number = display.getDefaultDisplaySync().height;

// 归一化 → 像素
const pixelX = landmark.x * screenW;
const pixelY = landmark.y * screenH;

display.getDefaultDisplaySync() 是同步 API,返回屏幕的逻辑像素尺寸。在每帧循环中调用是安全的,因为屏幕尺寸在会话期间不会改变。

6.2 像素 → VP(适配单位)

ArkUI 使用 vp(virtual pixel)作为布局单位来适配不同密度的屏幕。将像素坐标转为 vp:

typescript 复制代码
const uiContext: UIContext = this.getUIContext();
const vpX = uiContext.px2vp(pixelX);
const vpY = uiContext.px2vp(pixelY);

px2vp() 的转换因子取决于设备的屏幕密度(DPI),由系统自动处理。

6.3 完整转换流水线

typescript 复制代码
interface LandmarkInfo {
  x: number;    // 像素 X 坐标(还没转 vp,供数据导出使用)
  y: number;    // 像素 Y 坐标
  type: arEngine.ARBodyLandmarkType;
}

function extractLandmarks(
  body: arEngine.ARBody,
  screenW: number,
  screenH: number
): LandmarkInfo[] {
  return body.getLandmarks2D().map(lm => ({
    x: lm.x * screenW,
    y: lm.y * screenH,
    type: lm.type
  }));
}

渲染时再用 px2vp 转为 vp:

typescript 复制代码
.position({
  x: this.uiContext.px2vp(lm.x),
  y: this.uiContext.px2vp(lm.y)
})

七、数据校验与异常处理

7.1 空帧检测

typescript 复制代码
if (bodies.length === 0) {
  // 当前帧无人 → 清空骨骼渲染
  this.bodyInfos = [];
  this.statusText = '未检测到人体';
  return;
}

7.2 坐标合法性检查

在极少数情况下(如光线突变、快速移动导致追踪丢失),返回的坐标可能超出 [0,1] 范围:

typescript 复制代码
if (lm.x < -0.1 || lm.x > 1.1 || lm.y < -0.1 || lm.y > 1.1) {
  // 异常的坐标值,跳过该点
  // 阈值设为 0.1 容差,因为轻微超界可能是正常的数值精度问题
  continue;
}

7.3 关键骨骼点完整性检查

某些业务场景依赖特定关节的数据。在继续处理之前应检查这些关键点是否都存在:

typescript 复制代码
const essentialTypes = [
  arEngine.ARBodyLandmarkType.NOSE,
  arEngine.ARBodyLandmarkType.LEFT_SHOULDER,
  arEngine.ARBodyLandmarkType.RIGHT_SHOULDER,
  arEngine.ARBodyLandmarkType.LEFT_HIP,
  arEngine.ARBodyLandmarkType.RIGHT_HIP
];

const lmMap = landmarksToMap(landmarks);
const isComplete = essentialTypes.every(t => lmMap.has(t));

if (!isComplete) {
  // 核心骨骼点缺失,可能是遮挡严重或半身入镜
  // 对于需要完整骨架的场景(如动作分析),此时应降低置信度或跳过此帧
}

7.4 帧间连续性校验

连续帧同一关节的坐标不应突变。可以用来检测追踪丢失和重新捕获:

typescript 复制代码
private prevNoseX: number = -1;
private prevNoseY: number = -1;

function checkContinuity(nose: LandmarkInfo): boolean {
  if (this.prevNoseX < 0) {
    this.prevNoseX = nose.x;
    this.prevNoseY = nose.y;
    return true;
  }

  const dx = Math.abs(nose.x - this.prevNoseX);
  const dy = Math.abs(nose.y - this.prevNoseY);

  // 正常帧间位移不超过 30px(30fps 下)
  if (dx > 50 || dy > 50) {
    console.warn('可能的跟踪丢失:帧间位移过大');
    // 可选:使用上一帧坐标做平滑
  }

  this.prevNoseX = nose.x;
  this.prevNoseY = nose.y;
  return true;
}

八、性能优化策略

8.1 避免每帧创建大量临时对象

每帧 body.getLandmarks2D() 已经返回新数组。在 map() 中再创建新的 LandmarkInfo 对象是不可避免的,但可以通过对象池复用减少 GC 压力:

typescript 复制代码
// 简单的对象池模式(20+ 个骨骼点 × 2 人 = 约 50 个对象/帧)
class ObjectPool<T> {
  private pool: T[] = [];
  acquire(): T { /* 从池中获取或创建 */ }
  release(obj: T): void { /* 归还池中 */ }
}

对于大多数 BodyAR 应用,每帧创建 20-40 个对象的内存压力在 ArkTS 垃圾回收器面前是可以忽略的。只有在需要处理大量历史帧数据时(如长时间录制),才需要考虑池化优化。

8.2 数据降采样

JSON 导出时建议降采样到 10fps,减少数据量但对分析质量无影响。深蹲的频率约 0.5-1Hz,10fps 采样率满足奈奎斯特采样定理。

typescript 复制代码
private frameSkip = 0;
private sampleRate = 3;  // 每 3 帧采样 1 帧

onFrameUpdate(): void {
  this.frameSkip = (this.frameSkip + 1) % this.sampleRate;
  if (this.frameSkip !== 0) return;

  // 仅在此之后的数据处理逻辑
  this.dataExporter.addFrame(lmMap, bodyIndex);
}

8.3 状态变化检测

不要每帧都更新所有 UI 状态------只在数据真正变化时才触发重渲染:

typescript 复制代码
// 坏做法:每帧都创建新的 ActionState 数组
this.actionStates = [new ActionState(...)];

// 好做法:仅在计数变化时更新
if (newCount !== this.lastCount) {
  this.actionStates = [new ActionState(...)];
  this.lastCount = newCount;
}

九、数据导出格式设计

9.1 导出数据结构

typescript 复制代码
interface LandmarkRecord {
  type: number;      // ARBodyLandmarkType 数值
  typeName: string;  // 可读名称 "NOSE", "LEFT_KNEE" ...
  x: number;         // 像素 X,保留 2 位小数
  y: number;         // 像素 Y,保留 2 位小数
}

interface MotionFrame {
  timestamp: number;    // Date.now()
  bodyIndex: number;    // 多人场景下的人体索引
  landmarks: LandmarkRecord[];
}

9.2 完整导出 JSON 结构

json 复制代码
{
  "exportTime": "2026-05-22T10:30:00.000Z",
  "totalFrames": 600,
  "screenWidth": 393,
  "screenHeight": 852,
  "frames": [
    {
      "timestamp": 1716300000000,
      "bodyIndex": 0,
      "landmarks": [
        {"type": 0, "typeName": "NOSE", "x": 200.15, "y": 150.32},
        {"type": 9, "typeName": "LEFT_KNEE", "x": 175.80, "y": 450.10}
      ]
    }
  ]
}

导出时附带 screenWidthscreenHeight,方便后续将像素坐标反算回归一化坐标。

十、小结

本文深入解析了从 ARFrame 到最终骨骼坐标的完整数据链路:

  1. ARFrame:每帧快照,必须 release
  2. ARBody[]:检测到的人体列表,trackId 关联跨帧身份
  3. ARBodyLandmark2D[]:归一化坐标 + 类型标记,用 Map 安全访问
  4. 坐标转换:归一化 [0,1] → 像素(× screenSize)→ vp(px2vp)
  5. 数据校验:空帧检测、坐标合法性、关键点完整性、帧间连续性
  6. 性能优化:降采样、状态变化检测、对象池

数据到手之后,最后一步是将其可视化------用 ArkUI 声明式组件在 AR 相机画面上叠加优雅的骨骼骨架。

相关推荐
寺中人1 小时前
华为韬(τ)定律:后摩尔时代,中国定义芯片新规则
人工智能·物联网·华为·韬定律
●VON2 小时前
BodyAR 会话管理深度剖析:ARConfig 参数全解与生命周期最佳实践
华为·harmonyos·鸿蒙·新特性
●VON2 小时前
BodyAR 骨骼可视化渲染:Shape + Line + Circle 打造优雅的 AR 骨架叠加层
华为·ar·harmonyos·鸿蒙·新特性
anyup2 小时前
【最全鸿蒙】uni-app 转鸿蒙:从打包失败到商店上架成功全过程
前端·uni-app·harmonyos
●VON3 小时前
鸿蒙NEXT新特性:HdsTabs悬浮页签栏barFloatingStyle完全入门指南
华为·harmonyos·鸿蒙·新特性
互联网散修3 小时前
鸿蒙自定义安全键盘开发实战:Canvas 精密布局与安全交互
harmonyos·自定义键盘
nashane3 小时前
HarmonyOS 6学习:麦克风“抢戏”打断音频?AudioSession焦点避坑指南
学习·音视频·harmonyos
大雷神3 小时前
第05篇|窗口与安全区:AppStorage 如何保存宽高、状态栏和暗色模式
harmonyos
GLAB-Mary4 小时前
苏州华为培训哪家好?
华为·hcip