HarmonyOS开发:运动类型识别(步行/跑步/骑行)

举报
Jack20 发表于 2026/06/21 16:00:58 2026/06/21
【摘要】 HarmonyOS开发:运动类型识别(步行/跑步/骑行)核心要点:本文深入讲解基于HarmonyOS多传感器融合的运动类型识别技术,涵盖步行、跑步、骑行三种核心运动模式的特征差异分析、基于决策树与模板匹配的分类算法实现,以及运动模式自动切换与数据统计方案。项目说明开发语言ArkTS核心API@ohos.sensor (加速度/陀螺仪)、@ohos.activityRecognition 一...

HarmonyOS开发:运动类型识别(步行/跑步/骑行)

核心要点:本文深入讲解基于HarmonyOS多传感器融合的运动类型识别技术,涵盖步行、跑步、骑行三种核心运动模式的特征差异分析、基于决策树与模板匹配的分类算法实现,以及运动模式自动切换与数据统计方案。

项目 说明
开发语言 ArkTS
核心API @ohos.sensor (加速度/陀螺仪)、@ohos.activityRecognition

一、背景与动机

运动类型识别是运动健康应用的核心能力。不同的运动类型对应不同的能量消耗、心率区间和训练效果,准确识别运动类型是提供精准运动指导的前提。

当前运动APP普遍存在一个问题:用户需要手动选择运动模式。忘记切换模式会导致:

  • 步行时选择了跑步模式 → 卡路里消耗被高估
  • 跑步时选择了骑行模式 → 配速和距离计算完全错误
  • 骑行时选择了步行模式 → 速度和心率区间不匹配

自动运动类型识别可以彻底解决这个问题——应用根据传感器数据自动判断当前运动类型,无需用户干预。HarmonyOS的多传感器框架为这一功能提供了坚实的数据基础。

三种核心运动模式的传感器特征差异

特征 步行 跑步 骑行
步频/踏频 80-120步/分 150-200步/分 60-100转/分
加速度振幅 0.5-1.5 m/s² 2.0-5.0 m/s² 0.2-0.8 m/s²
主频率 1.3-2.0 Hz 2.5-3.5 Hz 1.0-1.7 Hz
陀螺仪角速度 低(身体摆动) 中(手臂摆动) 高(转向)
垂直冲击 中等(每步一次) 强烈(每步一次) 弱(坐姿无冲击)
加速度对称性 高(左右交替) 高(左右交替) 低(单侧发力)

二、核心原理

2.1 运动类型识别方法论

运动类型识别的主流方法分为三类:

运动类型识别方法
├── 基于规则(Rule-Based)
│   └── 决策树分类器:根据特征阈值逐层判定
│       优点:简单高效、可解释性强
│       缺点:阈值固定、泛化能力弱
│
├── 基于模板匹配(Template Matching)
│   └── DTW动态时间规整:与标准运动模板比较相似度
│       优点:对时序变化鲁棒
│       缺点:计算量大、模板覆盖度有限
│
└── 基于机器学习(ML-Based)
    └── SVM/随机森林/神经网络:数据驱动分类
        优点:精度高、泛化能力强
        缺点:需要训练数据、端侧部署复杂

本文采用决策树+模板匹配混合方案:决策树快速粗分类,模板匹配精细判定,兼顾效率与精度。

2.2 特征工程

从滑动窗口(2秒@50Hz=100个采样点)中提取以下特征:

时域特征

  • 信号能量(Energy):E = (1/N) × Σ|a(i)|²
  • 均值(Mean):μ = (1/N) × Σa(i)
  • 标准差(StdDev):σ = √[(1/N) × Σ(a(i)-μ)²]
  • 过零率(ZCR):信号穿越均值的次数
  • 峰值个数(Peak Count):局部极大值个数
  • 偏度(Skewness):信号分布的不对称程度
  • 峰度(Kurtosis):信号分布的尖锐程度

频域特征

  • 主频率(Dominant Frequency):FFT最大分量
  • 频谱能量比(Spectral Ratio):低频/高频能量比
  • 谱熵(Spectral Entropy):频谱的复杂度

跨轴特征

  • 轴间相关性:X-Y、X-Z、Y-Z的相关系数
  • 合加速度特征:三轴合成后的能量和频率

2.3 运动类型识别算法流程

flowchart TD
    A[三轴加速度+陀螺仪数据] --> B[滑动窗口分段<br/>窗口2/步长0.5]
    B --> C[特征提取<br/>时域+频域+跨轴]
    C --> D{决策树粗分类}
    
    D -->|垂直冲击强 & 主频1.3-2Hz| E[步行候选]
    D -->|垂直冲击强 & 主频2.5-3.5Hz| F[跑步候选]
    D -->|垂直冲击弱 & 陀螺仪能量高| G[骑行候选]
    D -->|其他| H[未知运动]
    
    E --> I{模板匹配精判}
    F --> I
    G --> I
    
    I -->|DTW距离<阈值| J[确认运动类型]
    I -->|DTW距离≥阈值| K[保持上一类型]
    
    J --> L[运动参数自适应调整]
    L --> M[步频/配速/卡路里计算]
    
    classDef dataStyle fill:#1a1a2e,stroke:#e94560,color:#fff,stroke-width:2px
    classDef processStyle fill:#16213e,stroke:#0f3460,color:#e0e0e0,stroke-width:2px
    classDef decisionStyle fill:#0f3460,stroke:#533483,color:#fff,stroke-width:2px
    classDef candidateStyle fill:#533483,stroke:#e94560,color:#fff,stroke-width:2px
    classDef outputStyle fill:#e94560,stroke:#ff6b6b,color:#fff,stroke-width:2px
    
    class A,B,C processStyle
    class D decisionStyle
    class E,F,G,H candidateStyle
    class I decisionStyle
    class J,K,L,M outputStyle

三、代码实战

3.1 运动特征提取器

实现多维度特征提取,为分类器提供输入:

// MotionFeatureExtractor.ets — 运动特征提取器
import sensor from '@ohos.sensor';

// 运动特征向量
export interface MotionFeatureVector {
  // 时域特征
  energy: number;              // 信号能量
  mean: number;                // 均值
  stdDev: number;              // 标准差
  zeroCrossingRate: number;    // 过零率
  peakCount: number;           // 峰值个数
  skewness: number;            // 偏度
  kurtosis: number;            // 峰度
  
  // 频域特征
  dominantFreq: number;        // 主频率
  spectralRatio: number;       // 频谱能量比
  spectralEntropy: number;     // 谱熵
  
  // 跨轴特征
  xyCorrelation: number;       // X-Y轴相关性
  xzCorrelation: number;       // X-Z轴相关性
  yzCorrelation: number;       // Y-Z轴相关性
  
  // 陀螺仪特征
  gyroEnergy: number;          // 陀螺仪能量
  gyroPeakCount: number;       // 陀螺仪峰值数
  
  // 垂直冲击特征
  verticalImpact: number;      // 垂直冲击强度
  impactRegularity: number;    // 冲击规律性
}

// 特征提取配置
const FEATURE_CONFIG = {
  windowSize: 100,        // 2秒@50Hz
  stepSize: 25,           // 0.5秒步长
  sampleRate: 50,         // 采样率
  fftSize: 128,           // FFT点数(2的幂次)
};

@ObservedV2
export class MotionFeatureExtractor {
  // 三轴加速度缓冲区
  private xBuffer: number[] = [];
  private yBuffer: number[] = [];
  private zBuffer: number[] = [];
  
  // 陀螺仪缓冲区
  private gyroXBuffer: number[] = [];
  private gyroYBuffer: number[] = [];
  private gyroZBuffer: number[] = [];

  /**
   * 添加加速度数据到缓冲区
   */
  addAccelData(x: number, y: number, z: number): void {
    this.xBuffer.push(x);
    this.yBuffer.push(y);
    this.zBuffer.push(z);

    // 保持窗口大小
    if (this.xBuffer.length > FEATURE_CONFIG.windowSize) {
      this.xBuffer.shift();
      this.yBuffer.shift();
      this.zBuffer.shift();
    }
  }

  /**
   * 添加陀螺仪数据到缓冲区
   */
  addGyroData(x: number, y: number, z: number): void {
    this.gyroXBuffer.push(x);
    this.gyroYBuffer.push(y);
    this.gyroZBuffer.push(z);

    if (this.gyroXBuffer.length > FEATURE_CONFIG.windowSize) {
      this.gyroXBuffer.shift();
      this.gyroYBuffer.shift();
      this.gyroZBuffer.shift();
    }
  }

  /**
   * 检查是否有足够数据
   */
  isReady(): boolean {
    return this.xBuffer.length >= FEATURE_CONFIG.windowSize;
  }

  /**
   * 提取完整特征向量
   */
  extractFeatures(): MotionFeatureVector | null {
    if (!this.isReady()) return null;

    // 计算合加速度
    const magBuffer = this.xBuffer.map((x, i) =>
      Math.sqrt(x * x + this.yBuffer[i] * this.yBuffer[i] + this.zBuffer[i] * this.zBuffer[i])
    );

    return {
      // 时域特征
      energy: this.calcEnergy(magBuffer),
      mean: this.calcMean(magBuffer),
      stdDev: this.calcStdDev(magBuffer),
      zeroCrossingRate: this.calcZeroCrossingRate(magBuffer),
      peakCount: this.calcPeakCount(magBuffer),
      skewness: this.calcSkewness(magBuffer),
      kurtosis: this.calcKurtosis(magBuffer),

      // 频域特征
      dominantFreq: this.calcDominantFreq(magBuffer),
      spectralRatio: this.calcSpectralRatio(magBuffer),
      spectralEntropy: this.calcSpectralEntropy(magBuffer),

      // 跨轴特征
      xyCorrelation: this.calcCorrelation(this.xBuffer, this.yBuffer),
      xzCorrelation: this.calcCorrelation(this.xBuffer, this.zBuffer),
      yzCorrelation: this.calcCorrelation(this.yBuffer, this.zBuffer),

      // 陀螺仪特征
      gyroEnergy: this.calcGyroEnergy(),
      gyroPeakCount: this.calcGyroPeakCount(),

      // 垂直冲击特征
      verticalImpact: this.calcVerticalImpact(),
      impactRegularity: this.calcImpactRegularity(),
    };
  }

  // ===== 特征计算方法 =====

  /** 信号能量 */
  private calcEnergy(data: number[]): number {
    return data.reduce((sum, val) => sum + val * val, 0) / data.length;
  }

  /** 均值 */
  private calcMean(data: number[]): number {
    return data.reduce((sum, val) => sum + val, 0) / data.length;
  }

  /** 标准差 */
  private calcStdDev(data: number[]): number {
    const mean = this.calcMean(data);
    const variance = data.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / data.length;
    return Math.sqrt(variance);
  }

  /** 过零率 */
  private calcZeroCrossingRate(data: number[]): number {
    const mean = this.calcMean(data);
    let crossings = 0;
    for (let i = 1; i < data.length; i++) {
      if ((data[i - 1] - mean) * (data[i] - mean) < 0) {
        crossings++;
      }
    }
    return crossings / (data.length / FEATURE_CONFIG.sampleRate); // 每秒过零次数
  }

  /** 峰值个数 */
  private calcPeakCount(data: number[]): number {
    let peaks = 0;
    for (let i = 1; i < data.length - 1; i++) {
      if (data[i] > data[i - 1] && data[i] > data[i + 1]) {
        peaks++;
      }
    }
    return peaks;
  }

  /** 偏度 */
  private calcSkewness(data: number[]): number {
    const mean = this.calcMean(data);
    const stdDev = this.calcStdDev(data);
    if (stdDev === 0) return 0;
    const n = data.length;
    const m3 = data.reduce((sum, val) => sum + Math.pow((val - mean) / stdDev, 3), 0) / n;
    return m3;
  }

  /** 峰度 */
  private calcKurtosis(data: number[]): number {
    const mean = this.calcMean(data);
    const stdDev = this.calcStdDev(data);
    if (stdDev === 0) return 0;
    const n = data.length;
    const m4 = data.reduce((sum, val) => sum + Math.pow((val - mean) / stdDev, 4), 0) / n;
    return m4 - 3; // 超额峰度
  }

  /** 主频率(自相关法) */
  private calcDominantFreq(data: number[]): number {
    const minLag = Math.floor(FEATURE_CONFIG.sampleRate / 5);   // 对应5Hz
    const maxLag = Math.floor(FEATURE_CONFIG.sampleRate / 0.5); // 对应0.5Hz

    let maxCorr = -Infinity;
    let bestLag = minLag;

    for (let lag = minLag; lag <= Math.min(maxLag, data.length - 1); lag++) {
      let corr = 0;
      let count = 0;
      for (let i = 0; i < data.length - lag; i++) {
        corr += data[i] * data[i + lag];
        count++;
      }
      corr = count > 0 ? corr / count : 0;
      if (corr > maxCorr) {
        maxCorr = corr;
        bestLag = lag;
      }
    }

    return bestLag > 0 ? FEATURE_CONFIG.sampleRate / bestLag : 0;
  }

  /** 频谱能量比(低频/高频) */
  private calcSpectralRatio(data: number[]): number {
    // 简化实现:将数据分为前后两半,比较能量
    const half = Math.floor(data.length / 2);
    const lowFreqEnergy = data.slice(0, half).reduce((sum, val) => sum + val * val, 0);
    const highFreqEnergy = data.slice(half).reduce((sum, val) => sum + val * val, 0);
    return highFreqEnergy > 0 ? lowFreqEnergy / highFreqEnergy : 0;
  }

  /** 谱熵 */
  private calcSpectralEntropy(data: number[]): number {
    // 简化实现:基于信号分布的均匀性
    const mean = this.calcMean(data);
    const stdDev = this.calcStdDev(data);
    if (stdDev === 0) return 0;
    
    // 使用直方图近似
    const bins = 10;
    const histogram = new Array(bins).fill(0);
    const min = Math.min(...data);
    const max = Math.max(...data);
    const range = max - min || 1;

    for (const val of data) {
      const bin = Math.min(Math.floor((val - min) / range * bins), bins - 1);
      histogram[bin]++;
    }

    // 计算熵
    let entropy = 0;
    for (const count of histogram) {
      if (count > 0) {
        const p = count / data.length;
        entropy -= p * Math.log2(p);
      }
    }

    return entropy / Math.log2(bins); // 归一化到0-1
  }

  /** 相关系数 */
  private calcCorrelation(x: number[], y: number[]): number {
    const n = Math.min(x.length, y.length);
    if (n === 0) return 0;

    const meanX = x.slice(0, n).reduce((a, b) => a + b, 0) / n;
    const meanY = y.slice(0, n).reduce((a, b) => a + b, 0) / n;

    let covXY = 0;
    let varX = 0;
    let varY = 0;

    for (let i = 0; i < n; i++) {
      const dx = x[i] - meanX;
      const dy = y[i] - meanY;
      covXY += dx * dy;
      varX += dx * dx;
      varY += dy * dy;
    }

    const denom = Math.sqrt(varX * varY);
    return denom > 0 ? covXY / denom : 0;
  }

  /** 陀螺仪能量 */
  private calcGyroEnergy(): number {
    if (this.gyroXBuffer.length === 0) return 0;
    const n = this.gyroXBuffer.length;
    let energy = 0;
    for (let i = 0; i < n; i++) {
      energy += this.gyroXBuffer[i] ** 2 + this.gyroYBuffer[i] ** 2 + this.gyroZBuffer[i] ** 2;
    }
    return energy / n;
  }

  /** 陀螺仪峰值数 */
  private calcGyroPeakCount(): number {
    if (this.gyroXBuffer.length === 0) return 0;
    const magGyro = this.gyroXBuffer.map((x, i) =>
      Math.sqrt(x * x + this.gyroYBuffer[i] ** 2 + this.gyroZBuffer[i] ** 2)
    );
    return this.calcPeakCount(magGyro);
  }

  /** 垂直冲击强度(Z轴加速度峰值均值) */
  private calcVerticalImpact(): number {
    const peaks: number[] = [];
    for (let i = 1; i < this.zBuffer.length - 1; i++) {
      if (this.zBuffer[i] > this.zBuffer[i - 1] && this.zBuffer[i] > this.zBuffer[i + 1]) {
        peaks.push(Math.abs(this.zBuffer[i] - 9.81));
      }
    }
    return peaks.length > 0 ? peaks.reduce((a, b) => a + b, 0) / peaks.length : 0;
  }

  /** 冲击规律性(冲击峰值的变异系数) */
  private calcImpactRegularity(): number {
    const peaks: number[] = [];
    for (let i = 1; i < this.zBuffer.length - 1; i++) {
      if (this.zBuffer[i] > this.zBuffer[i - 1] && this.zBuffer[i] > this.zBuffer[i + 1]) {
        peaks.push(Math.abs(this.zBuffer[i] - 9.81));
      }
    }
    if (peaks.length < 2) return 0;
    const mean = peaks.reduce((a, b) => a + b, 0) / peaks.length;
    const stdDev = Math.sqrt(peaks.reduce((s, v) => s + (v - mean) ** 2, 0) / peaks.length);
    return mean > 0 ? 1 - Math.min(stdDev / mean, 1) : 0; // 越接近1越规律
  }

  /**
   * 清空缓冲区
   */
  clear(): void {
    this.xBuffer = [];
    this.yBuffer = [];
    this.zBuffer = [];
    this.gyroXBuffer = [];
    this.gyroYBuffer = [];
    this.gyroZBuffer = [];
  }
}

3.2 运动类型分类器

实现决策树+模板匹配的混合分类器:

// ActivityTypeClassifier.ets — 运动类型分类器

// 运动类型枚举
export enum ActivityType {
  WALKING = 'WALKING',     // 步行
  RUNNING = 'RUNNING',     // 跑步
  CYCLING = 'CYCLING',     // 骑行
  UNKNOWN = 'UNKNOWN',     // 未知
}

// 运动类型识别结果
export interface ActivityTypeResult {
  type: ActivityType;
  confidence: number;       // 置信度0-1
  subType?: string;         // 子类型(如快走、慢跑)
  features: {               // 关键特征值(用于UI展示)
    cadence: number;        // 步频/踏频
    speed: number;          // 估算速度
    intensity: number;      // 运动强度0-1
  };
}

// 决策树阈值配置
const DECISION_TREE = {
  // 垂直冲击阈值
  walkImpactMin: 0.5,
  walkImpactMax: 3.0,
  runImpactMin: 2.5,
  cycleImpactMax: 0.8,
  
  // 主频率阈值
  walkFreqMin: 1.2,
  walkFreqMax: 2.2,
  runFreqMin: 2.3,
  runFreqMax: 4.0,
  cycleFreqMin: 0.8,
  cycleFreqMax: 1.8,
  
  // 信号能量阈值
  walkEnergyMin: 10,
  walkEnergyMax: 60,
  runEnergyMin: 40,
  cycleEnergyMax: 20,
  
  // 陀螺仪能量阈值
  cycleGyroMin: 0.5,
  
  // 冲击规律性阈值
  walkRegularMin: 0.6,
  runRegularMin: 0.7,
};

// 运动模板(标准化的特征向量)
interface MotionTemplate {
  type: ActivityType;
  features: Partial<MotionFeatureVector>;
  weight: number;  // 特征权重
}

// 预定义运动模板
const MOTION_TEMPLATES: MotionTemplate[] = [
  {
    type: ActivityType.WALKING,
    features: {
      dominantFreq: 1.6, energy: 30, verticalImpact: 1.5,
      impactRegularity: 0.8, gyroEnergy: 0.3, zeroCrossingRate: 3.2,
    },
    weight: 1.0,
  },
  {
    type: ActivityType.RUNNING,
    features: {
      dominantFreq: 3.0, energy: 80, verticalImpact: 4.0,
      impactRegularity: 0.85, gyroEnergy: 0.8, zeroCrossingRate: 6.0,
    },
    weight: 1.0,
  },
  {
    type: ActivityType.CYCLING,
    features: {
      dominantFreq: 1.3, energy: 12, verticalImpact: 0.3,
      impactRegularity: 0.5, gyroEnergy: 1.5, zeroCrossingRate: 2.6,
    },
    weight: 1.0,
  },
];

@ObservedV2
export class ActivityTypeClassifier {
  // 当前识别结果
  @Trace currentType: ActivityType = ActivityType.UNKNOWN;
  @Trace currentConfidence: number = 0;

  // 历史结果(用于平滑)
  private recentResults: ActivityType[] = [];
  private readonly smoothingWindow: number = 5;

  // 运动参数
  private strideLengthMap: Record<string, number> = {
    [ActivityType.WALKING]: 0.7,
    [ActivityType.RUNNING]: 1.0,
    [ActivityType.CYCLING]: 2.5,  // 踏板一周行进距离(简化)
  };

  /**
   * 分类运动类型(核心方法)
   */
  classify(features: MotionFeatureVector): ActivityTypeResult {
    // 第一步:决策树粗分类
    const candidateType = this.decisionTreeClassify(features);

    // 第二步:模板匹配精判
    const refinedResult = this.templateMatchClassify(candidateType, features);

    // 第三步:时序平滑
    const smoothedType = this.temporalSmoothing(refinedResult.type);

    // 计算运动参数
    const motionParams = this.calculateMotionParams(smoothedType, features);

    // 更新状态
    this.currentType = smoothedType;
    this.currentConfidence = refinedResult.confidence;

    return {
      type: smoothedType,
      confidence: refinedResult.confidence,
      subType: this.getSubType(smoothedType, features),
      features: motionParams,
    };
  }

  /**
   * 决策树粗分类
   */
  private decisionTreeClassify(features: MotionFeatureVector): ActivityType {
    const { verticalImpact, dominantFreq, energy, gyroEnergy, impactRegularity } = features;

    // 规则1:骑行判定(低冲击+高陀螺仪能量)
    if (verticalImpact < DECISION_TREE.cycleImpactMax &&
        gyroEnergy > DECISION_TREE.cycleGyroMin &&
        dominantFreq >= DECISION_TREE.cycleFreqMin &&
        dominantFreq <= DECISION_TREE.cycleFreqMax) {
      return ActivityType.CYCLING;
    }

    // 规则2:跑步判定(高冲击+高频率+高能量)
    if (verticalImpact >= DECISION_TREE.runImpactMin &&
        dominantFreq >= DECISION_TREE.runFreqMin &&
        dominantFreq <= DECISION_TREE.runFreqMax &&
        energy >= DECISION_TREE.runEnergyMin &&
        impactRegularity >= DECISION_TREE.runRegularMin) {
      return ActivityType.RUNNING;
    }

    // 规则3:步行判定(中等冲击+中频率)
    if (verticalImpact >= DECISION_TREE.walkImpactMin &&
        verticalImpact <= DECISION_TREE.walkImpactMax &&
        dominantFreq >= DECISION_TREE.walkFreqMin &&
        dominantFreq <= DECISION_TREE.walkFreqMax &&
        impactRegularity >= DECISION_TREE.walkRegularMin) {
      return ActivityType.WALKING;
    }

    // 规则4:低冲击+低频率 → 可能是骑行或慢走
    if (verticalImpact < DECISION_TREE.walkImpactMin) {
      if (gyroEnergy > 0.3) {
        return ActivityType.CYCLING;
      }
      return ActivityType.WALKING; // 慢走
    }

    // 规则5:高冲击+高能量 → 可能是跑步
    if (energy > DECISION_TREE.runEnergyMin) {
      return ActivityType.RUNNING;
    }

    return ActivityType.UNKNOWN;
  }

  /**
   * 模板匹配精判(加权欧氏距离)
   */
  private templateMatchClassify(
    candidateType: ActivityType,
    features: MotionFeatureVector
  ): { type: ActivityType; confidence: number } {
    // 计算与每个模板的距离
    const distances: { type: ActivityType; distance: number }[] = [];

    for (const template of MOTION_TEMPLATES) {
      let dist = 0;
      let featureCount = 0;

      for (const [key, templateValue] of Object.entries(template.features)) {
        const featureValue = features[key as keyof MotionFeatureVector];
        if (featureValue !== undefined && templateValue !== undefined) {
          // 归一化距离
          const normalizedDiff = (featureValue - templateValue) / (Math.abs(templateValue) + 0.001);
          dist += normalizedDiff * normalizedDiff * template.weight;
          featureCount++;
        }
      }

      distances.push({
        type: template.type,
        distance: featureCount > 0 ? Math.sqrt(dist / featureCount) : Infinity,
      });
    }

    // 按距离排序
    distances.sort((a, b) => a.distance - b.distance);

    // 最小距离对应的类型
    const bestMatch = distances[0];
    const secondBest = distances[1];

    // 置信度计算(基于距离比)
    let confidence = 0.5;
    if (bestMatch.distance < 0.5) {
      confidence = 0.9;
    } else if (bestMatch.distance < 1.0) {
      confidence = 0.7;
    } else if (bestMatch.distance < 2.0) {
      confidence = 0.5;
    }

    // 如果候选类型与最佳匹配一致,增加置信度
    if (bestMatch.type === candidateType) {
      confidence = Math.min(confidence + 0.1, 1.0);
    }

    // 如果最佳和次佳距离接近,降低置信度
    if (secondBest && secondBest.distance / bestMatch.distance < 1.5) {
      confidence *= 0.7;
    }

    return { type: bestMatch.type, confidence };
  }

  /**
   * 时序平滑(多数投票)
   */
  private temporalSmoothing(newType: ActivityType): ActivityType {
    this.recentResults.push(newType);
    if (this.recentResults.length > this.smoothingWindow) {
      this.recentResults.shift();
    }

    // 多数投票
    const voteCount: Record<string, number> = {};
    for (const type of this.recentResults) {
      voteCount[type] = (voteCount[type] || 0) + 1;
    }

    let maxVotes = 0;
    let result = newType;
    for (const [type, count] of Object.entries(voteCount)) {
      if (count > maxVotes) {
        maxVotes = count;
        result = type as ActivityType;
      }
    }

    return result;
  }

  /**
   * 计算运动参数
   */
  private calculateMotionParams(
    type: ActivityType,
    features: MotionFeatureVector
  ): { cadence: number; speed: number; intensity: number } {
    // 步频/踏频(基于主频率换算)
    const cadence = Math.round(features.dominantFreq * 60);

    // 估算速度
    let speed = 0;
    switch (type) {
      case ActivityType.WALKING:
        speed = cadence / 60 * (this.strideLengthMap[ActivityType.WALKING] || 0.7);
        break;
      case ActivityType.RUNNING:
        speed = cadence / 60 * (this.strideLengthMap[ActivityType.RUNNING] || 1.0);
        break;
      case ActivityType.CYCLING:
        speed = cadence / 60 * (this.strideLengthMap[ActivityType.CYCLING] || 2.5);
        break;
    }

    // 运动强度(基于能量归一化)
    const maxEnergy = type === ActivityType.RUNNING ? 150 :
                      type === ActivityType.WALKING ? 60 : 30;
    const intensity = Math.min(features.energy / maxEnergy, 1.0);

    return {
      cadence,
      speed: Math.round(speed * 3.6 * 10) / 10, // m/s转km/h
      intensity: Math.round(intensity * 100) / 100,
    };
  }

  /**
   * 获取运动子类型
   */
  private getSubType(type: ActivityType, features: MotionFeatureVector): string {
    switch (type) {
      case ActivityType.WALKING:
        if (features.dominantFreq < 1.5) return '慢走';
        if (features.dominantFreq > 1.8) return '快走';
        return '正常步行';
      case ActivityType.RUNNING:
        if (features.dominantFreq < 2.8) return '慢跑';
        if (features.dominantFreq > 3.3) return '快跑';
        return '正常跑步';
      case ActivityType.CYCLING:
        if (features.dominantFreq < 1.2) return '休闲骑行';
        if (features.dominantFreq > 1.5) return '竞速骑行';
        return '正常骑行';
      default:
        return '未知';
    }
  }

  /**
   * 重置分类器
   */
  reset(): void {
    this.currentType = ActivityType.UNKNOWN;
    this.currentConfidence = 0;
    this.recentResults = [];
  }
}

3.3 运动类型识别完整界面

实现运动类型自动识别界面,包含实时识别、运动参数展示和运动统计:

// ActivityRecognitionPage.ets — 运动类型识别完整界面
import { MultiSensorManager, SensorFrame } from './MultiSensorManager';
import { MotionFeatureExtractor, MotionFeatureVector } from './MotionFeatureExtractor';
import { ActivityTypeClassifier, ActivityType, ActivityTypeResult } from './ActivityTypeClassifier';

// 运动类型UI配置
const ACTIVITY_UI_CONFIG: Record<string, { icon: string; label: string; color: string; bgColor: string }> = {
  [ActivityType.WALKING]: { icon: '🚶', label: '步行', color: '#4FC3F7', bgColor: 'rgba(79,195,247,0.15)' },
  [ActivityType.RUNNING]: { icon: '🏃', label: '跑步', color: '#00E676', bgColor: 'rgba(0,230,118,0.15)' },
  [ActivityType.CYCLING]: { icon: '🚴', label: '骑行', color: '#FFB74D', bgColor: 'rgba(255,183,77,0.15)' },
  [ActivityType.UNKNOWN]: { icon: '❓', label: '检测中', color: '#78909C', bgColor: 'rgba(120,144,156,0.15)' },
};

// 运动统计记录
interface WorkoutRecord {
  type: ActivityType;
  startTime: number;
  duration: number;     // 秒
  distance: number;     // 米
  calories: number;     // 千卡
  avgCadence: number;   // 平均步频
  avgSpeed: number;     // 平均速度
}

@Entry
@Component
struct ActivityRecognitionPage {
  // 管理器
  private sensorManager: MultiSensorManager = new MultiSensorManager();
  private featureExtractor: MotionFeatureExtractor = new MotionFeatureExtractor();
  private classifier: ActivityTypeClassifier = new ActivityTypeClassifier();

  // UI状态
  @State isDetecting: boolean = false;
  @State currentType: ActivityType = ActivityType.UNKNOWN;
  @State currentConfidence: number = 0;
  @State currentSubType: string = '';
  @State cadence: number = 0;
  @State speed: number = 0;
  @State intensity: number = 0;
  @State totalDistance: number = 0;
  @State totalCalories: number = 0;
  @State duration: string = '00:00:00';
  @State workoutHistory: WorkoutRecord[] = [];

  // 运动计时
  private startTime: number = 0;
  private timerId: number = -1;
  private frameCount: number = 0;

  build() {
    Navigation() {
      Scroll() {
        Column({ space: 20 }) {
          // 运动类型识别展示
          this.ActivityTypeCard()

          // 运动参数面板
          this.MotionParamsPanel()

          // 运动强度指示器
          this.IntensityIndicator()

          // 运动统计
          this.WorkoutStatsSection()

          // 控制按钮
          this.ControlSection()
        }
        .width('100%')
        .padding({ left: 20, right: 20, top: 16, bottom: 40 })
      }
      .scrollBar(BarState.Off)
    }
    .title('运动类型识别')
    .titleMode(NavigationTitleMode.Mini)
  }

  // ===== 运动类型识别展示 =====
  @Builder
  ActivityTypeCard() {
    Column({ space: 20 }) {
      // 大图标展示
      Stack() {
        // 背景光晕
        Circle()
          .width(160)
          .height(160)
          .fill(ACTIVITY_UI_CONFIG[this.currentType].bgColor)

        // 运动图标
        Text(ACTIVITY_UI_CONFIG[this.currentType].icon)
          .fontSize(72)
          .animation({ duration: 300 })
      }

      // 运动类型名称
      Column({ space: 4 }) {
        Text(ACTIVITY_UI_CONFIG[this.currentType].label)
          .fontSize(28)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Bold)

        if (this.currentSubType) {
          Text(this.currentSubType)
            .fontSize(14)
            .fontColor(ACTIVITY_UI_CONFIG[this.currentType].color)
        }
      }

      // 置信度
      Row({ space: 8 }) {
        Text('识别置信度')
          .fontSize(12)
          .fontColor('#78909C')

        Progress({ value: this.currentConfidence * 100, total: 100, type: ProgressType.Linear })
          .width(120)
          .height(6)
          .color(ACTIVITY_UI_CONFIG[this.currentType].color)
          .backgroundColor('rgba(255,255,255,0.1)')

        Text(`${Math.round(this.currentConfidence * 100)}%`)
          .fontSize(12)
          .fontColor(ACTIVITY_UI_CONFIG[this.currentType].color)
      }
    }
    .width('100%')
    .padding(24)
    .borderRadius(24)
    .backgroundColor('rgba(15, 52, 96, 0.6)')
    .backdropBlur(20)
    .alignItems(HorizontalAlign.Center)
  }

  // ===== 运动参数面板 =====
  @Builder
  MotionParamsPanel() {
    Column({ space: 12 }) {
      Text('📊 运动参数')
        .fontSize(16)
        .fontColor('#FFFFFF')
        .fontWeight(FontWeight.Bold)

      Grid() {
        GridItem() {
          this.ParamCard('步频/踏频', `${this.cadence}`, '步/分', '#4FC3F7')
        }
        GridItem() {
          this.ParamCard('速度', `${this.speed}`, 'km/h', '#00E676')
        }
        GridItem() {
          this.ParamCard('距离', `${(this.totalDistance / 1000).toFixed(2)}`, 'km', '#FFB74D')
        }
        GridItem() {
          this.ParamCard('卡路里', `${this.totalCalories.toFixed(1)}`, 'kcal', '#FF5252')
        }
      }
      .columnsTemplate('1fr 1fr')
      .rowsTemplate('1fr 1fr')
      .width('100%')
      .height(180)
      .columnsGap(10)
      .rowsGap(10)
    }
    .width('100%')
    .padding(16)
    .borderRadius(16)
    .backgroundColor('rgba(22, 33, 62, 0.8)')
  }

  @Builder
  ParamCard(label: string, value: string, unit: string, color: string) {
    Column({ space: 6 }) {
      Text(label)
        .fontSize(11)
        .fontColor('#78909C')

      Row({ space: 2 }) {
        Text(value)
          .fontSize(18)
          .fontColor(color)
          .fontWeight(FontWeight.Bold)
        Text(unit)
          .fontSize(9)
          .fontColor('#546E7A')
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .borderRadius(12)
    .backgroundColor('rgba(26, 26, 46, 0.8)')
  }

  // ===== 运动强度指示器 =====
  @Builder
  IntensityIndicator() {
    Column({ space: 12 }) {
      Text('💪 运动强度')
        .fontSize(16)
        .fontColor('#FFFFFF')
        .fontWeight(FontWeight.Bold)

      // 强度条
      Row({ space: 4 }) {
        ForEach([
          { label: '低', threshold: 0.3, color: '#4FC3F7' },
          { label: '中', threshold: 0.6, color: '#FFB74D' },
          { label: '高', threshold: 0.8, color: '#FF5252' },
          { label: '极高', threshold: 1.0, color: '#e94560' },
        ], (item: { label: string; threshold: number; color: string }) => {
          Column() {
            Column()
              .width('100%')
              .height(`${this.intensity >= item.threshold ? 100 : this.intensity / item.threshold * 50}%`)
              .backgroundColor(this.intensity >= item.threshold ? item.color : `${item.color}33`)
              .borderRadius({ topLeft: 4, topRight: 4 })
          }
          .layoutWeight(1)
          .height(40)
          .justifyContent(FlexAlign.End)
        })
      }
      .width('100%')

      // 时长
      Row({ space: 8 }) {
        Text('⏱')
          .fontSize(16)
        Text(this.duration)
          .fontSize(20)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Bold)
          .fontFamily('HarmonyOS Sans')
      }
    }
    .width('100%')
    .padding(16)
    .borderRadius(16)
    .backgroundColor('rgba(22, 33, 62, 0.8)')
  }

  // ===== 运动统计 =====
  @Builder
  WorkoutStatsSection() {
    Column({ space: 12 }) {
      Text('📈 运动记录')
        .fontSize(16)
        .fontColor('#FFFFFF')
        .fontWeight(FontWeight.Bold)

      if (this.workoutHistory.length === 0) {
        Text('开始运动后将自动记录')
          .fontSize(13)
          .fontColor('#546E7A')
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding(20)
      } else {
        ForEach(this.workoutHistory.slice(-3).reverse(), (record: WorkoutRecord) => {
          Row({ space: 12 }) {
            Text(ACTIVITY_UI_CONFIG[record.type].icon)
              .fontSize(24)

            Column({ space: 2 }) {
              Text(ACTIVITY_UI_CONFIG[record.type].label)
                .fontSize(14)
                .fontColor('#FFFFFF')
              Text(`${(record.distance / 1000).toFixed(2)}km | ${record.calories.toFixed(0)}kcal`)
                .fontSize(11)
                .fontColor('#78909C')
            }
            .layoutWeight(1)

            Text(`${Math.floor(record.duration / 60)}${record.duration % 60}`)
              .fontSize(12)
              .fontColor('#546E7A')
          }
          .width('100%')
          .padding(12)
          .borderRadius(10)
          .backgroundColor('rgba(26, 26, 46, 0.8)')
        })
      }
    }
    .width('100%')
    .padding(16)
    .borderRadius(16)
    .backgroundColor('rgba(22, 33, 62, 0.8)')
  }

  // ===== 控制按钮 =====
  @Builder
  ControlSection() {
    Button(this.isDetecting ? '结束运动' : '开始运动')
      .width('100%')
      .height(52)
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .backgroundColor(this.isDetecting ? '#FF5252' : '#e94560')
      .borderRadius(26)
      .onClick(() => this.toggleDetection())
  }

  // ===== 业务逻辑 =====

  private toggleDetection(): void {
    if (this.isDetecting) {
      this.stopDetection();
    } else {
      this.startDetection();
    }
  }

  private startDetection(): void {
    this.isDetecting = true;
    this.startTime = Date.now();
    this.frameCount = 0;

    // 启动多传感器
    this.sensorManager.startSensors((frame: SensorFrame) => {
      // 添加数据到特征提取器
      this.featureExtractor.addAccelData(frame.accelX, frame.accelY, frame.accelZ);
      this.featureExtractor.addGyroData(frame.gyroX, frame.gyroY, frame.gyroZ);

      this.frameCount++;

      // 每25帧(0.5秒)提取一次特征
      if (this.frameCount % 25 === 0 && this.featureExtractor.isReady()) {
        const features = this.featureExtractor.extractFeatures();
        if (features) {
          const result = this.classifier.classify(features);
          this.updateUI(result);
        }
      }
    });

    // 启动计时器
    this.startTimer();
  }

  private stopDetection(): void {
    this.isDetecting = false;
    this.sensorManager.stopSensors();
    this.stopTimer();

    // 保存运动记录
    if (this.currentType !== ActivityType.UNKNOWN) {
      const record: WorkoutRecord = {
        type: this.currentType,
        startTime: this.startTime,
        duration: Math.floor((Date.now() - this.startTime) / 1000),
        distance: this.totalDistance,
        calories: this.totalCalories,
        avgCadence: this.cadence,
        avgSpeed: this.speed,
      };
      this.workoutHistory.push(record);
    }

    // 重置
    this.featureExtractor.clear();
    this.classifier.reset();
  }

  private updateUI(result: ActivityTypeResult): void {
    this.currentType = result.type;
    this.currentConfidence = result.confidence;
    this.currentSubType = result.subType ?? '';
    this.cadence = result.features.cadence;
    this.speed = result.features.speed;
    this.intensity = result.features.intensity;

    // 累计距离和卡路里
    const speedMs = result.features.speed / 3.6; // km/h转m/s
    this.totalDistance += speedMs * 0.5; // 0.5秒步长
    const metMap: Record<string, number> = {
      [ActivityType.WALKING]: 3.5,
      [ActivityType.RUNNING]: 8.0,
      [ActivityType.CYCLING]: 6.0,
    };
    const met = metMap[result.type] || 3.0;
    this.totalCalories += met * 70 * (0.5 / 3600); // 70kg体重,0.5秒
  }

  private startTimer(): void {
    this.timerId = setInterval(() => {
      const elapsed = Date.now() - this.startTime;
      const h = Math.floor(elapsed / 3600000);
      const m = Math.floor((elapsed % 3600000) / 60000);
      const s = Math.floor((elapsed % 60000) / 1000);
      this.duration = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
    }, 1000) as number;
  }

  private stopTimer(): void {
    if (this.timerId !== -1) {
      clearInterval(this.timerId);
      this.timerId = -1;
    }
  }

  aboutToDisappear(): void {
    this.sensorManager.stopSensors();
    this.stopTimer();
  }
}

四、踩坑与注意事项

4.1 骑行识别的特殊挑战

骑行识别相比步行和跑步更具挑战性:

挑战 原因 解决方案
手机位置不确定 可能在口袋、车把、背包 结合陀螺仪旋转特征辅助判断
加速度信号弱 坐姿骑行,身体几乎无垂直运动 使用踏频频率而非步频
与乘车混淆 公交/地铁也有低冲击+高陀螺仪 结合GPS速度区分
室内骑行 静态骑行台无位移 依赖踏频和心率判断

4.2 特征提取的计算性能

16维特征向量在50Hz采样率下的计算开销:

特征 计算复杂度 优化建议
能量/均值/标准差 O(N) 增量计算,避免全窗口遍历
过零率 O(N) 维护状态变量,增量更新
峰值检测 O(N) 维护单调队列,O(1)查询
主频率(自相关) O(N²) 限制搜索范围,降低到O(N×L)
相关性 O(N) 增量协方差计算

建议:特征提取间隔设为0.5秒(25帧),而非每帧都提取,可大幅降低CPU占用。

4.3 运动类型切换的滞后问题

时序平滑(多数投票)会导致运动类型切换存在延迟:

  • 5帧平滑窗口:延迟约2.5秒
  • 3帧平滑窗口:延迟约1.5秒
  • 无平滑:延迟0秒但抖动严重

推荐策略

  • 从低强度切换到高强度(步行→跑步):使用短窗口(3帧),快速响应
  • 从高强度切换到低强度(跑步→步行):使用长窗口(5帧),避免误判

4.4 不同佩戴位置的影响

佩戴位置 步行特征 跑步特征 骑行特征
手持 清晰,振幅大 清晰,振幅大 信号弱
裤袋 清晰,Z轴明显 清晰,冲击强 微弱踏频
上臂 中等,有摆臂 强烈,摆臂明显 微弱
手腕 有手臂摆动干扰 强烈 有转向信号

建议:在特征提取时增加佩戴位置检测,或让用户选择佩戴位置以调整算法参数。

4.5 模板匹配的局限性

预定义模板无法覆盖所有运动变体:

  • 不同人的步态差异大(身高、体重、步幅)
  • 同一人在不同疲劳状态下运动特征会变化
  • 室外和室内的运动特征可能不同(跑步机vs户外跑)

改进方向

  1. 个性化模板:用户首次使用时采集标准运动数据生成个人模板
  2. 在线学习:根据用户确认的运动类型持续更新模板
  3. 多模板融合:每种运动类型维护多个子模板

五、HarmonyOS 6适配

5.1 系统级运动类型识别

HarmonyOS 6提供系统级运动类型识别服务:

// HarmonyOS 6:系统级运动类型识别
import activityRecognition from '@ohos.activityRecognition';

// 订阅运动类型变更
activityRecognition.on('activityTypeChange', {
  minInterval: 3000,
  activities: [
    activityRecognition.ActivityType.WALKING,
    activityRecognition.ActivityType.RUNNING,
    activityRecognition.ActivityType.ON_BICYCLE,
  ],
}, (result: activityRecognition.ActivityTypeResult) => {
  console.info(`运动类型: ${result.type}`);
  console.info(`置信度: ${result.confidence}`);
  console.info(`切换时间: ${result.transitionTime}`);
});

5.2 端侧AI运动分类

HarmonyOS 6的NNAPI支持部署轻量级运动分类模型:

// HarmonyOS 6:端侧AI运动分类
import mindSpore from '@ohos.ai.mindSpore';

// 加载运动分类模型(1D-CNN,输入100×6,输出3类)
const model = await mindSpore.loadModelFromFile('models/activity_type.ms');

// 构建输入张量:100帧 × 6通道(3轴加速度+3轴陀螺仪)
const inputBuffer = new Float32Array(100 * 6);
for (let i = 0; i < 100; i++) {
  inputBuffer[i * 6 + 0] = accelXBuffer[i];
  inputBuffer[i * 6 + 1] = accelYBuffer[i];
  inputBuffer[i * 6 + 2] = accelZBuffer[i];
  inputBuffer[i * 6 + 3] = gyroXBuffer[i];
  inputBuffer[i * 6 + 4] = gyroYBuffer[i];
  inputBuffer[i * 6 + 5] = gyroZBuffer[i];
}

const inputTensor = mindSpore.createTensor(inputBuffer, [1, 100, 6]);
const output = model.predict([inputTensor]);
const probabilities = output[0].getData();

// 获取最可能的运动类型
const typeIndex = probabilities.indexOf(Math.max(...probabilities));
const types = [ActivityType.WALKING, ActivityType.RUNNING, ActivityType.CYCLING];
const detectedType = types[typeIndex];

5.3 自适应特征权重

HarmonyOS 6支持根据用户历史数据动态调整特征权重:

// HarmonyOS 6:自适应特征权重
import featureWeight from '@ohos.health.featureWeight';

// 根据用户历史运动数据更新特征权重
const updatedWeights = await featureWeight.updateWeights({
  userId: 'user_123',
  activityTypes: [ActivityType.WALKING, ActivityType.RUNNING, ActivityType.CYCLING],
  recentSamples: 100, // 最近100次运动样本
});

// 应用更新后的权重到分类器
this.classifier.updateFeatureWeights(updatedWeights);

六、总结

本文完整实现了基于HarmonyOS多传感器融合的运动类型识别系统,核心要点如下:

模块 关键技术 要点
特征提取 16维时频域特征 能量/频率/相关性/冲击多维特征
决策树分类 阈值分层判定 冲击→频率→能量→陀螺仪逐层过滤
模板匹配 加权欧氏距离 多模板对比,距离比计算置信度
时序平滑 多数投票 5帧窗口防抖,避免频繁切换
运动参数 步频/速度/强度 基于主频率和能量估算

最佳实践建议

  1. 优先使用系统级活动识别API,精度和功耗都优于自研方案
  2. 自研方案适合特殊场景,如特定运动类型识别、个性化算法调优
  3. 决策树+模板匹配是实用方案,兼顾效率和精度
  4. 特征提取间隔0.5秒,平衡实时性和计算开销
  5. 运动类型切换需要平滑,但不同方向切换应使用不同窗口大小
  6. 个性化模板能显著提升精度,建议首次使用时采集标准运动数据

下一篇文章将深入讲解健康数据采集与华为运动健康对接,实现运动数据的持久化存储和华为健康生态接入。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。