フィットネスバイクを継続するためにIoTやXRができること


# 概要
この記事は、NTTドコモ アドベントカレンダー2020 控室24日目の記事です。
フィットネスバイク（エアロバイク）と呼ばれるデバイスを使った運動を、いかに楽しんで継続できるかを考え、それに必要だった技術開発を紹介する記事です。



# 背景と目的

2020年さまざまな影響で、家の中にいることが多くなりました。これに伴い、運動量が激減してしまいこのままではいけないとフィットネスバイクを購入しました。しかしもとより継続的に運動をしてストイックに身体を鍛えることが出来る性格であれば苦労はしません。しばらくするとフィットネスバイクは、リビングの邪魔な置物として鎮座するようになりました・・・**これではいけない！！**

フィットネスバイクの場合、ランニングやサイクリングなどと違って「ながら運動」が可能です。そのため、多くの先行事例[1]があります。また、フィットネスバイクは比較的構造がシンプルなこともあり、IoT的にフィットネスバイクからの回転の状態を取り出すことで地図などと連動させたり[2],[3]ゲーム内の車両と連動させたり[4]するなどの多くのアプリケーションが実際に稼働しています。

しかしこれらは高価なサイクルコンピューターが必要であったり専用のハードウェアを必要としていたりしており、なかなか手を出せずにいました。
そこで本稿では比較的入手性の容易なデバイスやクラウドサービスを使って、なんちゃってヴァーチャルバイクのシステムを構築し、運動不足を解消することを目的とします。


# 全体の設計
全体でどういったモジュールが必要になるかを設計しましょう。
多くのフィットネスバイクを用いたアプリケーションは、以下の4つのモジュールで構成されています。

1. 漕ぐ（フィットネスバイク）
2. 計測（サイクルコンピューター）
3. 処理（アプリケーションサーバ）
4. 表示（ディスプレイ）

サイクルコンピューターとは、通常自転車に装着することで、自転車の速度、移動距離、走行時間などを計測してくれる機材です。フルカラーのディスプレイを有しGPSなどを用いて現在地をリアルタイムに表示される機能を持つものもあります。機能として整理するならば、以下の図のようになります。

![arch.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320465/b7f9f0ec-65f2-49ce-06b3-102734b520f5.png)
このそれぞれのモジュールについて検討を進めます。

## 漕ぐ
各種通販サイトなどでエアロバイク、フィットネスバイクなどと検索すると様々な車両が出てきます。
多くの場合、可変の負荷レベルを設定できる機能がついているはずです。本当に漕ぐだけの回転軸とペダルしかないものもありますし、サドルとハンドルが通常の自転車と同じようにあって、自転車に乗っている状態と同じ体勢で漕げるものもあります。
負荷をかけるかけ方（摩擦、磁力、電磁力）によって電源が必要であったり、それなりの動作音がしたりするそうなので、同じようなことをお考えの方はご参考まで。

今回私が購入したのはこちらですが、特に何か思い入れがあるわけではありません。[https://www.amazon.co.jp/dp/B08JKDWZKL](https://www.amazon.co.jp/dp/B08JKDWZKL)
シンプルでかつ音が静かということでした。

![bike.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320465/533afd30-241d-146c-20b1-fafe92b794c6.png)


## 漕いだ情報を計測に
フィットネスバイクをどれぐらい漕いだのかを計測する機器が付属しているはずです。
上記のフィットネスバイクの場合、こういった表示がなされていました。

実際に漕いで見ると早く漕ぐと表示される速度があがり、遅く漕ぐと表示される速度が下がるのでペダルの回転数を見て速度の表示を行っているようです。後ろの入力を見ると、音声用のモノラルのミニプラグが刺さっています。実際にそのプラグを抜いてみると速度の表示が消えたので、何かしらの信号をこれを用いて送っているようです。なお、[2]でもステレオミニプラグを使われているようなので、この業界ではよくある構成なのかもしれません。

実際にどういう信号がやりとりされているのかを確認するためにテスターにつないで計測してみます。
実際に利用されている線を切断してしまうと万一修復できなかったときに悲しいことになってしまいますので、一つの音声入力を複数に分岐させるようなプラグを利用して一つをもともと刺さっていた所、もう一つをデータを取り出す側に分岐させるようにしましょう。

![cut.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320465/39f8123e-e5fd-6bdb-d14d-3ea0c4ca5a97.png)


ケーブルを切断し、被覆をカッターナイフなどで剥いたものをテスターに接続し通電を確認するとペダルが特定の位置に来たときに通電しているようです。
この通電が来たタイミングで信号を受け取るために、今回はRaspberry PiのGPIOを用いることにしました。
GPIOは名前の通り、general-purpose input/output なため、GPIOなポートを使ってこの通電状態を確認できれば良さそうです。
[5]などを参考に、一つの線をground、もう一つの線をGPIO16に接続してみました。GPIOの入出力として書いたのは以下の部分だけです。

![zerow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320465/28fa32a9-6927-5bf1-6aa5-cbf694360e30.png)

これで、GPIO16番ポートがLOWからHIGHに状態が写ったときに、event_callbackの関数が呼ばれるようになります。
なお、手元の環境では何も問題は生じていないが、何もしていない状態のときに16番ポートがどうなっているのかは未定義のため、
必要に応じてプルダウン抵抗をつけたり、ソフトウェア側で設定する必要があるかもしれません。


```
def main():
    GPIO.setmode(GPIO.BCM)
    GPIO.setup(16, GPIO.IN)
    GPIO.add_event_detect(16, GPIO.RISING, callback=event_callback, bouncetime=300)

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        GPIO.remove_event_detect(16)
        GPIO.cleanup()
```

なお、立ち上がり、立ち下がりが完全にデジタルにはならずバタつくことがしばしば発生します。
そのためbouncetimeを設定しています。この場合、300msec以内に複数回callbackが発生することはなくなります。
60秒で200回なので、200rpmを超えるケイデンスでペダリングする場合は欠損してしまいます。
オリンピックのトラック競技選手並の回転数で漕がれる予定の方はこのあたりのパラメータを調整してください。

## 計測

それでは、ペダルを一回転させるごとにどの程度の距離を進むのでしょうか？
付属のサイクルコンピューターの出力結果を検証してみると、負荷の状態（1-8まで）に依らず、回転数によって同じ距離を得ているようです。
競輪で使われている車両のように、ペダルの回転がそのまま車輪に伝わるようで漕いだ回数によってのみ距離が進むチェーンが空転しないという設定のようです。
負荷が変わっても同じ回転が同じ距離を示している、ということなので負荷が高い≒少し上り坂を上がっているという設定だと思うことにしましょう。

実際にペダリングをしてみると、サイクルコンピューター上では170回転で1000m進んでいることがわかりました。1回転あたり、5.88m程度です。街乗り自転車よりは多少ギア比が高い設定、ぐらいでしょうか。
つまり、回転数を取得して170で割れば進んだ距離(km)になりますし、一時間あたりの回転数を170で割れば時速になります。

## 計測した情報を処理に

回転を検出したタイミングに対してタイムスタンプをつけてDBに記録しておくことにしましょう。
ローカルで処理するためのデータベースと、クラウド側で処理するためのデータベースの２つに書き込むことにします。

クラウド側はAPI GatewayをHTTPSのエンドポイントに設定し、実際の処理をLambdaで行い、データをRDSに書く、というよくある構成を利用します。
直接RDSに書いてもよいのですが、RDS proxyのconnection poolを利用して大量にLambdaからのアクセスが発生しても捌けるようにしておきます。

例えば[6]などを見れば構成がわかりやすいです。よくある構成をサーバレスアーキテクチャで実装するのは本当に楽でいいですね。
当初、RDSの代わりにDynamoDBを使っていましたが、結局可視化の際に全てのデータを毎回ロードして表示しているので、リレーショナルデータベースの方が後々よいかなと思い至りました。
同時に保存しているローカル側のデータベースと同じテーブル定義を利用しやすい、というのもあります。

![post2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320465/53bdde63-98a4-5214-3040-52414a8b2925.png)

自分が使う専用のデータベースなので、とてもシンプルなテーブル定義にします。

```
CREATE TABLE logs(
    count int,
    created_at timestamp
);
```

潔いですね。
ローカルのデータベースはsqlite3を利用します。
pythonには最初からライブラリが使えることもあり、非常に扱いやすくなっています。

## 処理
データベースに入っているので、集計処理はとても簡単です。

### 現在までに何回転しているか

```
> select max(count), created_at from logs;
37680|2020-11-22 16:49:35
```

### その日に到達した距離の一覧を取得

```
> select max(count), substr(created_at, 1, 10) from logs group by substr(created_at, 1, 10);
4220|2020-11-08
7270|2020-11-09
9270|2020-11-10
9470|2020-11-11
9550|2020-11-12
13060|2020-11-14
14410|2020-11-15
21370|2020-11-16
25750|2020-11-17
31730|2020-11-19
35340|2020-11-21
37680|2020-11-22
```

## 処理した情報を表示

これまでの開発で、開始地点からXm進んでいるということが取得できることになります。
モチベーションを高めるために、ある都市から別の都市へ向かっている、ということを表現することにしましょう。
日本地図を見ながら、神奈川県川崎市（市庁舎）から福岡県福岡市（市庁舎）に向かうことにします。

緯度経度を地図で調べると、

```
川崎 = (35.5308074, 139.7008083)
福岡 = (33.5905687, 130.3761657)
```

となっています。
ところで、この二点間の距離は何キロメートルでしょうか？国土地理院の[経緯度を用いた2地点間の測地線長、方位角を求める計算](https://vldb.gsi.go.jp/sokuchi/surveycalc/surveycalc/algorithm/bl2st/bl2st.htm)に詳しいアルゴリズムが載っています。
少しアルゴリズムを頭に浮かべてから見ていただければと思います。いかがでしたでしょうか？地球は正球ではない、というのが一番の理由でどの楕円であると近似して計算を行うかによって計算結果が異なっています。なぜ異なる演算方法がいくつも存在しているのかというと、地球上のどこにいるかによってそれぞれのモデルの誤差が異なり、どの位置でも誤差が小さいモデル、というのが存在しないためです。おとなしくライブラリを使いましょう。GeoPy [8]を使うことにします。GeoPyでは、WGS-84を用いて計算しているようです。

```
              モデル            長半径 (km)   短半 (km)      扁平率
ELLIPSOIDS = {'WGS-84':        (6378.137,    6356.7523142,  1 / 298.257223563),
              'GRS-80':        (6378.137,    6356.7523141,  1 / 298.257222101),
              'Airy (1830)':   (6377.563396, 6356.256909,   1 / 299.3249646),
              'Intl 1924':     (6378.388,    6356.911946,   1 / 297.0),
              'Clarke (1880)': (6378.249145, 6356.51486955, 1 / 293.465),
              'GRS-67':        (6378.1600,   6356.774719,   1 / 298.25),
              }
```

これを用いると、この2地点間の距離はおそよ882kmです。
当然ですがこの距離は、2点間で大圏航路を通ったときの距離になります。高度0mを真っ直ぐに通ったときの距離です。

途中経路について考えた場合、メルカトール図法で書かれているGoogle Mapなどは2点間の最短経路は曲線になるため、
正距方位図法で書かれた地図上で計算する必要があります。


## 表示
ここまでで任意の2地点を結ぶ球面状を走ることが出来るようになりました。
だんだん想像力が膨らんできましたね。上空を自転車のようなペダルを漕いで飛ぶシーンを思い浮かべてみましょう。映画E.T.[9]のワンシーンのようです。
これらを再現するために、足元の床と正面の壁に擬似的に上空からの様子をプロジェクションすることで上空を飛んでいるイメージを再現することにします。

想像したイメージ図は以下のとおりです。

![flight.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320465/d29e849c-f4fc-21fc-a650-66b0c6169922.png)
この画像は国土地理院の空中写真を一部加工し利用しています。もとの画像は[こちら](https://mapps.gsi.go.jp/maplibSearch.do?specificationId=1822359)

航空写真は、Google Maps Static APIで取得することができます。通常はjavascriptを用いてインタラクティブにやりとりをするのですが、今季は最終的にMadmapperなどを用いて足元の映像を再現するのでstatic apiを用いました。
利用方法はとても簡単で、API_KEYを取得した上で、各種パラメタをを仕込んだURLに対してGETを発行するだけです。こうすればimageファイルとして返ってきます。
https://maps.googleapis.com/maps/api/staticmap?center=35.5308074,139.7008083&zoom=12&size=400x400&maptype=hybrid&key=``API_KEY``



## VR化
最後にこれをVR化しましょう。VRゴーグルをかぶりながらフィットネスバイクに搭乗し、ペダルを漕ぐと進むことにします。
速度はペダルの回転で制御出来ますが、左右の回転は身体を傾けた量（VRゴーグルの傾きに応じた値）に対応して転回するすることにします。まさにゆったりと空を飛んでいる気分ですね。
今回はこれらの開発環境を作って試してみることにします。

- [11] [Oculus Quest 2](https://www.oculus.com/quest-2/)
- [12] [Unity](https://unity.com/ja)
- [13] [Oculus Integration](https://assetstore.unity.com/packages/tools/integration/oculus-integration-82022)
- [14] [Maps SDK for Unity](https://developers.google.com/maps/documentation/gaming/overview_musk?hl=ja)


UnityはPC向けのアプリケーションでもOculus（この場合Android向け）のアプリケーションでもビルドすることが可能です。
今回はOculus Quest 2＋Oculus Linkを使うことにします。
Oculus Quest 2はスタンドアローンで動作するVRゴーグルですが、Oculus Linkを使うとOculus Rift用に作られたPCで動作するアプリケーションを動作させることが出来ます。
USB3なケーブルでPCとOculus Quest 2を接続する必要がありますが、圧倒的にデバッグが楽です。

それぞれの項目の解説は別の機会に譲るとして、ポイントとなる部分を記載します。

### Maps SDK for Unity
Google社が提供しているGoogle Mapの地形データ、および建物データをUnityからAPI経由で取得することが出来るライブラリです。
クイックスタートと数行の記述で任意の座標、任意の方向で以下のような絵をレンダリングすることが可能です。
![kanagawa.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320465/25f6232b-3947-be2b-9314-82a4e05fad92.png)
この絵は地面が平らであることを仮定していますが、Google Earthの標高データをもとに[地形標高](https://developers.google.com/maps/documentation/gaming/elevation?hl=ja)を表現することも可能です。
UnityではTerrainエンジンと呼ばれる地形を描画するための環境が整っており、これと統合して扱えるので非常に扱いやすいです。

ではなぜここで地面が平らであるかというと、``私が酔ったから`` です。乗り物酔いはしたことがないのですがVR酔いはするらしく、左右の視点移動、前後の視点移動に加えて上下動も加わった途端に気持ち悪くなってきたため水平の世界を走ることにしました。
ちなみに、高度一定にして見下ろす実装も行ったのですが、日本は想像以上に山が多く都市部を走っているときは快適でも山間部に差し掛かった途端に山の地中を走ることになりました。
おそらく現在地に対する相対高度を緩やかに維持するような実装をすればいいのだと思いますが、今回はデバッグ中の酔いに耐えられないのでこのまま走ることにします。

なお、地形が平らなので山間部に入ると急に広場が広がっているように見えます。日本は本当に山が多いですね。

### Oculus Integration
Oculus Integrationは、UnityからOculusの各種機能を使うためのAssetsでUnityのAsset Storeからダウンロードすることが可能です。
使い方はとても簡単で、``XR Plug-in Management``をインストールすれば、そこからOculusをサポートすることが出来ます。
VRヘッドセットで描画するのも簡単で、Unityにおけるメインのカメラの代わりにAssetsの中の``Prefabs``に含まれている``OVRCameraRig``を使うだけです。
先のMaps SDKがcameraの動きや向きによってオブジェクトをロードしたり開放したりするので、MainCameraの下にぶら下げてあげると2つ合わせてまったく同じように使えます。


なお、UnityでVRヘッドセットの傾きを取得するためには、XRのGetRotationが使えます。
```
Quaternion dir = UnityEngine.XR.InputTracking.GetLocalRotation(UnityEngine.XR.XRNode.Head);
```
戻り値Quaternion の2番めの値が首を左右にかしげる向きの傾きなので、これをもとにハンドルを左右に切る挙動を再現します。


2つとも始めて触るアセットでしたが、半日でそれなりに動くようになりました。

### 日々の進捗

日々の進捗も見たくなりますよね。データベースに入れておくと簡単です。
```
    conn = sqlite3.connect('logs.db')
    c = conn.cursor()
    c.execute("select max(count), substr(created_at, 1, 10) from logs group by substr(created_at, 1, 10)")
    x, y = zip(*c.fetchall())
    day = [i[-2:] for i in y]
    km = [i / 170 for i in x]
    plt.bar(day, km)
    plt.savefig('figure.png')
    conn.close()
```

![](https://pflab.com/airbike/figure.png)


## 終わりに
11月8日から始めたVirtual自転車の旅は、12月23日に無事ゴールを迎えました。
作る→漕ぐ→飽きる→設計する→作る→また漕ぐのループを回すことができたのが飽きっぽい自分が880kmも完走できた勝因だと勝手に思っています。
一日平均すると20km程度しか進んでいませんが、少々傾斜がついている坂を登りつけけているような足の負荷だったので個人的には良い運動になったと思います。
自宅にいても世界中のデータから仮想空間を再現できる時代です。今後も運動を習慣づけられるように開発を継続していきたいと思います。


# 参考文献
- [1] [FeelCycle](https://www.feelcycle.com/)
- [2] [【フィットネスバイク】のインターネットを作る！！](https://qiita.com/ie4/items/130308793444bd98179)
- [3] [エアロバイクをGoogleマップに連携して日本縦断の旅に出ます](https://omocoro.jp/kiji/230063/)
- [4] [Zwift](https://zwift.com/ja)
- [5] [Raspberry Pi GPIO](https://www.raspberrypi.org/documentation/usage/gpio/)
- [6] [【待望リリース！】もう Lambda×RDS は怖くない！LambdaでRDSプロキシを徹底的に検証してみた 〜全てがサーバレスになる〜](https://qiita.com/G-awa/items/b9138cc1c9e4867a905e)
- [7] [経緯度を用いた2地点間の測地線長、方位角を求める計算](https://vldb.gsi.go.jp/sokuchi/surveycalc/surveycalc/algorithm/bl2st/bl2st.htm)
- [8] [GeoPy distance](https://geopy.readthedocs.io/en/stable/#module-geopy.distance)
- [9] [E.T.](https://ja.wikipedia.org/wiki/E.T.)
- [10] [Google Map Static API](https://developers.google.com/maps/documentation/maps-static/overview)
- [11] [Oculus Quest 2](https://www.oculus.com/quest-2/)
- [12] [Unity](https://unity.com/ja)
- [13] [Oculus Integration](https://assetstore.unity.com/packages/tools/integration/oculus-integration-82022)
- [14] [Maps SDK for Unity](https://developers.google.com/maps/documentation/gaming/overview_musk?hl=ja)