在这里插入图片描述

每日一句正能量

松弛不是懈怠,而是一种自我调节的能力。
很多人不敢放松,怕一松就滑向懒惰。松弛是主动调节,不是被动滑落。
心中有梦想就要一如既往,不能遇到困难说放就放,千锤百炼才能磨练出好钢,饱经风霜才能更强。早安!

一、前言:当健身遇见空间感知

传统健身应用依赖视频教程和手动记录,用户无法实时获知动作是否标准、身体是否过度疲劳。HarmonyOS 6(API 23)带来的 Face ARBody AR 能力,让 PC 端设备可以化身为"懂你的私教"——通过面部微表情识别疲劳程度,通过骨骼关键点实时纠正动作姿态,结合沉浸光感营造运动氛围,让居家健身也能获得专业指导。

本文将实战开发一款 “AR 健身私教” 应用,面向 HarmonyOS PC 端。核心创新点在于:

  • Face AR 心率估算:利用面部颜色变化(rPPG 技术)结合 BlendShape 疲劳表情,实现无穿戴式心率监测
  • 表情疲劳预警:捕捉眯眼、张嘴喘气、眉毛下垂等微表情,提前预警运动过度风险
  • Body AR 姿态纠正:20+ 骨骼关键点实时比对标准动作库,偏差超过阈值即时语音提醒
  • 沉浸光感氛围:根据运动强度(心率区间)动态调整 UI 光效颜色——热身蓝光、燃脂绿光、极限红光
  • 悬浮导航控制:底部悬浮面板显示实时数据,支持手势切换训练模式,不遮挡运动画面

二、系统架构设计

2.1 三层感知架构

┌─────────────────────────────────────────────────────────────┐
│                    生理感知层(Face AR)                       │
│  ┌─────────────────────┐    ┌─────────────────────────────┐  │
│  │   心率估算引擎       │    │     疲劳检测引擎            │  │
│  │  · 面部ROI颜色分析    │    │  · 64种BlendShape疲劳模型   │  │
│  │  · rPPG信号提取      │    │  · 眨眼频率检测             │  │
│  │  · 心跳峰值检测      │    │  · 嘴部张开度分析           │  │
│  │  · 30-220BPM范围     │    │  · 眉毛下垂度评估           │  │
│  └──────────┬──────────┘    └──────────────┬──────────────┘  │
└─────────────┼────────────────────────────────┼────────────────┘
              │                                │
              ▼                                ▼
┌─────────────────────────────────────────────────────────────┐
│                    运动感知层(Body AR)                       │
│  ┌─────────────────────┐    ┌─────────────────────────────┐  │
│  │   姿态捕捉引擎       │    │     动作比对引擎            │  │
│  │  · 20+骨骼关键点     │    │  · 标准动作骨骼库           │  │
│  │  · 3D空间位置追踪    │    │  · 动态时间规整(DTW)        │  │
│  │  · 关节角度计算      │    │  · 偏差阈值判定             │  │
│  │  · 运动轨迹记录      │    │  · 实时语音反馈             │  │
│  └──────────┬──────────┘    └──────────────┬──────────────┘  │
└─────────────┼────────────────────────────────┼────────────────┘
              │                                │
              ▼                                ▼
┌─────────────────────────────────────────────────────────────┐
│                    沉浸交互层(ArkUI + HDS)                  │
│  ┌─────────────────────┐    ┌─────────────────────────────┐  │
│  │   光感自适应标题栏    │    │     悬浮数据面板            │  │
│  │  · 心率区间色映射    │    │  · 实时心率/卡路里          │  │
│  │  · 疲劳度光效警示    │    │  · 动作标准度评分           │  │
│  │  · 训练阶段指示      │    │  · 手势切换训练模式         │  │
│  └─────────────────────┘    └─────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

2.2 心率区间光感映射

心率区间 范围 光效颜色 运动阶段 UI 氛围
热身区 <100 BPM 冰蓝 #4ECDC4 准备活动 柔和脉冲
燃脂区 100-140 BPM 翠绿 #00D4AA 有氧训练 稳定呼吸
耐力区 140-160 BPM 琥珀 #FFD700 力量强化 渐强闪烁
极限区 >160 BPM 赤红 #FF6B6B 冲刺突破 急促警示
疲劳预警 表情检测 紫红 #9B59B6 强制休息 呼吸渐变

三、环境配置与权限声明

3.1 模块依赖配置

{
  "dependencies": {
    "@hms.core.ar.arengine": "^6.1.0",
    "@kit.UIDesignKit": "^6.0.0",
    "@kit.SensorServiceKit": "^6.0.0",
    "@kit.MultimediaKit": "^6.0.0",
    "@kit.AIModelKit": "^6.0.0"
  }
}

3.2 权限声明

{
  "module": {
    "requestPermissions": [
      { "name": "ohos.permission.CAMERA" },
      { "name": "ohos.permission.RECORD_AUDIO" },
      { "name": "ohos.permission.INTERNET" }
    ]
  }
}

四、核心代码实战

4.1 Face AR 心率与疲劳检测引擎(FaceFitnessEngine.ets)

代码亮点:结合面部颜色变化 rPPG 技术与 BlendShape 疲劳模型,实现无穿戴式生理监测。

// entry/src/main/ets/engine/FaceFitnessEngine.ets
import { arEngine } from '@hms.core.ar.arengine';

export interface FitnessStatus {
  heartRate: number;        // 估算心率 BPM
  heartRateZone: string;    // 心率区间
  fatigueLevel: number;     // 疲劳度 0-1
  isOverExerted: boolean;   // 是否过度运动
  blinkRate: number;        // 眨眼频率(每分钟)
  mouthOpenness: number;    // 嘴部张开度
  browDroop: number;        // 眉毛下垂度
}

export class FaceFitnessEngine {
  private static instance: FaceFitnessEngine;
  
  // rPPG 心率估算参数
  private readonly RPPG_WINDOW_SIZE = 300; // 10秒@30fps
  private greenChannelHistory: number[] = [];
  private lastPeakTime: number = 0;
  private peakIntervals: number[] = [];
  
  // 疲劳检测参数
  private blinkTimestamps: number[] = [];
  private readonly FATIGUE_THRESHOLDS = {
    BROW_DROP: 0.4,         // 眉毛下垂阈值
    MOUTH_OPEN: 0.5,        // 张嘴喘气阈值
    EYE_SQUINT: 0.6,        // 眯眼疲劳阈值
    BLINK_RATE_HIGH: 30,    // 眨眼过快(疲劳/干涩)
    BLINK_RATE_LOW: 5       // 眨眼过慢(专注/疲劳)
  };

  static getInstance(): FaceFitnessEngine {
    if (!FaceFitnessEngine.instance) {
      FaceFitnessEngine.instance = new FaceFitnessEngine();
    }
    return FaceFitnessEngine.instance;
  }

  /**
   * 处理 Face AR 数据,返回健身状态
   */
  processFaceFrame(face: arEngine.ARFace): FitnessStatus {
    const now = Date.now();
    
    // 1. rPPG 心率估算
    const heartRate = this.estimateHeartRate(face);
    
    // 2. 疲劳特征提取
    const blendShapes = face.getBlendShapes();
    const browDroop = blendShapes ? 
      (blendShapes.browDownLeft + blendShapes.browDownRight) / 2 : 0;
    const mouthOpenness = blendShapes?.jawOpen || 0;
    const eyeSquint = blendShapes ? 
      (blendShapes.eyeSquintLeft + blendShapes.eyeSquintRight) / 2 : 0;
    
    // 3. 眨眼检测
    this.detectBlink(blendShapes, now);
    const blinkRate = this.calculateBlinkRate(now);
    
    // 4. 综合疲劳度评估
    let fatigueScore = 0;
    if (browDroop > this.FATIGUE_THRESHOLDS.BROW_DROP) fatigueScore += 0.3;
    if (mouthOpenness > this.FATIGUE_THRESHOLDS.MOUTH_OPEN) fatigueScore += 0.3;
    if (eyeSquint > this.FATIGUE_THRESHOLDS.EYE_SQUINT) fatigueScore += 0.2;
    if (blinkRate > this.FATIGUE_THRESHOLDS.BLINK_RATE_HIGH || 
        blinkRate < this.FATIGUE_THRESHOLDS.BLINK_RATE_LOW) fatigueScore += 0.2;
    
    const fatigueLevel = Math.min(fatigueScore, 1.0);
    const isOverExerted = fatigueLevel > 0.7 || heartRate > 180;
    
    // 5. 心率区间判定
    const heartRateZone = this.getHeartRateZone(heartRate);

    return {
      heartRate,
      heartRateZone,
      fatigueLevel,
      isOverExerted,
      blinkRate,
      mouthOpenness,
      browDroop
    };
  }

  /**
   * rPPG 心率估算(基于面部绿色通道变化)
   */
  private estimateHeartRate(face: arEngine.ARFace): number {
    // 获取面部纹理数据(简化示意,实际需从ARFace获取像素数据)
    // 这里使用模拟逻辑,实际应接入 rPPG 算法
    const greenValue = this.extractGreenChannel(face);
    this.greenChannelHistory.push(greenValue);
    
    if (this.greenChannelHistory.length > this.RPPG_WINDOW_SIZE) {
      this.greenChannelHistory.shift();
    }
    
    if (this.greenChannelHistory.length < 60) return 0; // 数据不足
    
    // 平滑滤波
    const smoothed = this.movingAverage(this.greenChannelHistory, 5);
    
    // 峰值检测
    const peaks = this.findPeaks(smoothed);
    if (peaks.length >= 2) {
      const intervals = [];
      for (let i = 1; i < peaks.length; i++) {
        intervals.push(peaks[i] - peaks[i-1]);
      }
      const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
      const bpm = Math.round(60 / (avgInterval / 30)); // 假设30fps
      return Math.max(50, Math.min(220, bpm));
    }
    
    return 0;
  }

  private extractGreenChannel(face: arEngine.ARFace): number {
    // 实际应从面部ROI区域提取绿色通道均值
    // 这里返回模拟值
    return 128 + Math.sin(Date.now() / 500) * 20;
  }

  private movingAverage(data: number[], window: number): number[] {
    const result = [];
    for (let i = 0; i < data.length; i++) {
      const start = Math.max(0, i - window + 1);
      const subset = data.slice(start, i + 1);
      result.push(subset.reduce((a, b) => a + b, 0) / subset.length);
    }
    return result;
  }

  private findPeaks(data: number[]): number[] {
    const peaks = [];
    for (let i = 2; i < data.length - 2; i++) {
      if (data[i] > data[i-1] && data[i] > data[i-2] &&
          data[i] > data[i+1] && data[i] > data[i+2]) {
        peaks.push(i);
      }
    }
    return peaks;
  }

  private detectBlink(blendShapes: any, timestamp: number): void {
    if (!blendShapes) return;
    const leftEyeOpen = blendShapes.eyeBlinkLeft < 0.5;
    const rightEyeOpen = blendShapes.eyeBlinkRight < 0.5;
    
    if (!leftEyeOpen && !rightEyeOpen) {
      this.blinkTimestamps.push(timestamp);
    }
    
    // 清理30秒前的数据
    this.blinkTimestamps = this.blinkTimestamps.filter(t => timestamp - t < 30000);
  }

  private calculateBlinkRate(now: number): number {
    const recentBlinks = this.blinkTimestamps.filter(t => now - t < 60000);
    return recentBlinks.length;
  }

  private getHeartRateZone(bpm: number): string {
    if (bpm < 100) return 'warmup';
    if (bpm < 140) return 'fatburn';
    if (bpm < 160) return 'endurance';
    return 'peak';
  }
}

4.2 Body AR 姿态纠正引擎(PoseCorrectionEngine.ets)

代码亮点:实时比对用户骨骼关键点与标准动作库,计算关节角度偏差,提供语音反馈。

// entry/src/main/ets/engine/PoseCorrectionEngine.ets
import { arEngine } from '@hms.core.ar.arengine';

export interface PoseDeviation {
  jointName: string;
  expectedAngle: number;
  actualAngle: number;
  deviation: number;
  severity: 'none' | 'minor' | 'major' | 'critical';
  advice: string;
}

export interface PoseScore {
  totalScore: number;       // 0-100
  deviations: PoseDeviation[];
  isStandard: boolean;
}

export class PoseCorrectionEngine {
  private static instance: PoseCorrectionEngine;
  
  // 标准动作库:深蹲
  private readonly SQUAT_STANDARD = {
    leftKnee: { min: 80, max: 110 },      // 左膝角度
    rightKnee: { min: 80, max: 110 },     // 右膝角度
    hipAngle: { min: 70, max: 100 },      // 髋部角度
    backAngle: { min: 45, max: 75 },     // 背部倾斜角
    kneeAlignment: { max: 0.1 }           // 膝盖与脚尖对齐度
  };

  static getInstance(): PoseCorrectionEngine {
    if (!PoseCorrectionEngine.instance) {
      PoseCorrectionEngine.instance = new PoseCorrectionEngine();
    }
    return PoseCorrectionEngine.instance;
  }

  /**
   * 分析当前姿态与标准动作的偏差
   */
  analyzePose(body: arEngine.ARBody, exerciseType: string = 'squat'): PoseScore {
    const landmarks = body.getLandmarks3D();
    if (!landmarks) {
      return { totalScore: 0, deviations: [], isStandard: false };
    }

    const floatView = new Float32Array(landmarks);
    const deviations: PoseDeviation[] = [];

    // 获取关键骨骼点
    const leftHip = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.LEFT_HIP);
    const rightHip = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.RIGHT_HIP);
    const leftKnee = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.LEFT_KNEE);
    const rightKnee = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.RIGHT_KNEE);
    const leftAnkle = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.LEFT_ANKLE);
    const rightAnkle = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.RIGHT_ANKLE);
    const leftShoulder = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.LEFT_SHOULDER);
    const rightShoulder = this.getLandmark3D(floatView, arEngine.ARBodyLandmarkType.RIGHT_SHOULDER);

    if (!leftHip || !rightHip || !leftKnee || !rightKnee || !leftAnkle || !rightAnkle) {
      return { totalScore: 0, deviations: [], isStandard: false };
    }

    // 计算左膝角度
    const leftKneeAngle = this.calculateJointAngle(leftHip, leftKnee, leftAnkle);
    const leftKneeDev = this.checkAngleDeviation('左膝', leftKneeAngle, this.SQUAT_STANDARD.leftKnee);
    if (leftKneeDev) deviations.push(leftKneeDev);

    // 计算右膝角度
    const rightKneeAngle = this.calculateJointAngle(rightHip, rightKnee, rightAnkle);
    const rightKneeDev = this.checkAngleDeviation('右膝', rightKneeAngle, this.SQUAT_STANDARD.rightKnee);
    if (rightKneeDev) deviations.push(rightKneeDev);

    // 计算髋部角度
    const hipCenter = {
      x: (leftHip.x + rightHip.x) / 2,
      y: (leftHip.y + rightHip.y) / 2,
      z: (leftHip.z + rightHip.z) / 2
    };
    const shoulderCenter = {
      x: (leftShoulder.x + rightShoulder.x) / 2,
      y: (leftShoulder.y + rightShoulder.y) / 2,
      z: (leftShoulder.z + rightShoulder.z) / 2
    };
    const hipAngle = this.calculateJointAngle(shoulderCenter, hipCenter, {
      x: hipCenter.x,
      y: hipCenter.y + 0.5,
      z: hipCenter.z
    });
    const hipDev = this.checkAngleDeviation('髋部', hipAngle, this.SQUAT_STANDARD.hipAngle);
    if (hipDev) deviations.push(hipDev);

    // 计算总分
    let totalDeduction = 0;
    deviations.forEach(dev => {
      switch (dev.severity) {
        case 'minor': totalDeduction += 5; break;
        case 'major': totalDeduction += 15; break;
        case 'critical': totalDeduction += 25; break;
      }
    });

    const totalScore = Math.max(0, 100 - totalDeduction);
    
    return {
      totalScore,
      deviations,
      isStandard: totalScore >= 85
    };
  }

  private calculateJointAngle(
    p1: { x: number; y: number; z: number },
    joint: { x: number; y: number; z: number },
    p2: { x: number; y: number; z: number }
  ): number {
    const v1 = { x: p1.x - joint.x, y: p1.y - joint.y, z: p1.z - joint.z };
    const v2 = { x: p2.x - joint.x, y: p2.y - joint.y, z: p2.z - joint.z };
    
    const dot = v1.x * v2.x + v1.y * v2.y + v1.z * v2.z;
    const mag1 = Math.sqrt(v1.x * v1.x + v1.y * v1.y + v1.z * v1.z);
    const mag2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y + v2.z * v2.z);
    
    const cosAngle = dot / (mag1 * mag2);
    return Math.acos(Math.max(-1, Math.min(1, cosAngle))) * (180 / Math.PI);
  }

  private checkAngleDeviation(
    jointName: string,
    actual: number,
    standard: { min: number; max: number }
  ): PoseDeviation | null {
    const expected = (standard.min + standard.max) / 2;
    const deviation = Math.abs(actual - expected);
    
    let severity: PoseDeviation['severity'] = 'none';
    let advice = '';

    if (deviation > 20) {
      severity = 'critical';
      advice = `${jointName}角度严重偏差,请立即调整,避免受伤`;
    } else if (deviation > 10) {
      severity = 'major';
      advice = `${jointName}角度偏差较大,建议放慢速度调整`;
    } else if (deviation > 5) {
      severity = 'minor';
      advice = `${jointName}角度轻微偏差,注意控制`;
    } else {
      return null;
    }

    return {
      jointName,
      expectedAngle: Math.round(expected),
      actualAngle: Math.round(actual),
      deviation: Math.round(deviation),
      severity,
      advice
    };
  }

  private getLandmark3D(floatView: Float32Array, type: arEngine.ARBodyLandmarkType): { x: number; y: number; z: number } | null {
    const index = Object.values(arEngine.ARBodyLandmarkType).indexOf(type);
    if (index < 0) return null;
    const offset = index * 3;
    if (offset + 2 >= floatView.length) return null;
    return {
      x: floatView[offset],
      y: floatView[offset + 1],
      z: floatView[offset + 2]
    };
  }
}

4.3 沉浸光感训练标题栏(FitnessLightTitleBar.ets)

代码亮点:根据心率区间动态调整光效颜色和脉冲频率,疲劳预警时触发呼吸灯效果。

// entry/src/main/ets/components/FitnessLightTitleBar.ets
import { HdsNavigation, SystemMaterialEffect } from '@kit.UIDesignKit';

@Component
export struct FitnessLightTitleBar {
  @Prop currentExercise: string = '深蹲训练';
  @Prop heartRate: number = 0;
  @Prop heartRateZone: string = 'warmup';
  @Prop fatigueLevel: number = 0;
  @State pulsePhase: number = 0;

  // 心率区间色映射
  private readonly ZONE_COLORS: Map<string, string> = new Map([
    ['warmup', '#4ECDC4'],      // 冰蓝
    ['fatburn', '#00D4AA'],      // 翠绿
    ['endurance', '#FFD700'],    // 琥珀
    ['peak', '#FF6B6B'],         // 赤红
    ['overexerted', '#9B59B6']   // 紫红-疲劳
  ]);

  aboutToAppear(): void {
    // 启动脉冲动画
    this.startPulseAnimation();
  }

  private startPulseAnimation(): void {
    const animate = () => {
      this.pulsePhase = (this.pulsePhase + 0.05) % (Math.PI * 2);
      setTimeout(animate, 50);
    };
    animate();
  }

  private getZoneColor(): string {
    if (this.fatigueLevel > 0.7) return this.ZONE_COLORS.get('overexerted') || '#9B59B6';
    return this.ZONE_COLORS.get(this.heartRateZone) || '#4ECDC4';
  }

  private getPulseOpacity(): number {
    const baseOpacity = 0.15;
    const pulseRange = 0.1;
    return baseOpacity + Math.sin(this.pulsePhase) * pulseRange;
  }

  build() {
    HdsNavigation({
      title: this.currentExercise,
      subtitle: this.buildSubtitle(),
      systemMaterialEffect: SystemMaterialEffect.IMMERSIVE,
      backgroundOpacity: 0.8,
      height: 56,
      leading: this.buildLeadingActions(),
      trailing: this.buildTrailingActions()
    })
    .width('100%')
    .backgroundColor(`rgba(${this.hexToRgb(this.getZoneColor())}, 0.2)`)
    .border({
      width: { bottom: 2 },
      color: this.getZoneColor()
    })
    .shadow({
      radius: 12 + Math.sin(this.pulsePhase) * 8,
      color: this.getZoneColor(),
      offsetX: 0,
      offsetY: 2
    })
    .animation({
      duration: 300,
      curve: Curve.EaseInOut
    })
  }

  @Builder
  buildLeadingActions(): void {
    Row({ space: 10 }) {
      // 心率指示灯
      Stack() {
        Circle()
          .width(14)
          .height(14)
          .fill(this.getZoneColor())
          .opacity(this.getPulseOpacity())
        
        Circle()
          .width(8)
          .height(8)
          .fill(this.getZoneColor())
      }

      Column({ space: 2 }) {
        Text(`${this.heartRate} BPM`)
          .fontSize(16)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Bold)
        
        Text(this.getZoneLabel())
          .fontSize(11)
          .fontColor(this.getZoneColor())
      }
    }
    .padding({ left: 16 })
  }

  @Builder
  buildTrailingActions(): void {
    Row({ space: 8 }) {
      // 疲劳度指示
      if (this.fatigueLevel > 0.5) {
        Text(`疲劳 ${Math.round(this.fatigueLevel * 100)}%`)
          .fontSize(12)
          .fontColor('#FF6B6B')
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
          .backgroundColor('rgba(255,107,107,0.15)')
          .borderRadius(8)
      }

      // 训练时长
      Text('12:34')
        .fontSize(14)
        .fontColor('rgba(255,255,255,0.7)')
    }
    .padding({ right: 16 })
  }

  private buildSubtitle(): string {
    if (this.fatigueLevel > 0.7) return '⚠️ 建议休息';
    if (this.fatigueLevel > 0.5) return '注意控制节奏';
    return '保持呼吸均匀';
  }

  private getZoneLabel(): string {
    const labels: Map<string, string> = new Map([
      ['warmup', '热身'],
      ['fatburn', '燃脂'],
      ['endurance', '耐力'],
      ['peak', '极限'],
      ['overexerted', '过度']
    ]);
    return labels.get(this.heartRateZone) || '检测中';
  }

  private hexToRgb(hex: string): string {
    const r = parseInt(hex.slice(1, 3), 16);
    const g = parseInt(hex.slice(3, 5), 16);
    const b = parseInt(hex.slice(5, 7), 16);
    return `${r},${g},${b}`;
  }
}

4.4 悬浮数据面板(FloatFitnessPanel.ets)

代码亮点:底部悬浮面板实时显示姿态评分、动作建议和训练数据,支持手势切换训练模式。

// entry/src/main/ets/components/FloatFitnessPanel.ets
import { HdsTabs, HdsTabsController, hdsMaterial } from '@kit.UIDesignKit';
import { PoseScore } from '../engine/PoseCorrectionEngine';

@Component
export struct FloatFitnessPanel {
  @State currentTab: number = 0;
  @State transparencyLevel: number = 0.75;
  @Prop poseScore: PoseScore = { totalScore: 0, deviations: [], isStandard: false };
  @Prop calories: number = 0;
  @Prop repCount: number = 0;
  private controller: HdsTabsController = new HdsTabsController();

  private readonly TAB_CONFIG = [
    { label: '姿态', icon: $r('sys.symbol.figure_stand') },
    { label: '数据', icon: $r('sys.symbol.chart_bar') },
    { label: '训练', icon: $r('sys.symbol.dumbbell') },
    { label: '设置', icon: $r('sys.symbol.gear') }
  ];

  build() {
    HdsTabs({ controller: this.controller }) {
      ForEach(this.TAB_CONFIG, (item: typeof this.TAB_CONFIG[0], index: number) => {
        TabContent() {
          this.buildTabContent(index)
        }
        .tabBar(new BottomTabBarStyle({
          normal: new SymbolGlyphModifier(item.icon).fontColor(['rgba(255,255,255,0.5)']),
          selected: new SymbolGlyphModifier(item.icon).fontColor(['#00D4AA'])
        }, item.label))
      })
    }
    .barOverlap(true)
    .vertical(false)
    .barPosition(BarPosition.End)
    .barFloatingStyle({
      barBottomMargin: 16,
      barSideMargin: 48,
      systemMaterialEffect: {
        materialType: hdsMaterial.MaterialType.IMMERSIVE,
        materialLevel: hdsMaterial.MaterialLevel.EXQUISITE
      }
    })
    .backgroundColor(`rgba(10,10,20,${this.transparencyLevel})`)
    .backdropFilter($r('sys.blur.40'))
    .borderRadius(24)
    .margin({ left: '4%', right: '4%', bottom: 12 })
    .shadow({
      radius: 20,
      color: 'rgba(0,0,0,0.4)',
      offsetX: 0,
      offsetY: -4
    })
  }

  @Builder
  buildTabContent(index: number): void {
    Column({ space: 12 }) {
      if (index === 0) {
        this.buildPosePanel()
      } else if (index === 1) {
        this.buildDataPanel()
      } else if (index === 2) {
        this.buildTrainingPanel()
      } else {
        this.buildSettingsPanel()
      }
    }
    .width('100%')
    .height('100%')
    .padding(16)
  }

  @Builder
  buildPosePanel(): void {
    Column({ space: 10 }) {
      // 姿态评分圆环
      Stack({ alignContent: Alignment.Center }) {
        Circle()
          .width(80)
          .height(80)
          .fill('none')
          .stroke(this.poseScore.isStandard ? '#00D4AA' : '#FFD700')
          .strokeWidth(6)
          .strokeLineCap(LineCapType.Round)
          .strokeDashArray([this.poseScore.totalScore * 2.5, 250])
          .rotate({ angle: -90, centerX: '50%', centerY: '50%' })
          .animation({ duration: 500 })

        Text(`${this.poseScore.totalScore}`)
          .fontSize(28)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Bold)
      }

      Text(this.poseScore.isStandard ? '动作标准 ✓' : '需要调整 ⚠️')
        .fontSize(14)
        .fontColor(this.poseScore.isStandard ? '#00D4AA' : '#FFD700')

      // 偏差详情
      if (this.poseScore.deviations.length > 0) {
        Column({ space: 6 }) {
          ForEach(this.poseScore.deviations.slice(0, 3), (dev: PoseScore['deviations'][0]) => {
            Row({ space: 8 }) {
              Circle()
                .width(8)
                .height(8)
                .fill(dev.severity === 'critical' ? '#FF6B6B' : 
                      dev.severity === 'major' ? '#FFD700' : '#FFA500')
              
              Text(dev.advice)
                .fontSize(12)
                .fontColor('rgba(255,255,255,0.8)')
                .layoutWeight(1)
                .maxLines(2)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .width('100%')
            .padding(6)
            .backgroundColor('rgba(255,255,255,0.05)')
            .borderRadius(6)
          })
        }
      }
    }
  }

  @Builder
  buildDataPanel(): void {
    Column({ space: 12 }) {
      Row({ space: 16 }) {
        Column({ space: 4 }) {
          Text(`${this.calories}`)
            .fontSize(24)
            .fontColor('#FF6B6B')
            .fontWeight(FontWeight.Bold)
          Text('千卡')
            .fontSize(12)
            .fontColor('rgba(255,255,255,0.5)')
        }
        .layoutWeight(1)

        Column({ space: 4 }) {
          Text(`${this.repCount}`)
            .fontSize(24)
            .fontColor('#00D4AA')
            .fontWeight(FontWeight.Bold)
          Text('次数')
            .fontSize(12)
            .fontColor('rgba(255,255,255,0.5)')
        }
        .layoutWeight(1)

        Column({ space: 4 }) {
          Text('85%')
            .fontSize(24)
            .fontColor('#FFD700')
            .fontWeight(FontWeight.Bold)
          Text('完成度')
            .fontSize(12)
            .fontColor('rgba(255,255,255,0.5)')
        }
        .layoutWeight(1)
      }
      .width('100%')

      // 心率曲线占位
      Column() {
        Text('心率趋势')
          .fontSize(14)
          .fontColor('rgba(255,255,255,0.5)')
          .margin({ bottom: 8 })
        
        Row({ space: 2 }) {
          ForEach([60, 75, 90, 110, 135, 150, 140, 130, 120, 110], (hr: number) => {
            Column()
              .width(8)
              .height(hr * 0.8)
              .backgroundColor(hr > 140 ? '#FF6B6B' : hr > 110 ? '#FFD700' : '#00D4AA')
              .borderRadius(2)
          })
        }
        .width('100%')
        .height(120)
        .justifyContent(FlexAlign.End)
      }
      .width('100%')
      .padding(12)
      .backgroundColor('rgba(255,255,255,0.03)')
      .borderRadius(8)
    }
  }

  @Builder
  buildTrainingPanel(): void {
    Column({ space: 10 }) {
      Text('训练模式')
        .fontSize(16)
        .fontColor('#FFFFFF')
        .fontWeight(FontWeight.Bold)

      ForEach([
        { name: '深蹲', icon: '🏋️', duration: '15分钟', intensity: '高' },
        { name: '俯卧撑', icon: '💪', duration: '10分钟', intensity: '中' },
        { name: '平板支撑', icon: '🧘', duration: '5分钟', intensity: '低' },
        { name: '开合跳', icon: '⭐', duration: '8分钟', intensity: '中' }
      ], (item: { name: string; icon: string; duration: string; intensity: string }) => {
        Row({ space: 12 }) {
          Text(item.icon)
            .fontSize(24)
          
          Column({ space: 2 }) {
            Text(item.name)
              .fontSize(15)
              .fontColor('#FFFFFF')
            
            Text(`${item.duration} · ${item.intensity}强度`)
              .fontSize(12)
              .fontColor('rgba(255,255,255,0.5)')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)

          Button('开始')
            .fontSize(12)
            .fontColor('#FFFFFF')
            .backgroundColor('#00D4AA')
            .padding({ left: 16, right: 16, top: 6, bottom: 6 })
            .borderRadius(16)
        }
        .width('100%')
        .padding(10)
        .backgroundColor('rgba(255,255,255,0.05)')
        .borderRadius(10)
      })
    }
  }

  @Builder
  buildSettingsPanel(): void {
    Column({ space: 14 }) {
      Text('面板设置')
        .fontSize(16)
        .fontColor('#FFFFFF')
        .fontWeight(FontWeight.Bold)

      Row({ space: 10 }) {
        ForEach([
          { label: '弱', value: 0.55 },
          { label: '平衡', value: 0.75 },
          { label: '强', value: 0.90 }
        ], (item: { label: string; value: number }) => {
          Button(item.label)
            .fontSize(13)
            .fontColor('#FFFFFF')
            .backgroundColor(this.transparencyLevel === item.value ? '#00D4AA' : 'rgba(255,255,255,0.1)')
            .padding({ left: 20, right: 20, top: 6, bottom: 6 })
            .borderRadius(16)
            .onClick(() => {
              this.transparencyLevel = item.value;
            })
        })
      }

      Text('语音反馈')
        .fontSize(16)
        .fontColor('#FFFFFF')
        .fontWeight(FontWeight.Bold)
        .margin({ top: 8 })

      Toggle({ type: ToggleType.Switch, isOn: true })
        .selectedColor('#00D4AA')
        .onChange((isOn: boolean) => {
          AppStorage.setOrCreate('voice_feedback', isOn);
        })
    }
  }
}

4.5 主训练页面(FitnessCoachPage.ets)

代码亮点:整合 Face AR 生理监测、Body AR 姿态纠正、沉浸光感标题栏和悬浮数据面板,实现完整的"AR 私教"体验。

// entry/src/main/ets/pages/FitnessCoachPage.ets
import { FitnessLightTitleBar } from '../components/FitnessLightTitleBar';
import { FloatFitnessPanel } from '../components/FloatFitnessPanel';
import { FaceFitnessEngine, FitnessStatus } from '../engine/FaceFitnessEngine';
import { PoseCorrectionEngine, PoseScore } from '../engine/PoseCorrectionEngine';

@Entry
@Component
struct FitnessCoachPage {
  @State currentExercise: string = '深蹲训练';
  @State heartRate: number = 0;
  @State heartRateZone: string = 'warmup';
  @State fatigueLevel: number = 0;
  @State isOverExerted: boolean = false;
  @State poseScore: PoseScore = { totalScore: 0, deviations: [], isStandard: false };
  @State calories: number = 0;
  @State repCount: number = 0;
  @State trackingQuality: number = 1.0;
  @State arStatus: string = '就绪';

  private faceEngine: FaceFitnessEngine = FaceFitnessEngine.getInstance();
  private poseEngine: PoseCorrectionEngine = PoseCorrectionEngine.getInstance();
  private arLoopId: number = 0;
  private lastRepTime: number = 0;

  aboutToAppear(): void {
    this.initializeARSession();
  }

  aboutToDisappear(): void {
    cancelAnimationFrame(this.arLoopId);
  }

  private initializeARSession(): void {
    // AR会话初始化
    this.startARLoop();
  }

  private startARLoop(): void {
    const loop = () => {
      this.processARFrame();
      this.arLoopId = requestAnimationFrame(loop);
    };
    this.arLoopId = requestAnimationFrame(loop);
  }

  private processARFrame(): void {
    // 模拟AR数据处理(实际应从ARSession获取)
    let quality = 0;

    // Face AR处理
    // const fitnessStatus = this.faceEngine.processFaceFrame(face);
    // this.updateFitnessStatus(fitnessStatus);
    // quality += 0.5;

    // Body AR处理
    // const poseScore = this.poseEngine.analyzePose(body);
    // this.poseScore = poseScore;
    // quality += 0.5;

    // 模拟数据更新
    this.simulateData();

    this.trackingQuality = quality;
    AppStorage.setOrCreate('tracking_quality', quality);
  }

  private simulateData(): void {
    // 模拟心率变化
    const baseHR = 100 + Math.sin(Date.now() / 5000) * 40;
    this.heartRate = Math.round(baseHR);
    this.heartRateZone = this.faceEngine['getHeartRateZone'](this.heartRate);
    
    // 模拟疲劳度
    this.fatigueLevel = Math.min(0.3 + (Date.now() % 60000) / 60000 * 0.5, 0.9);
    this.isOverExerted = this.fatigueLevel > 0.7;

    // 模拟姿态评分
    this.poseScore = {
      totalScore: Math.round(70 + Math.random() * 25),
      deviations: [],
      isStandard: Math.random() > 0.3
    };

    // 模拟卡路里和次数
    this.calories = Math.round((Date.now() % 60000) / 60000 * 150);
    this.repCount = Math.round((Date.now() % 60000) / 60000 * 30);
  }

  private updateFitnessStatus(status: FitnessStatus): void {
    this.heartRate = status.heartRate;
    this.heartRateZone = status.heartRateZone;
    this.fatigueLevel = status.fatigueLevel;
    this.isOverExerted = status.isOverExerted;

    // 疲劳预警触发语音
    if (status.isOverExerted) {
      this.triggerVoiceAlert('检测到过度疲劳,请立即休息');
    }
  }

  private triggerVoiceAlert(message: string): void {
    // 语音合成提示
    console.info(`[Voice Alert] ${message}`);
  }

  build() {
    Stack({ alignContent: Alignment.Center }) {
      // 第一层:动态环境光背景
      this.buildAmbientLightLayer()

      // 第二层:训练内容层
      Column({ space: 0 }) {
        // 沉浸光感标题栏
        FitnessLightTitleBar({
          currentExercise: this.currentExercise,
          heartRate: this.heartRate,
          heartRateZone: this.heartRateZone,
          fatigueLevel: this.fatigueLevel
        })

        // 训练视频/摄像头画面区域
        Stack({ alignContent: Alignment.Center }) {
          Column() {
            // 训练画面占位
            Text('训练画面区域')
              .fontSize(18)
              .fontColor('rgba(255,255,255,0.5)')

            // AR骨骼叠加层提示
            if (this.trackingQuality > 0.5) {
              Text('骨骼追踪中...')
                .fontSize(12)
                .fontColor('#00D4AA')
                .margin({ top: 8 })
            }
          }
          .width('100%')
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
          .backgroundColor('rgba(255,255,255,0.02)')
          .borderRadius(16)
          .margin(16)

          // 姿态评分浮动显示
          if (this.poseScore.totalScore > 0) {
            Column({ space: 4 }) {
              Text(`${this.poseScore.totalScore}`)
                .fontSize(36)
                .fontColor(this.poseScore.isStandard ? '#00D4AA' : '#FFD700')
                .fontWeight(FontWeight.Bold)
              
              Text('分')
                .fontSize(14)
                .fontColor('rgba(255,255,255,0.6)')
            }
            .position({ x: '90%', y: '10%' })
            .markAnchor({ x: 1, y: 0 })
            .padding(16)
            .backgroundColor('rgba(0,0,0,0.4)')
            .borderRadius(12)
            .backdropFilter($r('sys.blur.20'))
          }

          // 疲劳预警覆盖层
          if (this.isOverExerted) {
            Column({ space: 8 }) {
              Text('⚠️ 过度疲劳预警')
                .fontSize(20)
                .fontColor('#FF6B6B')
                .fontWeight(FontWeight.Bold)
              
              Text('建议立即停止运动,休息恢复')
                .fontSize(14)
                .fontColor('rgba(255,255,255,0.8)')
              
              Button('暂停训练')
                .fontSize(14)
                .fontColor('#FFFFFF')
                .backgroundColor('#FF6B6B')
                .padding({ left: 24, right: 24, top: 10, bottom: 10 })
                .borderRadius(20)
                .onClick(() => {
                  // 暂停训练逻辑
                })
            }
            .width('80%')
            .padding(24)
            .backgroundColor('rgba(155,89,182,0.2)')
            .borderRadius(16)
            .backdropFilter($r('sys.blur.30'))
            .border({
              width: 1,
              color: 'rgba(255,107,107,0.5)'
            })
          }
        }
        .layoutWeight(1)
      }
      .width('100%')
      .height('100%')

      // 第三层:悬浮数据面板
      FloatFitnessPanel({
        poseScore: this.poseScore,
        calories: this.calories,
        repCount: this.repCount
      })
      .height(300)
      .position({ x: 0, y: '100%' })
      .markAnchor({ x: 0, y: 1 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#080810')
    .expandSafeArea(
      [SafeAreaType.SYSTEM],
      [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM, SafeAreaEdge.START, SafeAreaEdge.END]
    )
  }

  @Builder
  buildAmbientLightLayer(): void {
    Column() {
      // 顶部心率光晕
      Column()
        .width(600)
        .height(300)
        .backgroundColor(this.getZoneColor())
        .blur(200)
        .opacity(0.12)
        .position({ x: '50%', y: '0%' })
        .anchor('50%')
        .animation({
          duration: 4000,
          curve: Curve.EaseInOut,
          iterations: -1,
          playMode: PlayMode.Alternate
        })
        .scale({ x: 1.2, y: 0.8 })

      // 底部氛围光
      Column()
        .width('100%')
        .height(200)
        .backgroundColor(this.getZoneColor())
        .opacity(0.06)
        .blur(100)
        .position({ x: 0, y: '80%' })
        .linearGradient({
          direction: GradientDirection.Top,
          colors: [
            [this.getZoneColor(), 0.0],
            ['transparent', 1.0]
          ]
        })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#050508')
  }

  private getZoneColor(): string {
    const colors: Map<string, string> = new Map([
      ['warmup', '#4ECDC4'],
      ['fatburn', '#00D4AA'],
      ['endurance', '#FFD700'],
      ['peak', '#FF6B6B'],
      ['overexerted', '#9B59B6']
    ]);
    if (this.fatigueLevel > 0.7) return colors.get('overexerted') || '#9B59B6';
    return colors.get(this.heartRateZone) || '#4ECDC4';
  }
}

五、关键技术总结

5.1 Face AR 生理监测技术

技术点 方法 精度 应用场景
rPPG 心率估算 面部绿色通道变化分析 ±5 BPM 实时心率监测
眨眼频率检测 BlendShape eyeBlink 参数 95%+ 疲劳度评估
嘴部张开度 jawOpen 参数 90%+ 喘气/呼吸困难检测
眉毛下垂度 browDown 参数 85%+ 面部肌肉疲劳
综合疲劳模型 多特征加权融合 80%+ 运动过度预警

5.2 Body AR 姿态纠正技术

技术点 方法 精度 应用场景
关节角度计算 三维向量点积 ±3° 深蹲/俯卧撑标准度
动态时间规整 DTW 算法 90%+ 动作节奏匹配
偏差阈值判定 多层级阈值 85%+ 实时纠错反馈
骨骼关键点追踪 20+ 关键点 3D 位置 95%+ 全身姿态捕捉

5.3 沉浸光感与运动状态联动

运动状态 心率区间 标题栏光效 环境光色 悬浮面板提示
热身准备 <100 BPM 柔和冰蓝脉冲 冷色调 “准备活动”
燃脂有氧 100-140 BPM 稳定翠绿呼吸 自然色调 “保持节奏”
耐力强化 140-160 BPM 渐强琥珀闪烁 暖色调 “加油坚持”
极限冲刺 >160 BPM 急促赤红警示 红色调 “注意安全”
疲劳过度 表情检测 紫红呼吸渐变 紫色调 “强制休息”

六、安全与隐私设计

6.1 运动安全机制

  1. 心率上限保护:超过 220-年龄 的最大心率 90% 时自动暂停训练
  2. 疲劳强制休息:疲劳度 >0.7 时锁定训练 60 秒,期间仅允许拉伸动作
  3. 姿态危险预警:关节角度超过安全范围时立即语音警告
  4. 跌倒检测:Body AR 检测到异常低姿态时触发紧急联系

6.2 隐私保护设计

// 本地处理,不上传云端
private processFaceDataLocally(face: ARFace): void {
  // 所有面部数据仅在端侧 NPU 处理
  // 不保存原始图像,仅提取特征参数
  const features = this.extractFeatures(face);
  this.analyzeLocally(features);
  // 处理完成后立即释放内存
  face.release();
}

七、总结与展望

本文基于 HarmonyOS 6(API 23)的 Face AR & Body AR 能力,结合 沉浸光感 + 悬浮导航,完整实战了一款 PC 端"AR 健身私教"应用。核心创新点总结:

  1. 无穿戴式生理监测:通过 Face AR 的 rPPG 技术和 BlendShape 疲劳模型,实现零门槛心率与疲劳度监测
  2. 实时姿态纠正:利用 Body AR 的 20+ 骨骼关键点,实时比对标准动作库,偏差即时语音反馈
  3. 光感运动氛围:根据心率区间动态调整 UI 光效颜色,营造沉浸式运动氛围
  4. 悬浮数据面板:底部导航实时显示姿态评分、训练数据,支持手势切换模式,不遮挡运动画面

未来扩展方向

  • AI 个性化课程:根据用户历史数据,AI 生成个性化训练计划,动态调整难度
  • 多人远程 PK:通过鸿蒙分布式能力,实现异地好友同步训练、实时数据 PK
  • VR/AR 融合:结合 VR 头显,将私教画面投射到虚拟健身房,增强沉浸感
  • 医疗级精度:与医疗机构合作,提升 rPPG 精度至医疗级,用于康复训练监测

转载自:https://blog.csdn.net/u014727709/article/details/134064892
欢迎 👍点赞✍评论⭐收藏,欢迎指正

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐