スマホの加速度センサーを使ってWebでリアルタイム同期する歩数計を作る



スマートフォンには多くのセンサーが搭載されており、それらを活用した様々なアプリがあります。

今回は、加速度センサーを使って複数のスマートフォンで取得した歩数をリアルタイムにモニタリングできるようなWebアプリを作成しました。

**参考にさせていただいた記事**

- [3軸デジタル加速度センサーを使用したフル機能の歩数計の設計](https://www.analog.com/jp/analog-dialogue/articles/pedometer-design-3-axis-digital-acceler.html)
- [ビデオチャットアプリを作る（WebRTC+Node.js+Socket.IO）](https://www.hiramine.com/programming/videochat_webrtc/index.html)

**使用した技術**
- node.js (express v.4.17.1)
- socket.io (v.4.3.2)
- chart.js (v.3.6.0)

## つくったもの

![ 2021-12-05 0.59.32.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513327/76e1f8b5-e259-b012-bf1e-2f9ba8e3737c.gif)

Github: https://github.com/Ogijun2018/SyncroWalk

スマートフォンの加速度センサーを用いて、ログインしている各デバイスの歩数の情報をデスクトップ画面で表示するWebアプリを作成しました。

Webで実装しているためiOS・Androidそれぞれでの実装の必要がなく、加速度センサーが搭載されていればどの端末でも使えるアプリになっています。

リアルタイムに歩いている様子が更新されるのを見ると、離れていても一緒に歩いている感が出て運動を頑張れるかな？と思い作成しました。

---

Webでリアルタイム更新できる歩数計を作るにあたって、実装のポイントは二つありました。

1. データ送信におけるリアルタイム性
1. 歩数計の精度

## データ送信におけるリアルタイム性

今回は「一緒に運動している感」を出すために、歩数が遅延なくリアルタイムに更新されるシステムを作ることを目標としました。

スマートフォンから取得できるデバイスの角度や加速度の値は毎秒数十回入ってくるため、後述するデータの平滑化を行ったとしても通常のクライアントサーバー方式のシステムでは無理があるだろうと思われます。

そのため、今回は低遅延でデータの送信ができるP2P方式のWebRTCを採用しました。

WebRTCというと映像や音声のストリームデータの送受信をするときに使用される技術な感じがしますが、もちろん独自データの送受信も行うことができます。

データの送受信は以下のような方法で行いました。

![ 2021-11-29 11.26.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513327/fa020d84-206b-2eca-4e1b-0e2ad880390a.png)

1. 作成したブラウザにアクセス
2. WebSocket (Socket.io) を利用し、シグナリングサーバとの接続を確立
3. サーバとの接続が確立されたら、生成されたidをトークンとしてクライアントに返す
4. 画面表示（iOSの場合は、明示的にデバイスの加速度の取得許可ダイアログを設定）
5. デバイスの傾き/加速度情報を取得し送信する
6. クライアントから取得された傾き/加速度情報に、③で生成されたトークン情報を付与してサーバへ送信
7. サーバに上がった自分以外の全クライアントの情報を他のデバイスで取得

```javascript:client.js
let g_mapRtcPeerConnection = new Map();
const g_socket = io.connect();
const IAM = {
  token: null,
};

// ② WebSocketを利用し、シグナリングサーバと接続 ---------------------------------
g_socket.emit("join", {});
g_socket.on("connect", () => {
  console.log("Socket Event : connect");
});

// ③ server.jsから生成されたトークンを受け取る -----------------------------------
g_socket.on("token", (data) => {
  IAM.token = data.token;
});

// ④ iOSの場合は明示的に加速度の取得許可ダイアログを設定 ----------------------------
// 参考: https://qiita.com/nakakaz11/items/a9be602874bd54819a18
function ClickRequestDeviceSensor() {
  DeviceOrientationEvent.requestPermission()
    .then(function (response) {
      if (response === "granted") {
        window.addEventListener("deviceorientation", deviceOrientation);
        $("#sensorrequest").css("display", "none");
      }
    })
    .catch(function (e) {
      console.log(e);
    });

  DeviceMotionEvent.requestPermission()
    .then(function (response) {
      if (response === "granted") {
        window.addEventListener("devicemotion", deviceMotion);
        $("#sensorrequest").css("display", "none");
      }
    })
    .catch(function (e) {
      console.log(e);
    });
}

if (window.DeviceOrientationEvent) {
  if (
    DeviceOrientationEvent.requestPermission &&
    typeof DeviceOrientationEvent.requestPermission === "function"
  ) {
    $("body").css("display", "none");
    var banner =
      '<div id="sensorrequest" onclick="ClickRequestDeviceSensor();">
           <p>センサー有効化</p>
       </div>';
    $("body").prepend(banner);
  } else {
    window.addEventListener("deviceorientation", deviceOrientation);
  }
}

if (window.DeviceMotionEvent) {
  if (
    DeviceMotionEvent.requestPermission &&
    typeof DeviceMotionEvent.requestPermission === "function"
  ) {
  } else {
    window.addEventListener("devicemotion", deviceMotion);
  }
}
// ------------------------------------------------------------------------

// ⑤ デバイスの傾き/加速度情報を取得し送信 -------------------------------------
function deviceMotion(e) {
  e.preventDefault();
  let ac = e.acceleration;
  deviceMotionData.x = ac.x;
  deviceMotionData.y = ac.y;
  deviceMotionData.z = ac.z;
  sendDeviceInfo();
}

function deviceOrientation(e) {
  e.preventDefault();
  deviceOrientationData.gamma = e.gamma;
  deviceOrientationData.beta​= e.beta;
  deviceOrientationData.alpha ​= e.alpha;
  sendDeviceInfo();
}
// ------------------------------------------------------------------------

// ⑥ クライアントから取得された傾き/加速度情報に、
//       ③で生成されたトークン情報を付与してサーバへ送信 -------------------------
function SendDeviceInfo() {
  // メッセージをDataChannelを通して相手に直接送信
  g_mapRtcPeerConnection.forEach((rtcPeerConnection) => {
    rtcPeerConnection.datachannel.send(
      JSON.stringify({
        type: "message",
        data: {
          deviceMotionData,
          deviceOrientationData
        },
        from: IAM.token,
      })
    );
  });
}

...

// ⑦ サーバに上がった自分以外の全クライアントの情報を他のデバイスで取得
function setupDataChannelEventHandler(rtcPeerConnection) {
  rtcPeerConnection.datachannel.onmessage = (event) => {
    let objData = JSON.parse(event.data);
    if ("message" === objData.type) {
      // 受け取ったデバイスの情報を表示
      let element = getRemoteChatElement(objData.from);
      element.innerHTML = `加速度情報: ${objData.data.deviceMotionData},
                           傾き情報: ${objData.data.deviceOrientationData}`;
    } ...
  };
}
// --------------------------------------------------------------------------
...
```

```javascript:server.js
const express = require("express");
const http = require("http");
const socketIO = require("socket.io");
const app = express();
const server = http.Server(app);
const io = socketIO(server);

// ③ サーバとの接続が確立されたら、生成されたidをトークンとしてクライアントに返す -------
io.on("connection", (socket) => {
  const token = socket.id;
  io.to(socket.id).emit("token", { token: token });
  ...
});
// --------------------------------------------------------------------------
...
```

シグナリングサーバーによる自動シグナリングの処理や、チャットからの離脱、STUN Serverの対応をしている部分は省略しています。

詳しい実装は [こちら（ビデオチャットアプリを作る（WebRTC+Node.js+Socket.IO））](https://www.hiramine.com/programming/videochat_webrtc/index.html)がとても参考になります。

## 歩数計の精度

上述の通り、スマートフォンを使用した歩数計はほとんどが加速度センサーを用いた実装になっています。

今回も同様に加速度センサーを用いた実装を行いますが、単純な加速度の上下を読み取るだけでは以下のような問題が起きてしまいます。

- ほんの少しの揺れ/歩行とは違う動きでも一歩と認識されてしまう
- デバイスの向きは状況によって違うため、特定の軸の加速度変化を見ても反応しない時がある
- しきい値が高すぎると小さい一歩では反応しない

これらを解決するために、[こちら（3軸デジタル加速度センサーを使用したフル機能の歩数計の設計）](https://www.analog.com/jp/analog-dialogue/articles/pedometer-design-3-axis-digital-acceler.html)の記事を参考にして歩数計アルゴリズムを作成し、歩数計の精度を高めました。

### 歩数計アルゴリズム

<div style="text-align: center;">
<img width="500" alt="example_acc" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513327/f044b3fb-842c-6bd3-1666-35d6c37349e8.jpeg">
</div>

上図はスマートフォンをポケットに入れた状態で歩行した際の加速度変化のグラフです。
この図ではz軸において歩行の一歩を踏み出した時に大きく加速度が変化していることが確認できます。

ただし、例えばスマートフォンをバッグの中に入れている時、デバイスが地面に対して垂直に立っているような形だとz軸ではなくy軸が大きく変化するはずであり、必ずしも1つの軸の情報のみを見れば良いというわけではありません。

そのため、3軸すべてにおける加速度のピーク値検出と動的に閾値を決める必要があります。

### 平滑化

まずは、データの瞬間的な変化をなくすために平滑化を行います。
今回は4個のデータが入ってくるまでデータの更新を止め、4個目が入ってきた時にその平均を算出することで平滑化を行います。

### 動的しきい値

平滑化されたデータを基に、具体的にどこのラインを超えたら歩行していると判断するのかを決定します。

<div style="text-align: center;">
<img width="500" alt="example_threshold" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513327/0162e212-4559-e1ee-3b61-ffa751e542a8.png">
</div>

（図参考: [3軸デジタル加速度センサーを使用したフル機能の歩数計の設計](https://www.analog.com/jp/analog-dialogue/articles/pedometer-design-3-axis-digital-acceler.html)）

3軸の加速度の最大値と最小値を50サンプルごとに継続的に更新し、その平均値を動的しきい値（THRESHOLD）とします。
次の50サンプルについてはその前に計算したしきい値を使用して歩行が行われているかを決定します。

そして、有効な一歩かどうかを判断するのには「現在の加速度」と「その一つ前に入力された加速度」との差を見ることで実現します。

歩行時に1歩を踏み出す動作は、**加速度曲線が動的しきい値をまたいでおり、加速度の傾きが負であるとき** と定義できます。

これらを実際に実装したコードが以下になります。

```javascript:client.js
let filterData = { x: 0, y: 0, z: 0 };
let axis_result = { x: 0, y: 0, z: 0 };
let axis_new = { x: 0, y: 0, z: 0 };
let axis_old = { x: 0, y: 0, z: 0 };
let deviceMotionData = { x: 0, y: 0, z: 0 };

// 動的しきい値のサンプル数
const THRESHOLD = 50;
// 歩数
let stepCount = 0;
let sampleCount = 0;
let filterCount = 0;

// 3軸それぞれの最小値に100, 最大値に-100を初期値で入れることで
// データ入力後すぐに更新できるようにする
let acXmin,
  acYmin,
  acZmin = 100;
let acXmax,
  acYmax,
  acZmax = -100;

// しきい値
let dcX, dcY, dcZ;

// 加速度変化の最も大きい軸
let thresholdLevel = 0;

function deviceMotion(e) {
  e.preventDefault();
  let ac = e.acceleration;
  let acg = e.accelerationIncludingGravity;

  if (filterCount < 3) {
    // 3軸のデータそれぞれの4データ分の平均を計算し平滑化する
    filterCount++;
    filterData.x += ac.x;
    filterData.y += ac.y;
    filterData.z += ac.z;
    return;
  } else {
    filterCount = 0;
    axis_result = {
      x: filterData.x / 4,
      y: filterData.y / 4,
      z: filterData.z / 4,
    };
    filterData = { x: 0, y: 0, z: 0 };
  }

  // 3軸それぞれの加速度の最小値・最大値を計算する
  acXmin = Math.min(axis_result.x, acXmin);
  acYmin = Math.min(axis_result.y, acYmin);
  acZmin = Math.min(axis_result.z, acZmin);
  acXmax = Math.max(axis_result.x, acXmax);
  acYmax = Math.max(axis_result.y, acYmax);
  acZmax = Math.max(axis_result.z, acZmax);

  sampleCount++;

  // サンプル数が50を超えたらしきい値を変更する
  if (sampleCount > THRESHOLD) {
    sampleCount = 0;
    // 3軸の加速度の最小値・最大値からしきい値を決定する
    dcX = (acXmax - acXmin) / 2;
    dcY = (acYmax - acYmin) / 2;
    dcZ = (acZmax - acZmin) / 2;
    // 初期化
    acXmax = acYmax = acZmax = -100;
    acXmin = acYmin = acZmin = 100;
  }

  // 現在の加速度のベクトルを計算
  let resultVector = Math.sqrt(
    Math.pow(axis_result.x, 2) +
      Math.pow(axis_result.y, 2) +
      Math.pow(axis_result.z, 2)
  );

  // 動的精度はとりあえず1.0の固定値とする
  if (resultVector > 1.0) {
    // 加速度変化が1.0以上あった場合、axis_oldとaxis_newを更新する
    axis_old = { x: axis_new.x, y: axis_new.y, z: axis_new.z };
    axis_new = { x: axis_result.x, y: axis_result.y, z: axis_result.z };
  } else {
    // そうでない場合はaxis_oldのみを更新する
    axis_old = { x: axis_new.x, y: axis_new.y, z: axis_new.z };
    return;
  }

  // 3軸の中でどれが一番加速度変化が大きかったかで条件分岐
  let abs_x_change = Math.abs(axis_result.x);
  let abs_y_change = Math.abs(axis_result.y);
  let abs_z_change = Math.abs(axis_result.z);
  if (abs_x_change > abs_y_change && abs_x_change > abs_z_change) {
    // X軸が一番大きかった場合
    thresholdLevel = dcX;
    if (axis_old.x > thresholdLevel && thresholdLevel > axis_new.x) {
      // 加速度の傾きが負 かつ しきい値を跨いでいる場合は1歩と判定
      stepCount++;
      document.getElementById("step").innerHTML = stepCount;
      SendDeviceInfo();
    }
  } else if (abs_y_change > abs_x_change && abs_y_change > abs_z_change) {
    thresholdLevel = dcY;
    // Y軸が一番大きかった場合
    if (axis_old.y > thresholdLevel && thresholdLevel > axis_new.y) {
      stepCount++;
      document.getElementById("step").innerHTML = stepCount;
      SendDeviceInfo();
    }
  } else if (abs_z_change > abs_x_change && abs_z_change > abs_y_change) {
    thresholdLevel = dcZ;
    // Z軸が一番大きかった場合
    if (axis_old.z > thresholdLevel && thresholdLevel > axis_new.z) {
      stepCount++;
      document.getElementById("step").innerHTML = stepCount;
      SendDeviceInfo();
    }
  }
}
```

感度が高すぎる場合は、`if (resultVector > 1.0) ` の部分を動的に設定・固定値を高くしたり、時間窓を設定して特定の間隔での加速度変化のみを歩行と判断する（遅すぎる・速すぎる加速度変化を除外する）ことでより精度をあげることができます。

## まとめ

歩数計のアルゴリズムは調べてもごく単純な実装だったり、なかなか情報がでてこず自分で実装してみましたが、意外と歩行を検知するだけでも大変なんだなぁという感想です。

実際に使用してみると、手でデバイスを持っている場合や前に進む歩行をしている場合はしっかりと反応していましたが、足踏みではなかなか反応しませんでした。
足踏みだと加速度変化が歩行に比べて小さくなってしまい、反応できないみたいですね。

ジャイロセンサーを使用して試してみましたが、今度は反応が良すぎるなどの問題が発生したので、さまざまなシチュエーションでの歩行に対応できるようにすることは今後の課題です。