
一、前言
数据到手之后,怎么把它画出来?这看似是一个简单的 UI 问题,但在 AR 场景下,叠加层需要覆盖在实时相机预览之上、每秒刷新 30 次、与相机画面精确对齐------普通的 UI 方案难以胜任。
本文将系统讲解基于 ArkUI 声明式组件的骨骼可视化全套方案,包括渲染架构设计、骨骼连线配置、关节高亮策略、头部可视化以及坐标对齐调试方法。
二、技术选型:Shape vs Canvas
ArkUI 提供了两种路径来实现自定义渲染:
| 维度 | Shape 组件 | Canvas RenderContext |
|---|---|---|
| 编程范式 | 声明式(@Builder + ForEach) | 命令式(手动 draw) |
| 类型安全 | 编译期检查 | 运行时错误 |
| 组件复用 | 天然支持,ForEach 自动 diff | 需手动管理重绘时机 |
| 代码量 | 约 150 行 | 约 300+ 行 |
| 渲染性能(34 个图形) | ~0.5ms/帧 | ~0.3ms/帧 |
| 学习成本 | 低(常规 ArkUI 即可) | 中(需理解 Canvas API) |
结论:在 30fps、单帧内 34 个图形元素(14 条线 + 20 个圆)的场景下,Shape 方案的渲染耗时约 0.5ms,完全在帧预算(33ms)的 2% 以内。差距微乎其微,Shape 方案的声明式优势和可维护性是更重要的考量。
推荐:优先使用 Shape 方案。只有在需要大量粒子效果(500+ 个图形)、自定义混合模式等 Canvas 独有的高级特性时,才考虑切换。
三、渲染架构设计
3.1 叠加层次结构
AR 相机画面 + 骨骼叠加层的完整 UI 结构:
Stack (全屏)
├── ARView({ context }) // 第 0 层:AR 相机预览
│ .width('100%').height('100%')
│
├── ForEach(HeadCircles) // 第 1 层:头部半透明圆形
│ Circle.fillOpacity(0.15).stroke('#FFD700')
│
├── Shape // 第 2 层:骨骼连线 + 关节圆点
│ ├── ForEach(BoneConnections) → Line(.strokeWidth(3))
│ └── ForEach(Landmarks) → Circle(.fill(Color.White))
│
├── ForEach(AngleLabels) // 第 3 层:关节角度文字
│ Text('L肘 150°').fill('#00FF44')
│
└── ActionCounter // 第 4 层:动作计数器
Text('深蹲: 5').position({x:16, y:16})
3.2 关键设计原则
- hitTestBehavior: None:所有叠加层必须设置,确保触摸事件穿透到 ARView
- 条件渲染 :只在
isTracking && bodyInfos.length > 0时才渲染叠加层 - @Builder 分离 :不同视觉元素独立成
@Builder方法,方便迭代和独立调试
四、骨骼连线系统
4.1 连接关系定义
骨骼连线是预定义的------连接哪些关节、用什么颜色,这些都是固定的配置数据。我们将它们定义为纯数据:
typescript
// BodyTypes.ets
export interface BoneConnection {
start: arEngine.ARBodyLandmarkType;
end: arEngine.ARBodyLandmarkType;
color: string;
}
export function getBoneConnections(): BoneConnection[] {
return [
// 头部区域 ------ 金色
{ start: ARBodyLandmarkType.NOSE, end: ARBodyLandmarkType.LEFT_EYE,
color: '#FFD700' },
{ start: ARBodyLandmarkType.NOSE, end: ARBodyLandmarkType.RIGHT_EYE,
color: '#FFD700' },
{ start: ARBodyLandmarkType.LEFT_EYE, end: ARBodyLandmarkType.LEFT_EAR,
color: '#FFD700' },
{ start: ARBodyLandmarkType.RIGHT_EYE, end: ARBodyLandmarkType.RIGHT_EAR,
color: '#FFD700' },
// 躯干 ------ 橙色
{ start: ARBodyLandmarkType.NOSE,
end: ARBodyLandmarkType.LEFT_SHOULDER, color: '#FF8C00' },
{ start: ARBodyLandmarkType.NOSE,
end: ARBodyLandmarkType.RIGHT_SHOULDER, color: '#FF8C00' },
{ start: ARBodyLandmarkType.LEFT_SHOULDER,
end: ARBodyLandmarkType.RIGHT_SHOULDER, color: '#FF8C00' },
{ start: ARBodyLandmarkType.RIGHT_SHOULDER,
end: ARBodyLandmarkType.RIGHT_HIP, color: '#FF8C00' },
{ start: ARBodyLandmarkType.RIGHT_HIP,
end: ARBodyLandmarkType.LEFT_HIP, color: '#FF8C00' },
{ start: ARBodyLandmarkType.LEFT_HIP,
end: ARBodyLandmarkType.LEFT_SHOULDER, color: '#FF8C00' },
// 左侧肢体 ------ 绿色
{ start: ARBodyLandmarkType.LEFT_SHOULDER,
end: ARBodyLandmarkType.LEFT_ELBOW, color: '#00FF44' },
{ start: ARBodyLandmarkType.LEFT_ELBOW,
end: ARBodyLandmarkType.LEFT_WRIST, color: '#00FF44' },
{ start: ARBodyLandmarkType.LEFT_HIP,
end: ARBodyLandmarkType.LEFT_KNEE, color: '#00FF44' },
{ start: ARBodyLandmarkType.LEFT_KNEE,
end: ARBodyLandmarkType.LEFT_ANKLE, color: '#00FF44' },
// 右侧肢体 ------ 蓝色
{ start: ARBodyLandmarkType.RIGHT_SHOULDER,
end: ARBodyLandmarkType.RIGHT_ELBOW, color: '#4488FF' },
{ start: ARBodyLandmarkType.RIGHT_ELBOW,
end: ARBodyLandmarkType.RIGHT_WRIST, color: '#4488FF' },
{ start: ARBodyLandmarkType.RIGHT_HIP,
end: ARBodyLandmarkType.RIGHT_KNEE, color: '#4488FF' },
{ start: ARBodyLandmarkType.RIGHT_KNEE,
end: ARBodyLandmarkType.RIGHT_ANKLE, color: '#4488FF' },
];
}
4.2 颜色分区的设计意图
| 颜色 | 区域 | 设计意图 |
|---|---|---|
金色 #FFD700 |
头部(鼻-眼-耳) | 最显眼,吸引注意力 |
橙色 #FF8C00 |
躯干(颈-肩-髋) | 暖色调,身体核心 |
绿色 #00FF44 |
左侧肢体 | 与右侧区分,便于判断肢体 |
蓝色 #4488FF |
右侧肢体 | 与左侧形成冷暖对比 |
颜色选择不仅是为了美观,更重要的是让用户能在运动中(画面快速变化)一眼区分躯干、左臂和右臂。冷暖色对比是认知心理学中经证实的快速区分策略。
4.3 骨骼连线渲染
使用 Shape 容器 + ForEach 遍历连接配置:
typescript
@Builder
drawBodyOverlay(): void {
Shape() {
ForEach(this.bodyInfos, (bodyInfo: BodyInfo) => {
// 将骨骼点数组转为 Map(在每个 ForEach 项内转换)
const lmMap = landmarksToMap(bodyInfo.landmarks);
// 画骨骼连线
ForEach(this.boneConnections, (conn: BoneConnection) => {
this.drawBoneLine(lmMap, conn.start, conn.end, conn.color);
});
// 画关节圆点
ForEach(bodyInfo.landmarks, (lm: LandmarkInfo) => {
this.drawJoint(lm);
});
})
}
.width('100%').height('100%')
.hitTestBehavior(HitTestMode.None)
}
@Builder
drawBoneLine(
lmMap: Map<arEngine.ARBodyLandmarkType, LandmarkInfo>,
start: arEngine.ARBodyLandmarkType,
end: arEngine.ARBodyLandmarkType,
color: string
): void {
// 只有当两个端点都存在时才画线
if (lmMap.has(start) && lmMap.has(end)) {
const s = lmMap.get(start)!;
const e = lmMap.get(end)!;
Line()
.startPoint([
this.uiContext.px2vp(s.x),
this.uiContext.px2vp(s.y)
])
.endPoint([
this.uiContext.px2vp(e.x),
this.uiContext.px2vp(e.y)
])
.stroke(color)
.strokeWidth(3)
}
}
每条线独立检查两个端点是否存在。如果某个关节在特定帧缺失(被遮挡),对应的连线自动跳过------渲染层不需要额外的错误处理逻辑。
五、关节圆点系统
5.1 基础关节
所有被检测到的骨骼点都用一个 8×8 的白色实心圆标记:
typescript
@Builder
drawJoint(lm: LandmarkInfo): void {
Circle({ width: 8, height: 8 })
.position({
x: this.uiContext.px2vp(lm.x),
y: this.uiContext.px2vp(lm.y)
})
.fill(Color.White)
}
5.2 关键节点高亮
面部关键点用更大尺寸的金色圆标记,与身体关节形成视觉层级:
typescript
if (lm.type === arEngine.ARBodyLandmarkType.NOSE ||
lm.type === arEngine.ARBodyLandmarkType.LEFT_EYE ||
lm.type === arEngine.ARBodyLandmarkType.RIGHT_EYE) {
Circle({ width: 12, height: 12 }) // 放大 50%
.fill('#FFD700') // 金色
} else {
Circle({ width: 8, height: 8 })
.fill(Color.White) // 白色
}
视觉层级的设计原则:头部 > 主要关节(肩/肘/膝)> 末端(腕/踝)。用户最先看到面部区域,从而快速定位人体在画面中的位置。
六、头部可视化
6.1 为什么不直接用骨骼点画头
头部在骨骼模型中没有"头轮廓"这个骨骼点------只有鼻子、眼睛和耳朵这些特征点。为了让用户一眼看到"这个人的头在哪里",我们需要一个半透明的圆形轮廓。
6.2 头部半径估算
基于人体解剖学比例:头部半径 ≈ 鼻子到肩线的距离 × 0.55。
typescript
const nose = lmMap.get(arEngine.ARBodyLandmarkType.NOSE)!;
const lSho = lmMap.get(arEngine.ARBodyLandmarkType.LEFT_SHOULDER)!;
const rSho = lmMap.get(arEngine.ARBodyLandmarkType.RIGHT_SHOULDER)!;
// 肩线中点
const midShoX = (lSho.x + rSho.x) / 2;
const midShoY = (lSho.y + rSho.y) / 2;
// 鼻子到肩线中点的距离
const dx = nose.x - midShoX;
const dy = nose.y - midShoY;
const distToSho = Math.sqrt(dx * dx + dy * dy);
// 头部半径
const radius = distToSho * 0.55;
// 圆心(鼻子上方偏移一点,视觉效果更自然)
const centerX = nose.x;
const centerY = nose.y - radius * 0.3;
6.3 头部圆形渲染
typescript
@Builder
drawHeadCircle(cx: number, cy: number, r: number): void {
Circle({
width: this.uiContext.px2vp(r * 2),
height: this.uiContext.px2vp(r * 2)
})
.position({
x: this.uiContext.px2vp(cx - r),
y: this.uiContext.px2vp(cy - r)
})
.fillOpacity(0.15) // 半透明填充
.fill(Color.White)
.stroke('#FFD700') // 金色边框
.strokeWidth(2)
.hitTestBehavior(HitTestMode.None)
}
选择半透明(opacity 0.15)的原因:头部圆形是最外层的视觉元素,如果完全不透明,会遮挡下方的人脸和肩膀骨骼线。0.15 的透明度既提供足够的视觉提示,又不会干扰细节观察。
七、坐标对齐调试
7.1 常见对齐问题
问题 1:骨骼点整体偏移------所有点向某个方向偏移,像是"贴在空气中"而不是身体上。
排查 :坐标转换时是否错误地使用了 NDC(-11)而非归一化(01)坐标系。getLandmarks2D() 返回的是 [0,1] 归一化坐标,原点在左上角。
问题 2:骨骼点的 Y 方向似乎反了------手脚颠倒。
排查:确认没有对 Y 做符号翻转。归一化坐标的 Y 已经是"向下为正",等于屏幕坐标系,不需要翻转。
问题 3:骨骼点与身体位置不完全吻合,有轻微偏差。
排查 :检查是否应用了错误的宽高比修正。getLandmarks2D() 返回的坐标对应的是显示区域的归一化坐标,直接乘以屏幕宽高即可。不需要手动修正相机比。
7.2 调试工具
在开发阶段添加网格叠加层,帮助快速判断对齐情况:
typescript
@Builder
drawDebugGrid(): void {
// 画中心十字线
Line()
.startPoint([0, this.uiContext.px2vp(screenH / 2)])
.endPoint([this.uiContext.px2vp(screenW), this.uiContext.px2vp(screenH / 2)])
.stroke('#FF000044') // 半透明红线
}
八、未检测到人体时的 UX 处理
看到空白画面时,用户不知道是"正在检测"还是"不工作了"。必须提供明确的视觉反馈:
typescript
if (this.isTracking && this.bodyInfos.length === 0) {
Column() {
Text('请站在摄像头前')
.fontSize(20)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
.margin({ bottom: 12 })
Text('AR Engine 将自动检测人体骨骼关键点')
.fontSize(14)
.fontColor('#AAAAAA')
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
.hitTestBehavior(HitTestMode.None)
}
三态切换:
| 状态 | UI 展示 | 状态栏 |
|---|---|---|
| 追踪启动中 | 引导文字 | "请站在摄像头前" |
| 追踪中,未检测到 | 引导文字 | "未检测到人体 (帧#N)" |
| 追踪中,已检测到 | 骨骼叠加层 | "已检测到 X 人 (帧#N)" |
九、性能验证
9.1 组件渲染开销测试
在真机上通过 DevEco Studio Performance 面板测试 Shape 方案的渲染耗时:
测试条件:单人模式,14 条 Line + 20 个 Circle = 34 个图形元素
帧率:30fps
Shape render duration: 0.48ms (avg over 300 frames)
帧预算占用:0.48 / 33.3 = 1.4%
结论:Shape 方案的渲染开销完全可以忽略,性能瓶颈在 NPU 推理而非 UI 渲染。
9.2 双人模式扩展
当 maxDetectedBodyNum: 2 时,图形元素翻倍到 68 个。实测渲染耗时约 0.9ms,仍仅占帧预算的 2.7%。Shape 方案在双人场景下也完全可用。
十、小结
本文系统讲解了基于 ArkUI 声明式组件的骨骼可视化方案:
- 架构:Stack 分层 → ARView + Shape 叠加 + Text 标注 + Counter
- 骨骼连线:预定义连接配置,按身体区域颜色分区,ForEach 遍历绘制
- 关节圆点:白色 8×8 基础关节 + 金色 12×12 面部高亮
- 头部圆形:基于肩线距离估算半径,半透明填充 + 金色边框
- 坐标对齐:归一化 [0,1] × screenSize → px2vp,无需额外修正
- UX 处理:三态 UI 切换(引导 / 检测中 / 已检测)
- 性能:34 个元素仅占帧预算 1.4%,双人模式 2.7%
骨骼可视化是整个 BodyAR 技术栈的"最后一公里"------将数据转化为用户可见、可理解的视觉反馈。在此基础上,还可以进一步拓展:结合关节角度计算实现体感运动计数、录制骨骼数据导出 JSON 供离线分析,让 AR 从"看得见"进化到"用得上"。