BodyAR 骨骼可视化渲染:Shape + Line + Circle 打造优雅的 AR 骨架叠加层

一、前言

数据到手之后,怎么把它画出来?这看似是一个简单的 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 声明式组件的骨骼可视化方案:

  1. 架构:Stack 分层 → ARView + Shape 叠加 + Text 标注 + Counter
  2. 骨骼连线:预定义连接配置,按身体区域颜色分区,ForEach 遍历绘制
  3. 关节圆点:白色 8×8 基础关节 + 金色 12×12 面部高亮
  4. 头部圆形:基于肩线距离估算半径,半透明填充 + 金色边框
  5. 坐标对齐:归一化 [0,1] × screenSize → px2vp,无需额外修正
  6. UX 处理:三态 UI 切换(引导 / 检测中 / 已检测)
  7. 性能:34 个元素仅占帧预算 1.4%,双人模式 2.7%

骨骼可视化是整个 BodyAR 技术栈的"最后一公里"------将数据转化为用户可见、可理解的视觉反馈。在此基础上,还可以进一步拓展:结合关节角度计算实现体感运动计数、录制骨骼数据导出 JSON 供离线分析,让 AR 从"看得见"进化到"用得上"。

相关推荐
●VON2 小时前
鸿蒙NEXT新特性:HdsTabs悬浮页签栏barFloatingStyle完全入门指南
华为·harmonyos·鸿蒙·新特性
互联网散修2 小时前
鸿蒙自定义安全键盘开发实战:Canvas 精密布局与安全交互
harmonyos·自定义键盘
nashane2 小时前
HarmonyOS 6学习:麦克风“抢戏”打断音频?AudioSession焦点避坑指南
学习·音视频·harmonyos
北京阿法龙科技有限公司2 小时前
AR 智能眼镜智正优化警务领域的日常巡逻和排查麻烦的难点
ar
大雷神3 小时前
第05篇|窗口与安全区:AppStorage 如何保存宽高、状态栏和暗色模式
harmonyos
GLAB-Mary3 小时前
苏州华为培训哪家好?
华为·hcip
深开鸿3 小时前
开源鸿蒙机器人操作系统M-Robots OS 2.0重磅发布
机器人·开源·harmonyos
意图共鸣3 小时前
意图共鸣科技发布《认知智能白皮书》:从华为“逻辑折叠”看认知架构(CA)的“感知-仲裁”解耦设计
科技·华为·架构
想你依然心痛3 小时前
HarmonyOS 6 悬浮导航 + 沉浸光感:打造鸿蒙智能体驱动的沉浸式语言学习伙伴
学习·华为·ar·harmonyos·智能体