#概要
世の中「他人に心を開きなさい」と良く言われが、物理的にそんなことできやしない。
ところが、今やウェアラブルデバイスが進化し、センサーも安いから、できるようになった。
というわけで、心臓を可視化してみた。
折角なので、Looking Glass勉強会 で見せびらかそう。
#システム構成
出力ハード: Looking Glass
可視化部分は Looking Glass を使えば裸眼で 3D の心臓を映し出せる。
Standard が 9 inch 程度のサイズと、丁度良い。
イメージは paper くんの胸ディスプレイ。
入力ハード: 反射型脈拍センサ
心拍センサのうち、反射型が千円オーダーと安く、Arduinoで簡単に扱えて便利。
動作原理は皮膚表面からLEDを照射し、反射光の微妙な違いを検出して脈拍を割り出す。
脈拍に応じて血管が収縮し、血液中の酸化ヘモグロビンの緑色光の吸収特性を利用している。
- 参考:ROHM 反射型脈拍センサ
https://www.rohm.co.jp/electronics-basics/sensors/sensor_what3
スイッチサイエンスやAmazoneで探せば、色々出ている。
-
SparkFun SEN-11574 3430円 紫線
https://www.switch-science.com/catalog/1135/ -
Dolity 950円 赤線
https://www.amazon.co.jp/Dolity-脈拍センサー-パルスセンサモジュール-心拍-耳たぶ-ハートレート-心拍数をテスト-3-3V〜5V/dp/B079HW2PPS/ref=sr_1_17 -
KKHMF 2個で795円 緑線
https://www.amazon.co.jp/KKHMF-パルスセンサモジュール-心拍数をテスト-3-3V~5V-Arduinoと互換/dp/B083DSZZJJ/ref=sr_1_35
他にも幾つか売られているが、評価目的で上記3種を購入してみた。
(本当は勉強会の直前の初物一発勝負の突貫工事なため、冗長化目的もある)
制御機器: Arduino + ノートPC
センサーからの信号はセンサの推奨用法に従い Arduino で受ける。
Looking Glass の描画はウェアラブルにするためノートPCを用いる。
その間は USB によるシリアル通信で繋げれば動く。
心臓は Unity Store から幾つか探してみたら Animated Heart が適度にリアルだった。
アニメーションとして実装され、その速度を変えることで心拍を可視化できる。
https://assetstore.unity.com/packages/3d/characters/animated-heart-66221?locale=ja-JP
リアルな心臓をゲットしたぜー
— Limg (@LimgTW) February 4, 2020
Unity 楽しい ヽ(๑╹ω╹๑ )ノ pic.twitter.com/t2BbN99RPP
#実装
Arduino
Switch Science の心拍センサーで公開している Arduino Code をダウンロードすると、2つのファイルが得られる。
- PulseSensorAmped_Arduino_1dot1.ino はサンプルコードであり、今回はこれを改造した。
- Interrupt.ino は信号の読み取りと脈拍計算の詳細。今回は触らず利用するだけとした。
ファイルの先頭を見れば分かるように、変数は全て静的に定義されている。
このため、複数のセンサーを使う場合は、異なるメモリ領域を使わせるクラス化が必要になる。
volatile int rate[10]; // used to hold last ten IBI values
volatile unsigned long sampleCounter = 0; // used to determine pulse timing
volatile unsigned long lastBeatTime = 0; // used to find the inter beat interval
volatile int P =512; // used to find peak in pulse wave
volatile int T = 512; // used to find trough in pulse wave
volatile int thresh = 512; // used to find instant moment of heart beat
volatile int amp = 100; // used to hold amplitude of pulse waveform
volatile boolean firstBeat = true; // used to seed rate array so we startup with reasonable BPM
volatile boolean secondBeat = true; // used to seed rate array so we startup with reasonable BPM
Interrupt.ino の挙動は、interruptSetup()
を呼び出すことで arduino のタイマを使った割り込み処理を開始し、後は割り込み度に PulseSensorAmped_Arduino_1dot1.ino で宣言される BPM
やSignal
、IBI
に値を書き込む。大量の volatile 宣言は一方的な割り込み処理のための最適化抑制である。
PulseSensorAmped_Arduino_1dot1.ino の処理は簡単で、setup()
の中で interruptSetup()
を呼び出し、loop()
の中で BPM
やSignal
、IBI
の値を Serial に書き出す。サンプルコードでは変数毎に改行して送っているが、デバッグや受取側を簡単にするため、カンマ区切りで数値を羅列するスタイルに改造した。
void loop(){
Serial.print(Signal);
Serial.print(",");
Serial.print(BPM);
Serial.print(",");
Serial.print((BPM < 90) ? 600 : 0);
Serial.println("");
ledFadeToBeat();
delay(20);
}
サンプリングごとにデータをカンマ区切りで出力すると、Arduino IDE の付随するプロッタが利用できるようになる。プロッタはデータをグラフとして可視化してくれる便利ツールである。
- 参考: Arduino IDEのシリアルプロッタの使い方【グラフを表示】
https://miraiworks.org/?p=5670
Unity / Serial
Unity 側の実装は System.IO.Ports.SerialPort を利用した。
- 参考:Microsoft / Docs / .NET / .NET API ブラウザー / System.IO.Ports / SerialPort
https://docs.microsoft.com/ja-jp/dotnet/api/system.io.ports.serialport?view=dotnet-plat-ext-3.1
そのラッパクラス Serial.cs を作った。Serial クラスの挙動は単純で、インスペクタで設定したポートとレートで接続し、受信する度に OnDataReceived イベントを起動する。
using UnityEngine;
using System.Collections;
using System.IO.Ports;
using System.Threading;
public class Serial : MonoBehaviour
{
public delegate void SerialDataReceivedEventHandler(string message);
public event SerialDataReceivedEventHandler OnDataReceived;
public string port = "COM5";
public int rate = 115200;
private SerialPort serial;
private Thread thread;
private bool isRun = false;
private string message = "";
private bool isReceived = false;
void Awake()
{
Open();
}
void Update()
{
if (isReceived) {
isReceived = false;
if (OnDataReceived != null) OnDataReceived(message);
}
}
void OnDestroy()
{
Close();
}
private void Open()
{
serial = new SerialPort(port, rate);
serial.Open();
isRun = true;
thread = new Thread(Read);
thread.Start();
Debug.Log($"Serial Open[{port}]: " + (serial.IsOpen ? "Success" : "Failure"));
}
private void Close()
{
isReceived = false;
isRun = false;
if (thread != null && thread.IsAlive) {
thread.Join();
}
if (serial != null && serial.IsOpen) {
serial.Close();
serial.Dispose();
}
}
private void Read()
{
while (isRun && serial != null && serial.IsOpen) {
try {
message = serial.ReadLine();
isReceived = true;
} catch (System.Exception x) {
Debug.LogWarning(x.Message);
}
}
}
public void Write(string message)
{
try {
serial.Write(message);
} catch (System.Exception x) {
Debug.LogWarning(x.Message);
}
}
}
Unity / Animation
Animated Heart を Asset Store からインストールすると、Assets に animated heart folder なるフォルダが出来て、中の model に real heart animated がアニメーションとして格納されている。これを Scene にドラッグするだけで、約 60Hz で動く心臓が出来上がる。
制御コードは AnimeSpeed.cs として実装した。
制御対象 Animation anime は任意の手段で参照できれば良いが、AnimeSpeed 自体を対象のコンポンネントとしたため、Start()
の中で GetComponent() を使って取得した。
続けて、Serial serial
に受信イベントを登録して終了。
DebugDump
は制御すべき Animation の中の対象。real heart animated は1つのアニメーションしか実装してないことを確認するために書いておいた。稼働時には必要ない。
const float animeLength = 0.9666667f;
はアニメーションの周期。
ほぼ 1s 周期になっているが、アニメーションのインスペクタを確認したらこの値になってた。
30fps で 30frame 定義されているが、Unity の Animation ウィンドウで確認したら #29 に長さが無く #0 と重なっているのが分かる。そのため、 29frame/30fps=0.9666667s になる。const float factor
は分単位の脈拍 BMP からアニメーション速度を計算するための変換係数である。
受信は void OnDataReceived(...)
で実装し、カンマ区切りでデータから生データraw
と心拍BPM
を切り出し、raw
は後述するグラフの描画に、BPM
はアニメーションの速度調整に使う。
速度調整は、Animation anime
の各AnimationState state
に対し、state.speed
を設定すれば良い。厳密には、使うステート名を特定してanime['StateName'].speed = ...
のように設定する必要があるが、ここは適当に実装した。
安定化の工夫として、異常値検出と間引きを行った。センサの装着が不十分な場合や激しい動作でズレた場合、BPM
に異常に大きい値が現れる。そのため、人間の心拍では出せない 90拍/分 をアニメーションを制御する上限値とした。また、アニメーションを細かく制御してもしょうがないので、ざっくり10回に1回の頻度に間引いた。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class AnimeSpeed : MonoBehaviour
{
private Animation anime;
public Serial serial;
public LineGraph graph;
void Start()
{
if (!serial) Debug.LogWarning("serial == null");
anime = GetComponent<Animation>();
if (serial) serial.OnDataReceived += OnDataReceived;
DebugDump();
}
const float animeLength = 0.9666667f;
const float factor = 1 / (animeLength * 60);
int thinning = 0;
void OnDataReceived(string message)
{
var data = message.Split(',');
if (data.Length < 2) return;
int raw = 0;
int BPM = 0;
try {
raw = int.Parse(data[0]);
BPM = int.Parse(data[1]);
graph.SetData((raw - 250) * 0.01f);
if (BPM < 90 && thinning-- == 0) {
thinning = 10;
SetSpeed(BPM * factor);
}
} catch (System.Exception e) {
Debug.LogWarning(e.Message + $" @ {data[0]}\t{data[1]}");
}
}
void SetSpeed(float factor) {
foreach (AnimationState state in anime)
{
state.speed = factor;
}
//Debug.Log($"anime.speed = {factor}");
}
void DebugDump() {
string log = $"DebugDump:\n Animation[{anime.ToString()}]";
foreach (AnimationState state in anime)
{
log += $"\n AnimationState:[{state.name}] @{state.length}s";
}
Debug.Log(log);
}
}
Unity / LineGraph
Arduino の IDE のシリアルプロッタで raw 値を可視化して見ると、厳密には違うだろうが心電図っぽい波形をだしているのが分かる。折角なので、心臓のアニメーションの背景に心電図擬きでも出せば、分かり易いかと考えた。グラフを描く Asset も探せば出てくるだろうが、今回は時間進行するアニメーションをしたいのと、もう明るくなってるのでサクッと作ることにした。
実装は、グラフを折線で表現し、ここの線分を Line クラスとして実装した。
ここの線分はPrimitiveType.Sphere
を両端に持つPrimitiveType.Cylinder
として実装し、
void SetPos(Vector3 start, Vector3 end)
で端点を指定しての移動、そして、
void Move(Vector3 velocity)
による平行移動をサポートする。
class LineGraph : MonoBehaviour
は Line[] Graph = new Line[LineSize] を静的に抱え、
void SetData(float value)
でデータを追加される度にグラフ全体を併進させ、
先頭にある線分Graph[index]
を追加したデータに基づいて書き換える。
グラフっぽい何かができた。#Unity pic.twitter.com/nyT7TmfpiE
— Limg (@LimgTW) February 9, 2020
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class LineGraph : MonoBehaviour
{
public Material material;
public float width = 0.1f;
public class Line {
private GameObject startSphere;
private GameObject endSphere;
private GameObject cylinder;
private GameObject[] elementArray;
private Vector3 start;
private Vector3 end;
public Material material;
public float width = 0.05f;
public Line(Vector3 start, Vector3 end, Material material, Transform transform, float width) {
this.start = start;
this.end = end;
this.material = material;
this.width = width;
startSphere = GameObject.CreatePrimitive(PrimitiveType.Sphere);
endSphere = GameObject.CreatePrimitive(PrimitiveType.Sphere);
cylinder = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
elementArray = new GameObject[3];
elementArray[0] = startSphere;
elementArray[1] = endSphere;
elementArray[2] = cylinder;
if (material) foreach(var e in elementArray) {
e.GetComponent<Renderer>().material = material;
e.transform.SetParent(transform);
}
SetPos(start, end);
}
public void SetPos(Vector3 start, Vector3 end) {
SetSphere(startSphere, start);
SetSphere(endSphere, end);
SetCylinder(cylinder, start, end);
}
public void Move(Vector3 velocity) {
foreach(var e in elementArray) {
e.transform.localPosition += velocity;
}
}
private void SetSphere(GameObject tartet, Vector3 pos) {
tartet.transform.localPosition = pos;
tartet.transform.localScale = new Vector3(width, width, width);
}
private void SetCylinder(GameObject tartet, Vector3 start, Vector3 end) {
tartet.transform.localPosition = (start + end) / 2;
float radius = Vector3.Distance(start, end) / 2;
tartet.transform.localScale = new Vector3(width, radius, width);
var e = tartet.transform.localEulerAngles;
var a = Mathf.Atan2(end.y - start.y, end.x - start.x) / Mathf.PI * 180;
e.z = a - 90;
tartet.transform.localEulerAngles = e;
}
};
private const int LineSize = 80;
private Line[] Graph = new Line[LineSize];
private int index = 0;
void Start()
{
//var Line = new Line(new Vector3(0,0,0), new Vector3(1,2,0), material);
Vector3 start = new Vector3(0,0,0);
Vector3 end;
for(int i = 0; i < LineSize; i++) {
end = start;
start = new Vector3(i*0.1f, Mathf.Sin(Mathf.PI * i * 0.1f), 0);
Graph[i] = new Line(start, end, material, this.transform, width);
}
}
private Vector3 oldPos = new Vector3(0,0,0);
public Vector3 velocity = new Vector3(-0.1f, 0, 0);
public void SetData(float value) {
foreach(var line in Graph) {
line.Move(velocity);
}
Vector3 newPos = new Vector3(0, value, 0);
Graph[index].SetPos(oldPos, newPos);
oldPos = newPos;
oldPos += velocity;
index++;
if (index >= LineSize) index = 0;
}
}
Heart と Beat を合わせると HeartBeat!
Heartbeat! pic.twitter.com/ym0J86mrUZ
— Limg (@LimgTW) February 9, 2020
完成
ギリギリ完成できたので、第4回 Looking Glass勉強会 に乗り込む!LT枠か展示枠を取って堂々と参加しても良かったが、閃いたのが直前過ぎて、実質上一夜漬けで作ったようなもので、とても展示を確約できなかった。幸い、るきベンは野良展示自体を認めているため、堂々と野良で参加できた。心の広い主催者に感謝!
そして自分で撮ってないから、人のツイートを借用!
閲覧注意
— シロフード (@sirohood_exp) February 10, 2020
心臓!(3DCG)
リアルタイムで自分の鼓動と連動してるらしい。#るきべん pic.twitter.com/ZMuw4rKqGt
これで心を開い人になれた。