Hololens2で人の体温を可視化する仕組みを検討しました。せっかくの Hololens2 なので距離センサの情報も使って、温度を3次元空間にマッピングしてみました。
はじめに
四月はじめ頃から在宅勤務になりました。新型コロナウイルスの恐怖にふるえつつ家に引きこもっております。
長期にわたる自宅警備で人と接しないことにすっかり慣れきってしまった今日このごろ。喉乾いてちょっとコンビニに出るのがもう怖い。勇気を出していざ外に出ると、道ゆく人がウイルスに感染してるんじゃないかと心配で心配で。そこで、Hololens2で見知らぬ通行人の体温を可視化して、安心して外に出られるような仕組みを検討してみました。
今回は最初に動画貼っておきます。
(↑クリックして動画再生。音声無し)
普通の2次元のサーモグラフィとは違って、壁の表面に温度が貼りついてる感じがわかりますでしょうか。温度とRGB画像を単に合成して「Hololens2でサーモグラフィ作った」でもいいんですが、せっかくの Hololens2 です。距離センサの情報も使って、温度を3次元空間にマッピングしてみました。今回はこれの製作記事です。
まずは温度センサ
本格的なサーモグラフィは高くて買えません。そもそもちゃんとしたサーモグラフィは大きくて重くてウエアラブルって感じでもない。そこでセイコーNPCから出てるお安い赤外線温度センサアレイ SMH-01B01ですよ。

ちっさ!
非接触で温度が測れるすごいやつです。今回はこれを使います。ネット通販で1万円もしません。解像度は 8x8ドットで、インターフェースは I2C です。
このモジュールには4ピンの ZHコネクタがついていて、ここから信号を取り出します。ZHコネクタに線を繋げるための圧着端子とハウジングは、日本橋(にっぽんばし)の共立電子などで買えます。まずは上の写真のように適当に電線を付けておきましょう。(実は今回の記事で一番難易度が高いのは ZH コネクタの圧着。1.5mmピッチの極小コネクタなので、よい圧着工具持ってないと無理ゲー。持っててもハードモード。かくいう私も半分ぐらい失敗しました。なので圧着ピンはどーんと20個ぐらい買っとくべし。安いし)
で、さっそく Hololens2 に繋ぎ... Hololens2 に I2C インターフェースはないので、一旦適当なマイコンに温度センサアレイを繋いで Hololens2 で使えるインターフェースに変換してやります。 今回は、BLE(Bluetooth Low Energy) を使います。

こんな感じで。
I2C <-> BLE変換用マイコンの選定
ぶっちゃけ Hololens2 は重たい。
ただでさえ重い Hololens2 にさらに重い外付け機器なんて本当はつけたくありません。ラズパイ?そんな消費電力でかいのバッテリーが重くなるので全力でお断りです。小さくて超低消費電力で通信機能もったマイコンということで、今回は STMicroelectronics 社製のマイコン、
STM32WB55 を使ってみます。
これは組み込み向けの省電力 ARM マイコンと無線通信モジュールが1チップに統合されてるやつで、普通のボタン電池ひとつで長時間稼働可能。ワンチップで完結して周辺部品不要ですので、自分で基板起こすと電源回路と無線アンテナ込み込みで500円玉ぐらいのサイズにできます。今回は試作なのでこのマイコンの開発評価ボード P-NUCLEO-WB55 を使います1。

IO ピンをたくさん立てたりしてる都合でちょっとボードがでかいですが、本体は左上の四角い1チップだけです。チップ上に技適マークがあることでマイコンと無線機能が1パッケージに封入されてるってはっきりわかります。こう見えてボタン電池駆動も余裕です2。裏面に電池スロットも付いてますので間違いない。
さっそくマイコンボードと温度センサを結線しましょう。

ピンの場所がよくわからない場合は、センサとボードの写真の配線色をよく見て真似してください。
I2C の信号線は、上記結線図に記載のように 適当な抵抗で 5V にプルアップします。温度センサのドキュメント中の参考回路図にはいろいろ部品付けるように書かれてますが、普通はプルアップだけでOK。STM32 の I2C ピンは 5V 直接入力対応ですのでレベル変換も不要。
STM32のプログラム
STM32 シリーズマイコンのプログラム開発には Mbed という開発環境が使えます。Web ベースの IDE 上で、 C++ でプログラムを書けます。

ローカルPCにコンパイラやライブラリなんかの開発環境入れる必要がないし、ストレージなんかも自前で用意しなくていい。煩わしい開発ソフトのアップデート3も気にしなくていいのですごく楽。しかもなんと無償!最高かよ!
Mbed (Web IDE) の使い方は他の IDE 使ったことあればすぐわかるはずです。そんなに多機能でもないし。もしわからないところがあればネット上に転がってる記事やらブログみてください。
マイコン上のプログラム実行環境としては、先月リリースされたばかりの Mbed OS 6.0 を使います。今回作るプログラムの範囲では特に Mbed OS 5.x と変わりません。
で、マイコン側のソースコード説明を書き・・・かけたのですが、この記事見に来た人の大多数は Hololens2 に釣られてきた人ですよね? Mbed や BLE を延々と語りだすときっと右上の×ボタンを押したくなるって知ってるので、空気を読んでさらっと要点だけ説明します。
まず、センサから値を取得するのは MbedOS 標準の I2C クラスを使えば特に難しいところはないので省略。BLE通信のところも基本的にドキュメントのサンプルコード通りでOK。説明が必要な工夫といえば、BLE通信の高速化のために温度データを圧縮してるところぐらい。
BLE遅い
BLE 通信は Low Energy でペアリング不要という大変魅力的な特徴を持ってます。強くプッシュしていきたいところですが、正直、遅いです。20バイトを超えるデータは複数パケットに分割されてしまうので特に遅い。今回使う温度センサの温度表現は1ピクセル当たり 16bit の整数なので、1回のデータ出力サイズは、8pixel x 8pixel x 2byte = 128byte、つまり7パケットにもなります。
今回使うセンサの仕様的には~250℃までの温度を扱えますが、体温計的に使う場合はそこまではいらない。そこで温度データのレンジを0~100℃に制限して、温度の内部表現を16bit -> 10bitに圧縮してます。これで一電文 80byte になりますので、4パケットで済むことになります。
圧縮方法は、10bit をバイトを跨いで詰め込んだストリームに…するとプログラムで扱うのが面倒なので、センサの1ライン分=数値8個(16byte)をセットにして、10byte に圧縮します。出力の最初の 8byte には各数値それぞれの下 8bit を格納、残り 2byte には、入りきらなかった 2bit を詰め込んでます。これはきたない!きたないけど速さがジャスティスなので仕方ないね。
// 16ビット温度を10ビットにクリッピング。
// なお、温度の内部表現は摂氏を10倍した整数値。
uint16_t clip10bit(int16_t val) {
uint16_t ctp; // 0-1000の範囲にクリッピングした温度
ctp = val < 0 ? 1022 : val; // LOW value は 1022
ctp = val > 1000 ? 1023 : ctp; // HIGH value は 1023
return ctp;
}
// センサから温度を取得してBLEのCharacteristicに設定
void get_temp(void)
{
int16_t w_temp[8 * 8];
uint8_t w_temp_comp[8 * 8 * 2 / 16 * 10]; // 16bit -> 10bitに圧縮用
// センサから温度読み出し
int ret1 = i2c.write(addr8bit, &CMD_READ128, 1, false);
int ret2 = i2c.read(addr8bit, (char *)w_temp, 64 * 2);
// 16bit -> 10bitにパッキング
// エンコード,デコードでバイトをまたいでビットふりわける処理書くのが面倒なので、
// 数字8個ごとに、下8bitを8byte分書いて、上2bit分を続く2byteに詰め込む形式とする。
memset(w_temp_comp, 0, sizeof w_temp_comp); // 出力バッファクリア
uint8_t *p_compbuf = w_temp_comp; // 出力位置ptr
for (int i = 0; i < 8 * 8; i++) {
uint16_t ctp = clip10bit(w_temp[i]); // 0-1000の範囲にクリッピングした温度
// 下8bit埋める処理
p_compbuf[i % 8] = (uint8_t)(ctp & 0x00ff); // 下8bitを入れる
// 上2bit埋める処理
if (i % 8 < 4) { // 前半4byte
p_compbuf[8] |= (uint8_t)((ctp & 0x0300) >> (2 + (i % 4) * 2));
} else { // 後半4byte
p_compbuf[9] |= (uint8_t)((ctp & 0x0300) >> (2 + (i % 4) * 2));
}
if (i % 8 == 7) { // その行の最後の数値処理したら行ポインタを次に送る
p_compbuf += 10;
}
}
// BLEのアトリビュート書き込み
ble_error_t berr1 = _server->write(_temp_char.getValueHandle(), (uint8_t *)w_temp, sizeof(_temp), false);
ble_error_t berr2 = _server->write(_temp_comp_char.getValueHandle(), w_temp_comp, sizeof(_temp_comp), false);
}
そんなこんなで。
Hololens2で空間に温度をつける
実は初代 Hololens でも Bluetooth で工作して遊んでました。しかし、UWP の BLE API まわりは仕様も実装もそびえ立つ○○というか、安定して動かすのに滅茶苦茶苦労しました。その頃の記憶がある人は Hololens で BLE っていうだけで残念さで胸がいっぱいになってることと思います。
しかし安心してください! Hololens2(正確には Windows 10 ビルド15063)から BLE スタックがリニューアルされて、非常にシンプルなIFで実装できるようになり、かつ見違えるほど安定して動くようになっています。ヤッター!
BLEでデータを取得する
具体的な実装としては、まずBLEハンドラのスクリプトを作ります。名前は BleHandler とします。BLEの初期化はこんな感じでやります。
public class BleHandler : MonoBehaviour
{
// 温度センサGATTデバイス名
const string TargetDeviceName = "GattServer";
// 温度センササービスGUID
readonly Guid TargetServiceGuid = new Guid("0000a600-0000-1000-8000-00805f9b34fb");
// 温度センサ(圧縮形式)Characteristic GUID
readonly Guid TargetCharGuid = new Guid("0000a613-0000-1000-8000-00805f9b34fb");
#if WINDOWS_UWP
private BluetoothLEAdvertisementWatcher bleWatcher;
// 温度取得用Characteristic. 毎回取得すると遅いので.
private static GattCharacteristic thermoChr;
#endif
// BLEデバイスが接続されてCharacteristicが使える状態ならtrue
private bool connected;
// UIスレッドに処理を戻す用
private SynchronizationContext context;
// Start is called before the first frame update
void Start()
{
context = SynchronizationContext.Current;
connected = false;
StartCoroutine(StartDevice());
Task task = Task.Run(() => {
ReadFromBLE();
});
}
private IEnumerator StartDevice()
{
// ちょっと待つ
yield return new WaitForSeconds(1);
#if WINDOWS_UWP
bleWatcher = new BluetoothLEAdvertisementWatcher()
{
ScanningMode = BluetoothLEScanningMode.Active
};
bleWatcher.Received += OnAdvertisementReceived;
// デバイススキャン開始
bleWatcher.Start();
#endif
}
// BLE Watcher のイベント受信ハンドラ
private async void OnAdvertisementReceived(BluetoothLEAdvertisementWatcher watcher, BluetoothLEAdvertisementReceivedEventArgs eventArgs)
{
/// *** 後述 ***
}
// BLE読みとりスレッド
private async void ReadFromBLE()
{
/// *** 後述 ***
}
}
Service や Characteristic の GUID は、マイコンボード側の UUID クラスで作った値を設定します。
プログラムで書く必要があることは BLE のデバイスを発見したとき用のイベントハンドラを登録して、Watcher を Start() するだけです。
Watcher のハンドラはこんな感じ。
#if WINDOWS_UWP
private async void OnAdvertisementReceived(BluetoothLEAdvertisementWatcher watcher, BluetoothLEAdvertisementReceivedEventArgs eventArgs)
{
if (!connected) {
BluetoothLEDevice device = await BluetoothLEDevice.FromBluetoothAddressAsync(eventArgs.BluetoothAddress);
if (device != null && device.Name == TargetDeviceName) {
// 温度センサデバイス見つけた!
// GATTサービスを取得
GattDeviceServicesResult gattRes = await device.GetGattServicesAsync();
if (gattRes.Status == GattCommunicationStatus.Success) {
foreach (var svc in gattRes.Services) {
// 温度センササービスを発見
if (svc.Uuid.Equals(TargetServiceGuid)) {
// Characteristicsを取得
GattCharacteristicsResult charRes = await svc.GetCharacteristicsAsync();
if (charRes.Status == GattCommunicationStatus.Success)
{
foreach (var chr in charRes.Characteristics) {
if (chr.Uuid.Equals(TargetCharGuid)) {
// 温度を読み取るCharacteristicを確保! 排他省略(未connect状態の時しか来ないし)
thermoChr = chr;
// 接続成功
connected = true;
break; // UUIDが一致するのはひとつだけなので
}
}
} else {
// なんかおかしい場合はやり直し
Debug.Log($" GattCharacteristicsResult Error! {charRes.Status}, {charRes.ProtocolError}");
}
break; // UUIDが一致するのはひとつだけなので
}
}
} else {
// なんかおかしい場合はやり直し
Debug.Log($" GattDeviceServicesResult Error! {gattRes.Status}, {gattRes.ProtocolError}");
}
}
}
}
#endif
Characteristic って何? GATT って何?ってあたりが気になる人もいるかと思います。BLE の用語なんですが、BLE の仕様をわかりやすく手短に説明できる気がしないので、BLE についてきちんと知りたい人はぐぐってください。詳しく解説してるページがたくさんヒットします。
というわけで細かいところを端折って説明すると、ようは発見した BLE デバイスが目的の温度センサデバイスかを名前で確認し、目的のデバイスであれば温度センサの値を取得するサービスを探してそこに接続する(=Characteristicを確保する)というだけのコードです。
実際の BLE Characteristic からのデータ取得はメインスレッドとは別のスレッド起こしてそっちでやります。
private async void ReadFromBLE()
{
await Task.Delay(3000); // どうせすぐには接続できない。すこし待つ
#if WINDOWS_UWP
for (;;) {
if (connected)
{
GattReadResult result;
try
{
// 値の読み取り。
result = await thermoChr.ReadValueAsync(BluetoothCacheMode.Uncached);
}
catch (global::System.Exception e)
{
connected = false; // 接続からやり直す場合はこう
await Task.Delay(1000);
continue; // リカバーする場合
//break; // 落とす場合
}
if (result.Status == GattCommunicationStatus.Success)
{
var reader = DataReader.FromBuffer(result.Value);
byte[] input = new byte[reader.UnconsumedBufferLength];
reader.ReadBytes(input);
// 数値の伸長バッファ
Int16[] tVal = new Int16[8*8]; // センサの解像度は 8 x 8
// *** ここで10bit->16bitに数値を復元***
// *** 処理はマイコン側の逆なので省略 ***
context.Post(__ => {
// ビジュアライズ
this.GetComponent<TempVizualizer>().Visualize(tVal);
}, null);
} else {
// なんかエラーぽい。接続からやりなおし。
connected = false;
}
}
// 高頻度で取得しようとしても無駄無駄無駄
await Task.Delay(100);
}
#endif
}
ReadValueAsync() で、BLE の属性値が取得できます。簡単!
取得した値は、新たに作る TempVisualizer スクリプトの Visualize() 関数を呼んで、そっちで処理してもらいます。
TempVisualizer スクリプトは、BleHandler スクリプトを Add した空の GameObject に横並びで Add しておきます。

取得した温度をビジュアライズする
言葉でうまく伝えられる気がしないので絵で。

こんな感じでやってみます。
Hololens2 の機能で空間マッピングした壁や物の表面に、温度を貼りつけて表示するには、頭の位置から前方に向けて RayCast を飛ばして、コライダーが衝突した部分に温度表示用のマーカーを置いていけばよいはずです。RayCast を飛ばす方向は真正面ではなく、各センサのピクセル位置に対応する方向にむける必要があるので、全部の照準をひとつにまとめた非表示の親ゲームオブジェクトを、頭の真正面に Head Lock (常に頭の向きに追随)させます。
TempVisualizer の Target にアサインしている “TempBoard” は、Spatial Mapping で得られた表面上に RayCast を飛ばす方向をコントロールする的(まと)です。同じく “Marker” は、RayCast が衝突した場所に配置する、温度表示用のマーカーの prefab です。
TempBoardの実体は、空のゲームオブジェクトの配下に、こんな感じに Cube を 8x8個、敷きつめたものです。

頭の位置から前方 1m の距離で、各ピクセルの実寸が 9.1 cm になるように、各 Cube の Scale 値を 0.091 に設定すれば、正確なアラインになります。
Cube に向けて RayCast したときに Target に衝突しないよう各 Cube の Collider はオフにしておきます。
なお、普通のサーモグラフィっぽい表示でいい場合は、ここで TempBoard オブジェクトを Head Lock して、各 Cube に対して色を変えるスクリプト “TempColorChanger"(後述)をセットしてやればOK。
TempVisualizer の実装はこんな雰囲気になります。
public class TempVizualizer : MonoBehaviour
{
[SerializeField]
GameObject target; // サーモグラフィの的になる壁
[SerializeField]
GameObject marker; // 個別の温度を表示するマーカー
private float timeElapsed;
private Queue<Vector3> posHistory; // 過去のカメラ位置
private Queue<Quaternion> rotHistory; // 過去のカメラ向き
// Start is called before the first frame update
void Start()
{
posHistory = new Queue<Vector3>();
rotHistory = new Queue<Quaternion>();
}
// Update is called once per frame
void Update()
{
timeElapsed += Time.deltaTime;
if (timeElapsed >= 0.1f) // 0.1秒ごとに...
{
// カメラ位置と向きの履歴をとっていく
// I2Cで 0~0.5sec, BLEで1.5sec前後の遅延があるため、 1.7秒程度前の位置に温度を貼り付けたいので
// I2CとBLEは非同期なので遅延はばらつくため、あんまり厳密に調整する必要もない。
posHistory.Enqueue(Camera.main.transform.position);
rotHistory.Enqueue(Camera.main.transform.rotation);
// 約1.7秒以上経過したデータを消す
while (posHistory.Count > 17)
{
posHistory.Dequeue();
rotHistory.Dequeue();
}
timeElapsed = 0.0f;
}
}
public void Visualize(Int16[] tp)
{
// 温度を取得したときに見ていた場所の前面にターゲットの位置を動かす
Vector3 pastCamPos;
Quaternion pastCamRot;
if (posHistory.Count > 0) {
pastCamPos = posHistory.Dequeue();
pastCamRot = rotHistory.Dequeue();
} else {
pastCamPos = Camera.main.transform.position;
pastCamRot = Camera.main.transform.rotation;
}
target.transform.position = pastCamPos + pastCamRot * new Vector3(0, 0, 1.0f);
target.transform.rotation = pastCamRot;
if (tp.Length >= 8*8)
{
// 温度配列を画面に表現する
for (int x = 0; x < 8; x++)
{
for (int y = 0; y < 8; y++)
{
float tempVal;
// 10bit符号なし整数にpackしてるのでHIGH VALUEとLOW VALUEは1001以降の値をアサインしてる。
switch (tp[x + y * 8]) {
case 1022:
tempVal = -1.0f;
break;
case 1023:
tempVal = 101.0f;
break;
default:
tempVal = (float)tp[x + y * 8] / 10f;
break;
}
GameObject targetPixel = target.transform.Find($"x{x}/y{y}").gameObject;
//*** 3次元空間に張り付ける場合 ***
RaycastHit hit;
if (Physics.Raycast(pastCamPos, targetPixel.transform.position, out hit, 10f))
{
// 温度表示オブジェクトを置く
GameObject markerInstance = Instantiate(marker, hit.point, pastCamRot);
markerInstance.transform.Find("Sphere").GetComponent<TempColorChanger>().TempVal = tempVal;
markerInstance.transform.Find("TempText").GetComponent<TempTextChanger>().TempVal = tempVal;
}
}
}
}
}
}
温度センサの値が Hololens2 に入ってくるまでには、I2C通信と BLE 通信にかかるオーバーヘッド分の遅延が入ります。Hololens2 でデータを受信したその時に見えてる場所に単純にターゲットを設定してしまうと、実際に過去に温度を計測した場所と温度マーカーを貼りつける場所にずれが発生しますので、過去の頭の位置と方向の履歴をもたせて、センサが値を取得した時点の位置に貼りつけてます。
温度表示に使っている marker は、今回は Sphere + 3D Text を合成した prefab にしました。工夫してカッコイイ見た目にしてください。で、それぞれにつけたスクリプト中の .TempVal プロパティを設定することで、温度の数値を表示したり、温度に応じた色を表示したりできるようにしています。各自工夫してカッコイイエフェクトを付けてほしいので表現部分のコードは省略。(決して説明が面倒くさくなったわけではない)
工作の時間
ここからは工作のお時間です。温度センサを Hololens2 に取りつけます。

まさにやっつけ仕事。セロテープはないわー。
センサ背面の絶縁処理を兼ねて、こんな感じのホルダを作りました。適当な空き箱を切り貼りして。

作成時の注意点としては、
- ぴったり納めるには1mm以下の寸法精度が必要。厚紙を折った時の紙の厚さを考慮して寸法の微調整は要ります。
- Hololens2への取り付け時に左右の角度は容易に調整できますが仰角は変えづらいので調整可能にしておく。
なお、センサを横向きに付けてるのは、センサの位置を深度カメラの視点の高さになるべく近づけるため。この向きが一番レンズを低く構えられます。配線を上出しにする向きでもいいのですが、そうするとHololens2のバイザー部をフリップアップするとセンサ基盤が頭にぶつかってしまいますので注意。
センサ基板には取付穴がありますのでプロダクションユースの場合はネジで固定するようなモノを作るとよいでしょう。紙での試作できっちりキャリブレーションできたら3Dプリンタでちゃんとしたホルダを作りたい(CAD 苦手なんでたぶん1日仕事)。
センサをホルダに取り付けて、ホルダを養生テープなどで Hololens2 に装着したら、先ほど作った Hololens2 アプリをおもむろに起動します。特にペアリングなどの準備は要りませんし、アプリとセンサとどっちを先に起動するかとかも気にしなくて大丈夫です。数秒後には、記事冒頭の動画のような立体サーモグラフィが見られます。暖かい物体や冷たい物体を用意して、センサ仰角のキャリブレーションを行えば完成です。
Hololens2をつけて街に出よう!
この装置を使って道行く人の体温を見るには2つ課題があります。
- Hololens2を被った見た目がちょっとあれで。あまつさえ変な手作り装置を張り付けた状態だと見た目がほんのすこしあやしい。職質されるリスクが拭えない。
- Hololens2が3D空間を認識する速度がやや遅い。Hololens2 は壁面や床などの固定された対象のポリゴンを安定して提供するのは得意だが、歩いてる人など動きのある物体の認識は苦手。
- 表現方法の都合上、リアルの視界がいくらか遮られる。歩きスマホ禁止条例なんてのが制定されるご時世に歩きHololensは許容されうるか。
ひとつ目の問題に関しては世の中にウエアラブル端末が蔓延すればすぐ問題なくなるはず。一昔前はスマホでハンズフリー通話しながら歩いてる人とか独り言つぶやいてるみたいに見えたけど今は慣れましたよね?それと同じでしょう。
ふたつめの、Hololens2 の空間マッピング機能が、動く物体をリアルタイム認識するのが苦手な問題は装置の仕様なのでいかんともしがたく。ただ、 この連載の初回 で試したようなボディトラッキング系の技術を使ってリアルタイム人体キャプチャできる計算能力はあるので、標準のSpatial Mappingとは別の技術を併用してがんばれば可能(私はがんばれない)。
まとめ
元の目的はともかく、任意のセンサで取得した情報を Hololens2 の深度情報と合成して三次元表現にできました。最初は8x8ピクセルなんていうがっかり解像度のセンサでまともな見た目になるか心配でしたが、見せ方を工夫することで結構見れる感じになりました。
温度センサに限らずいろんなセンサ情報を Hololens2 に合成できると夢がひろがります。たとえば指向性マイクアレイ使って機械から異音が出てる部位を可視化したり、超音波センサでキクガシラコウモリの居場所を探したり4とか。
みなさんも面白おかしい応用を思いついたらぜひ私に教えてください!

