滑雪App怎么做传感器融合?坡度计算详解

上一篇我们聊了雪痕的GPS轨迹记录,这篇来聊点更高级的——传感器融合与坡度计算。如果你还没体验过雪痕,可以去鸿蒙应用市场搜一下**「雪痕」**,下载下来滑一趟,看看坡度数据是怎么显示的。体验完了再回来看这篇文章,你会更清楚传感器融合的算法原理。


写在前面

大家好,我是一名写了十多年Web前端的老兵。从jQuery时代一路走到React/Vue,CSS3动画、requestAnimationFrame、Web Animation API这些都算是看家本领。去年开始转战鸿蒙生态,用ArkTS开发App,这一路踩了不少坑,也积累了不少心得。

上一篇我们解决了"怎么记录GPS轨迹"的问题,这篇来解决"怎么计算坡度"的问题。

这个需求在Web端几乎不可能实现,因为你很难在浏览器里拿到高频率的传感器数据。鸿蒙端的好处是,加速度计和陀螺仪可以做到毫秒级采样,数据足够精细,可以用来做姿态估计。


这篇文章聊什么

雪痕的传感器融合功能,核心要解决的问题是:

  1. 数据怎么采集 — 用加速度计和陀螺仪采集姿态数据
  2. 坡度怎么计算 — 根据重力分量计算倾斜角度
  3. 数据怎么融合 — 用互补滤波融合两个传感器的数据

第一步:理解传感器

滑雪App需要两个传感器配合工作:

  1. 加速度计(SENSOR_TYPE_ID_ACCELEROMETER)— 测量加速度,包括重力
  2. 陀螺仪(SENSOR_TYPE_ID_GYROSCOPE)— 测量角速度

为什么需要两个传感器?

  • 加速度计:可以测量重力方向,从而计算坡度。但受运动干扰大。
  • 陀螺仪:可以测量旋转速度,积分得到角度。但有漂移。

两者互补:加速度计提供长期稳定的参考,陀螺仪提供短期精确的变化。


第二步:权限申请

使用传感器之前,需要在module.json5里声明权限:

{
  "requestPermissions": [
    {
      "name": "ohos.permission.ACTIVITY_MOTION",
      "reason": "用于加速度计和陀螺仪获取运动数据",
      "usedScene": {
        "abilities": ["EntryAbility"],
        "when": "always"
      }
    }
  ]
}

第三步:封装传感器服务

同时订阅加速度计和陀螺仪:

// SensorService.ets
import { sensor } from '@kit.SensorServiceKit';

export class SensorService {
  private accelCallback: ((data: AccelData) => void) | null = null;
  private gyroCallback: ((data: GyroData) => void) | null = null;

  startAccel(callback: (data: AccelData) => void) {
    this.accelCallback = callback;
    sensor.on(sensor.SensorId.ACCELEROMETER, (data: sensor.AccelerometerResponse) => {
      this.accelCallback?.({
        x: data.x,
        y: data.y,
        z: data.z,
        timestamp: Date.now()
      });
    }, { interval: 20000000 }); // 20ms (50Hz)
  }

  startGyro(callback: (data: GyroData) => void) {
    this.gyroCallback = callback;
    sensor.on(sensor.SensorId.GYROSCOPE, (data: sensor.GyroscopeResponse) => {
      this.gyroCallback?.({
        x: data.x,
        y: data.y,
        z: data.z,
        timestamp: Date.now()
      });
    }, { interval: 20000000 }); // 20ms (50Hz)
  }

  stopAccel() {
    sensor.off(sensor.SensorId.ACCELEROMETER);
    this.accelCallback = null;
  }

  stopGyro() {
    sensor.off(sensor.SensorId.GYROSCOPE);
    this.gyroCallback = null;
  }

  stopAll() {
    this.stopAccel();
    this.stopGyro();
  }
}

interface AccelData {
  x: number;
  y: number;
  z: number;
  timestamp: number;
}

interface GyroData {
  x: number;
  y: number;
  z: number;
  timestamp: number;
}

React对应版本(模拟数据):

// React - 模拟传感器服务
function useSensors() {
  const [accel, setAccel] = useState({ x: 0, y: 0, z: 9.8 });
  const [gyro, setGyro] = useState({ x: 0, y: 0, z: 0 });

  useEffect(() => {
    const interval = setInterval(() => {
      setAccel({
        x: (Math.random() - 0.5) * 2,
        y: -9.8 + (Math.random() - 0.5) * 1, // 重力
        z: (Math.random() - 0.5) * 2,
        timestamp: Date.now()
      });
      setGyro({
        x: (Math.random() - 0.5) * 0.5,
        y: (Math.random() - 0.5) * 0.5,
        z: (Math.random() - 0.5) * 0.5,
        timestamp: Date.now()
      });
    }, 20);

    return () => clearInterval(interval);
  }, []);

  return { accel, gyro };
}

第四步:计算坡度

根据加速度计数据计算坡度。核心思路是:测量重力在手机坐标系中的分量

// SlopeCalculator.ets
export class SlopeCalculator {
  // 从加速度计计算坡度(度)
  static calculateFromAccel(accel: AccelData): number {
    // 假设手机平放,Y轴朝前
    // 坡度 = atan2(垂直分量, 水平分量)
    const vertical = accel.y;   // 垂直方向的加速度
    const horizontal = Math.sqrt(accel.x * accel.x + accel.z * accel.z); // 水平方向的加速度

    if (horizontal === 0) return 0;

    const angleRad = Math.atan2(vertical, horizontal);
    const angleDeg = angleRad * (180 / Math.PI);

    return angleDeg;
  }

  // 从GPS海拔数据计算坡度(度)
  static calculateFromAltitude(
    alt1: number, dist1: number,
    alt2: number, dist2: number
  ): number {
    const altDiff = alt2 - alt1;
    const distDiff = dist2 - dist1;

    if (distDiff === 0) return 0;

    const angleRad = Math.atan2(altDiff, distDiff);
    const angleDeg = angleRad * (180 / Math.PI);

    return angleDeg;
  }

  // 坡度百分比
  static toPercentage(angleDeg: number): number {
    return Math.tan(angleDeg * Math.PI / 180) * 100;
  }

  // 坡度等级
  static getSlopeGrade(angleDeg: number): string {
    const absAngle = Math.abs(angleDeg);
    if (absAngle < 5) return '平缓';
    if (absAngle < 15) return '缓坡';
    if (absAngle < 25) return '中坡';
    if (absAngle < 35) return '陡坡';
    return '极陡';
  }
}

React对应版本:

// React - 坡度计算
const SlopeCalculator = {
  calculateFromAccel: (accel) => {
    const vertical = accel.y;
    const horizontal = Math.sqrt(accel.x * accel.x + accel.z * accel.z);
    if (horizontal === 0) return 0;
    const angleRad = Math.atan2(vertical, horizontal);
    return angleRad * (180 / Math.PI);
  },

  calculateFromAltitude: (alt1, dist1, alt2, dist2) => {
    const altDiff = alt2 - alt1;
    const distDiff = dist2 - dist1;
    if (distDiff === 0) return 0;
    const angleRad = Math.atan2(altDiff, distDiff);
    return angleRad * (180 / Math.PI);
  },

  toPercentage: (angleDeg) => {
    return Math.tan(angleDeg * Math.PI / 180) * 100;
  },

  getSlopeGrade: (angleDeg) => {
    const absAngle = Math.abs(angleDeg);
    if (absAngle < 5) return '平缓';
    if (absAngle < 15) return '缓坡';
    if (absAngle < 25) return '中坡';
    if (absAngle < 35) return '陡坡';
    return '极陡';
  }
};

第五步:互补滤波

融合加速度计和陀螺仪的数据,用互补滤波

// ComplementaryFilter.ets
export class ComplementaryFilter {
  private angle: number = 0;
  private alpha: number = 0.98; // 陀螺仪权重

  update(accelAngle: number, gyroRate: number, dt: number): number {
    // 互补滤波公式:
    // angle = alpha * (angle + gyroRate * dt) + (1 - alpha) * accelAngle
    // alpha 越大,越信任陀螺仪(短期精确,长期漂移)
    // alpha 越小,越信任加速度计(长期稳定,短期噪声大)

    const gyroAngle = this.angle + gyroRate * dt;
    this.angle = this.alpha * gyroAngle + (1 - this.alpha) * accelAngle;

    return this.angle;
  }

  reset() {
    this.angle = 0;
  }
}

React对应版本:

// React - 互补滤波
function useComplementaryFilter() {
  const angleRef = useRef(0);
  const alpha = 0.98;

  const update = useCallback((accelAngle, gyroRate, dt) => {
    const gyroAngle = angleRef.current + gyroRate * dt;
    angleRef.current = alpha * gyroAngle + (1 - alpha) * accelAngle;
    return angleRef.current;
  }, []);

  const reset = useCallback(() => {
    angleRef.current = 0;
  }, []);

  return { update, reset };
}

第六步:在滑雪页面集成

把传感器融合集成到滑雪页面:

// ArkTS - 滑雪页面集成传感器
@Component
struct SkiActive {
  @State slope: number = 0;
  @State slopeGrade: string = '平缓';
  @State speed: number = 0;

  private sensorService: SensorService = new SensorService();
  private slopeFilter: ComplementaryFilter = new ComplementaryFilter();
  private lastTimestamp: number = 0;

  startSki() {
    this.sensorService.startAccel((accel) => {
      const accelAngle = SlopeCalculator.calculateFromAccel(accel);
      const now = accel.timestamp;
      const dt = this.lastTimestamp > 0 ? (now - this.lastTimestamp) / 1000 : 0.02;
      this.lastTimestamp = now;

      this.slope = this.slopeFilter.update(accelAngle, 0, dt);
      this.slopeGrade = SlopeCalculator.getSlopeGrade(this.slope);
    });

    this.sensorService.startGyro((gyro) => {
      // 陀螺仪数据可以用于更精确的坡度计算
    });
  }

  stopSki() {
    this.sensorService.stopAll();
  }

  build() {
    Column() {
      Text(`${this.slope.toFixed(1)}°`)
        .fontSize(48)
        .fontWeight(FontWeight.Bold)
      Text(this.slopeGrade)
        .fontSize(16)
        .fontColor('#666')
      Text(`${SlopeCalculator.toPercentage(this.slope).toFixed(0)}%`)
        .fontSize(24)
        .fontColor('#f59e0b')
    }
  }
}

React对应版本:

// React - 滑雪页面集成传感器
function SkiActive() {
  const [slope, setSlope] = useState(0);
  const [slopeGrade, setSlopeGrade] = useState('平缓');
  const { accel, gyro } = useSensors();
  const { update: updateFilter } = useComplementaryFilter();
  const lastTimestampRef = useRef(0);

  useEffect(() => {
    const accelAngle = SlopeCalculator.calculateFromAccel(accel);
    const now = accel.timestamp;
    const dt = lastTimestampRef.current > 0 ? (now - lastTimestampRef.current) / 1000 : 0.02;
    lastTimestampRef.current = now;

    const filteredAngle = updateFilter(accelAngle, 0, dt);
    setSlope(filteredAngle);
    setSlopeGrade(SlopeCalculator.getSlopeGrade(filteredAngle));
  }, [accel]);

  return (
    <div className="flex flex-col items-center justify-center h-full">
      <p className="text-6xl font-bold">{slope.toFixed(1)}°</p>
      <p className="text-lg text-gray-500">{slopeGrade}</p>
      <p className="text-2xl text-amber-500">{SlopeCalculator.toPercentage(slope).toFixed(0)}%</p>
    </div>
  );
}

踩坑提醒

  1. 手机放置位置:手机放口袋里和手持的姿态不同,坡度计算结果也不同。建议在设置里让用户选择手机放置方式。

  2. 校准:开始滑雪前,让用户站在平地上校准一次,消除传感器偏差。

  3. 滤波参数:互补滤波的alpha参数(0.98)需要根据实际情况调整。alpha越大,越信任陀螺仪;越小,越信任加速度计。

  4. 电池消耗:两个传感器同时运行很耗电,建议在页面不可见时降低采样频率。

  5. 温度影响:低温环境下,传感器精度可能下降。建议在设置里提醒用户。


总结

这篇文章带你走了一遍传感器融合的完整流程:

  1. 传感器采集:同时订阅加速度计和陀螺仪
  2. 坡度计算:根据重力分量计算倾斜角度
  3. 互补滤波:融合两个传感器的数据
  4. 页面集成:把坡度数据展示出来

核心算法就一个:互补滤波。加速度计提供长期稳定的参考,陀螺仪提供短期精确的变化,两者融合得到最准确的结果。

两篇文章下来,雪痕的核心功能——GPS轨迹记录和传感器融合——就讲完了。如果你对滑雪App开发感兴趣,可以去鸿蒙应用市场下载雪痕体验一下,看看实际效果。

Logo

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

更多推荐