HarmonyOS开发:运动类型识别(步行/跑步/骑行)
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户外跑)
改进方向:
- 个性化模板:用户首次使用时采集标准运动数据生成个人模板
- 在线学习:根据用户确认的运动类型持续更新模板
- 多模板融合:每种运动类型维护多个子模板
五、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帧窗口防抖,避免频繁切换 |
| 运动参数 | 步频/速度/强度 | 基于主频率和能量估算 |
最佳实践建议:
- 优先使用系统级活动识别API,精度和功耗都优于自研方案
- 自研方案适合特殊场景,如特定运动类型识别、个性化算法调优
- 决策树+模板匹配是实用方案,兼顾效率和精度
- 特征提取间隔0.5秒,平衡实时性和计算开销
- 运动类型切换需要平滑,但不同方向切换应使用不同窗口大小
- 个性化模板能显著提升精度,建议首次使用时采集标准运动数据
下一篇文章将深入讲解健康数据采集与华为运动健康对接,实现运动数据的持久化存储和华为健康生态接入。
- 点赞
- 收藏
- 关注作者
评论(0)