はじめに
旭川高専祭で「どの休憩室が空いているか」をスマホから見られるようにしたくて、BLE(Bluetooth Low Energy)の電波を数えて部屋の混雑度を推定するシステムを作りました。部屋に置くのは余っていた Android スマホ(Pixel 9a / Pixel 7a)だけで、カメラもマイクも使いません。
この記事では、作ったものの構成、実装でハマったところ、そして学校祭1日目に手で数えた実人数と突き合わせた結果をまとめます。
BLE で混雑度や人流を推定する研究自体はすでにたくさんあります。この記事は新しい手法の提案ではなく、「市販のスマホだけで、実際のイベントで動かしてみたらどうだったか」という実践記録です。
作ったもの
- ノード:Kotlin / Jetpack Compose の Android アプリ。Foreground Service で BLE を常時スキャンし、1分ごとの窓で集計値だけを送ります
- API:Supabase。サーバは置かず、推定は Postgres の関数でやっています
- Web ページ:ビルド不要の素の HTML/CSS/JS。部屋ごとの「空き/やや混雑/混雑」と、今日の推移グラフを出します
ノードが送る値(例)
1分間に見えた端末を、受信強度(RSSI)の閾値別・メーカー別に数えた値と、RSSI の統計だけです。
{
"node_id": "room-1-02",
"observed_at": "2026-10-17T11:10:00+09:00",
"window_sec": 60,
"counts_by_rssi": {"-60": 6, "-70": 23, "-80": 56, "-90": 124, "all": 212},
"counts_by_vendor": {"apple": 14, "google": 3, "microsoft": 2, "samsung": 1, "other": 5, "none": 6},
"rssi_stats": {"median": -78, "p10": -91, "p90": -62},
"estimator": {"feature": "-70", "coef": 0.72, "intercept": -2.3}
}
counts_by_rssi は累積で、"-70" は「窓の中の最大 RSSI が -70 dBm 以上だった端末の数」です。推定人数は、この中のどれか1つの特徴量に係数を掛けて切片を足すだけの線形式で出しています。
プライバシーの設計
人がいる場所に置くものなので、ここは最初に決めました。
- MAC アドレスは受信した瞬間にソルト付き SHA-256 でハッシュ化し、生の値は保存も送信もしない
- ソルトはアプリを起動するたびに作り直すので、再起動をまたいで同じ端末を追跡できない
- 端末の外に出るのは「1分間に何台あったか」という数字だけ
- 校正用に端末内に残すデバッグログにも、生の MAC アドレスは入れない。ログは送信しない
会場には「実証実験中」の張り紙をして、仕組みと、数えられたくない人は Bluetooth をオフにすれば対象外になることを書きました。
実装でハマったところ
1. スキャン開始からちょうど5分で結果が来なくなる
Pixel 9a(Android 16)で動かしたところ、スキャン開始から5分で受信数が約28%に落ち、その次の分から0になりました。Bluetooth のログにはこう出ていました。
W/BtScan.ScanManager: regularScanTimeout(...): Moving unfiltered scan to opportunistic scan
フィルタなしのスキャン(空の ScanFilter も「フィルタなし」扱い)は、開始から DeviceConfig の bluetooth/scan_timeout_millis が経つと opportunistic モードに格下げされ、結果が届かなくなります。AOSP の既定は30分ですが、この端末では 300000(5分) でした。
対策として、
- 3分ごとにスキャンを stop/start してタイマーを数え直させる
- 30秒間1件も結果が来なければ再起動する
ようにしました。ただし Android には「30秒間に5回を超える startScan はブロックされる」という制限もあるので、再起動の頻度は上げすぎないようにしています。
2. 画面が消えるとスキャンが止まる
Android 8.1 以降は、画面オフ中にフィルタなしのスキャンが止まります。常時給電する前提なので、常駐画面に FLAG_KEEP_SCREEN_ON と最低輝度を設定し、画面を点けたままにしています。運用中に誰かが電源ボタンを押して画面が消え、受信が0になったこともありました。
3. 起動直後の窓は「数秒分」しか見ていない
起動直後の窓は、実際には5秒ほどしか観測していないのに、60秒窓として送られていました。スキャンを開始・再開した窓には "partial": true を付けて、送信しないようにしました。
4. Apple 製品が2台に見える
近くに Apple 製品が8台もないのに、メーカー別の Apple が8前後になっていました。デバッグログで調べると、ほぼ同じ RSSI のアドレスが
- Nearby Info(Apple のメーカーデータの種類 0x10、100回/分ほど)
- 探す(0x12、25回/分ほど)
の対になっていました。「探す」の広告は通常の広告とは別のアドレスで出るので、アドレス単位で数えると1台が2台に見えます。窓の中で「探す」だけ、または iBeacon(0x02)だけを出していた Apple のアドレスは数えないようにしました。直した直後の窓では、Apple のアドレス8つのうち数えたのは4つになりました。
5. スマホの画面とサーバの推定人数が違う
ノードの設定画面で推定の式を変えても、サーバ側は別の既定の式で推定していたので、値が食い違っていました。ノードが観測値に estimator(特徴量・係数・切片)を添えて送り、サーバはその式で推定するように変えました。式は観測ごとに保存されるので、あとから式を変えても過去の推移はそのときの式で見られます。
おまけ:ノードの死活監視
Supabase の pg_cron と pg_net で、10分ごとにノードの状況(最終受信、画面の ON/OFF、送信待ちの件数など)を Discord の Webhook に投稿しています。集計は Supabase 側でやるので、ノードが止まっていても「停止」として報告されます。
学校祭で動かした結果
条件
- 日時:2026年10月17日(学校祭1日目)9:00〜15:00
- 部屋:3F休憩室(Pixel 7a)、2F休憩室(Pixel 9a)。各部屋にノード1台
- 実人数:各部屋の係の人に、1時間に2回(10分以上あけて)、部屋にいる人数を紙に書いてもらった。各部屋12回ずつ
- 突き合わせ:実人数を記録した時刻の前後2分(計5分)の観測値の平均を使った
当日の画面には、どちらの部屋も仮の式「-60 dBm 以上の端末数 × 0.5」で推定した値を表示していました。
どの閾値の端末数が人数と合うか
実人数と各特徴量の相関係数(r)と、1次式で当てはめたときの誤差です。LOO-MAE は「1点を抜いて残りで式を作り、抜いた点を予測する」のを全点で繰り返したときの平均絶対誤差です。
3F休憩室(実人数 0〜16人、平均 8.3人)
| 特徴量 | r | 係数 | 切片 | LOO-MAE(人) |
|---|---|---|---|---|
| -60 dBm 以上 | 0.72 | 1.52 | 1.4 | 3.2 |
| -70 dBm 以上 | 0.84 | 0.72 | -2.3 | 2.3 |
| -80 dBm 以上 | 0.76 | 0.35 | -5.2 | 2.9 |
| -90 dBm 以上 | 0.59 | 0.13 | -5.1 | 3.6 |
| すべて | 0.54 | 0.08 | -5.9 | 3.5 |
2F休憩室(実人数 1〜20人、平均 8.3人)
| 特徴量 | r | 係数 | 切片 | LOO-MAE(人) |
|---|---|---|---|---|
| -60 dBm 以上 | 0.68 | 1.78 | -2.5 | 3.3 |
| -70 dBm 以上 | 0.84 | 1.08 | -2.4 | 2.5 |
| -80 dBm 以上 | 0.88 | 0.90 | -4.9 | 2.4 |
| -90 dBm 以上 | 0.93 | 0.73 | -6.2 | 1.9 |
| すべて | 0.93 | 0.64 | -7.6 | 1.9 |
部屋ごとに最もよい式に当てはめ直すと、誤差は平均2人前後になりました。
わかったこと
最適な閾値が部屋によってまったく違う。 3F は -70 dBm、2F は -90 dBm(またはすべて)が一番よく合いました。3F は人が0人のとき(9:20)でも、すべての端末で約160台、-90 dBm 以上で約90台見えていました。隣の部屋や廊下、展示の人だかりの端末を大量に拾っているので、強い電波だけに絞らないと人数の変化が埋もれます。2F はまわりが静かで、弱い電波まで数えたほうが合いました。
式は部屋をまたいで使えない。 片方の部屋で作った式をもう片方にそのまま使うと、誤差は大きく悪化しました。
| 作った部屋 → 使った部屋 | 特徴量 | MAE(人) |
|---|---|---|
| 3F → 2F | -70 dBm | 3.5 |
| 2F → 3F | -70 dBm | 5.2 |
| 3F → 2F | -90 dBm | 10.8 |
| 2F → 3F | -90 dBm | 58.8 |
当日表示していた値は少なすぎた。 仮の式(-60 dBm × 0.5)の平均絶対誤差は 3F で 6.2人、2F で 5.3人でした。推定の平均は2〜3人で、実際の平均8人を大きく下回っています。設置する部屋ごとに、事前に実人数を数えて式を決めておくべきでした。
この結果の限界
- 実人数は各部屋12点しかなく、式を作ったデータと評価したデータが同じ(LOO で多少は補正していますが、楽観的な数字です)
- 1日分だけ。来場者の層や時間帯が変わると係数が変わる可能性がある
- 実人数は手で数えた値で、数えた時刻も分単位の手書き
- 1台あたりの端末数(スマホ・イヤホン・時計など)が人によって違う影響は分けられていない
今後
「最適な閾値も係数も部屋ごとに違い、手で校正しないと使えない」というのがいちばんの課題です。設置した場所ごとに、
- 無人の時間帯の端末数から、背景(人がいなくても見える端末)を自動で引く
- 壁際に置いた参照ビーコンの受信強度から、部屋の大きさに合った閾値を自動で決める
といった方法で、実人数をほとんど数えずに式を決められないかを試してみたいと思っています。
おわりに
余ったスマホ2台と Supabase の無料枠だけでも、学校祭の休憩室の混雑度を誤差2人程度で出せそうだとわかりました。一方で、そのためには部屋ごとの校正が欠かせず、何も考えずに置くだけでは大きく外れることもわかりました。

