概要
世の中「他人に心を開きなさい」と良く言われが、物理的にそんなことできやしない。
ところが、今やウェアラブルデバイスが進化し、センサーも安いから、できるようになった。
というわけで、心臓を可視化してみた。
折角なので、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
これで心を開い人になれた。