
一、前言
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}
]
}
]
}
导出时附带 screenWidth 和 screenHeight,方便后续将像素坐标反算回归一化坐标。
十、小结
本文深入解析了从 ARFrame 到最终骨骼坐标的完整数据链路:
- ARFrame:每帧快照,必须 release
- ARBody[]:检测到的人体列表,trackId 关联跨帧身份
- ARBodyLandmark2D[]:归一化坐标 + 类型标记,用 Map 安全访问
- 坐标转换:归一化 [0,1] → 像素(× screenSize)→ vp(px2vp)
- 数据校验:空帧检测、坐标合法性、关键点完整性、帧间连续性
- 性能优化:降采样、状态变化检测、对象池
数据到手之后,最后一步是将其可视化------用 ArkUI 声明式组件在 AR 相机画面上叠加优雅的骨骼骨架。