心臓を人に見せる方法

#概要

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

折角なので、[Looking Glass勉強会](https://lookingglass.connpass.com/event/159451/) で見せびらかそう。


#システム構成
## 出力ハード： Looking Glass
可視化部分は [Looking Glass](https://lookingglassfactory.com/tech/) を使えば裸眼で 3D の心臓を映し出せる。
[Standard](https://lookingglassfactory.com/product/8-9) が 9 inch 程度のサイズと、丁度良い。
イメージは [paper](https://ja.wikipedia.org/wiki/Pepper_(%E3%83%AD%E3%83%9C%E3%83%83%E3%83%88)) くんの胸ディスプレイ。
　　　　<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/343826/0f0002b3-9d21-545f-ccad-9789d951c4b3.png" width="20%">

## 入力ハード： 反射型脈拍センサ

心拍センサのうち、反射型が千円オーダーと安く、Arduinoで簡単に扱えて便利。
動作原理は皮膚表面からLEDを照射し、反射光の微妙な違いを検出して脈拍を割り出す。
脈拍に応じて血管が収縮し、血液中の酸化ヘモグロビンの緑色光の吸収特性を利用している。

- 参考：ROHM 反射型脈拍センサ<br>
https://www.rohm.co.jp/electronics-basics/sensors/sensor_what3

スイッチサイエンスやAmazoneで探せば、色々出ている。

- SparkFun　SEN-11574 3430円 紫線<br>
https://www.switch-science.com/catalog/1135/

- Dolity 950円 赤線<br>
[https://www.amazon.co.jp/Dolity-脈拍センサー-パルスセンサモジュール-心拍-耳たぶ-ハートレート-心拍数をテスト-3-3V〜5V/dp/B079HW2PPS/ref=sr_1_17](https://www.amazon.co.jp/Dolity-%E8%84%88%E6%8B%8D%E3%82%BB%E3%83%B3%E3%82%B5%E3%83%BC-%E3%83%91%E3%83%AB%E3%82%B9%E3%82%BB%E3%83%B3%E3%82%B5%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB-%E5%BF%83%E6%8B%8D-%E8%80%B3%E3%81%9F%E3%81%B6-%E3%83%8F%E3%83%BC%E3%83%88%E3%83%AC%E3%83%BC%E3%83%88-%E5%BF%83%E6%8B%8D%E6%95%B0%E3%82%92%E3%83%86%E3%82%B9%E3%83%88-3-3V%E3%80%9C5V/dp/B079HW2PPS/ref=sr_1_17)
- KKHMF 2個で795円 緑線<br>
[https://www.amazon.co.jp/KKHMF-パルスセンサモジュール-心拍数をテスト-3-3V～5V-Arduinoと互換/dp/B083DSZZJJ/ref=sr_1_35](https://www.amazon.co.jp/KKHMF-%E3%83%91%E3%83%AB%E3%82%B9%E3%82%BB%E3%83%B3%E3%82%B5%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB-%E5%BF%83%E6%8B%8D%E6%95%B0%E3%82%92%E3%83%86%E3%82%B9%E3%83%88-3-3V%EF%BD%9E5V-Arduino%E3%81%A8%E4%BA%92%E6%8F%9B/dp/B083DSZZJJ/ref=sr_1_35)

他にも幾つか売られているが、評価目的で上記３種を購入してみた。
（本当は勉強会の直前の初物一発勝負の突貫工事なため、冗長化目的もある）

## 制御機器：　Arduino + ノートPC
センサーからの信号はセンサの推奨用法に従い Arduino で受ける。
Looking Glass の描画はウェアラブルにするためノートPCを用いる。
その間は USB によるシリアル通信で繋げれば動く。

心臓は Unity Store から幾つか探してみたら Animated Heart が適度にリアルだった。
アニメーションとして実装され、その速度を変えることで心拍を可視化できる。
https://assetstore.unity.com/packages/3d/characters/animated-heart-66221?locale=ja-JP
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">リアルな心臓をゲットしたぜー<br><br>Unity 楽しい ヽ(๑╹ω╹๑ )ノ <a href="https://t.co/t2BbN99RPP">pic.twitter.com/t2BbN99RPP</a></p>&mdash; Limg (@LimgTW) <a href="https://twitter.com/LimgTW/status/1224780643688517632?ref_src=twsrc%5Etfw">February 4, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

#実装
## Arduino
Switch Science の心拍センサーで公開している [Arduino Code](https://storage.googleapis.com/google-code-archive-downloads/v2/code.google.com/pulse-sensor/PulseSensorAmped_Arduino_1dot1.zip) をダウンロードすると、２つのファイルが得られる。

- PulseSensorAmped_Arduino_1dot1.ino はサンプルコードであり、今回はこれを改造した。
- Interrupt.ino は信号の読み取りと脈拍計算の詳細。今回は触らず利用するだけとした。<br>
ファイルの先頭を見れば分かるように、変数は全て静的に定義されている。<br>
このため、複数のセンサーを使う場合は、異なるメモリ領域を使わせるクラス化が必要になる。

```cpp: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 に書き出す。サンプルコードでは変数毎に改行して送っているが、デバッグや受取側を簡単にするため、カンマ区切りで数値を羅列するスタイルに改造した。

```c++:PulseSensorAmped_Arduino_1dot1.ino
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のシリアルプロッタの使い方【グラフを表示】<br>
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 イベントを起動する。

```c#:Serial.cs
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 で動く心臓が出来上がる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/343826/f3a82412-24e7-43a2-d6b5-fcf75e85e27c.png)

制御コードは AnimeSpeed.cs として実装した。
制御対象 Animation anime は任意の手段で参照できれば良いが、AnimeSpeed 自体を対象のコンポンネントとしたため、``Start()`` の中で GetComponent<Animation>() を使って取得した。
続けて、``Serial serial``に受信イベントを登録して終了。
``DebugDump``は制御すべき Animation の中の対象。real heart animated は１つのアニメーションしか実装してないことを確認するために書いておいた。稼働時には必要ない。

``const float animeLength = 0.9666667f; `` はアニメーションの周期。
ほぼ 1s 周期になっているが、アニメーションのインスペクタを確認したらこの値になってた。
30fps で 30frame 定義されているが、Unity の Animation ウィンドウで確認したら #29 に長さが無く #0 と重なっているのが分かる。そのため、 29frame/30fps＝0.9666667s になる。``const float factor``は分単位の脈拍 BMP からアニメーション速度を計算するための変換係数である。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/343826/af567f11-68e1-f1ef-c300-c5f0ef5c927b.png)


受信は ``void OnDataReceived(...)`` で実装し、カンマ区切りでデータから生データ``raw``と心拍``BPM``を切り出し、``raw``は後述するグラフの描画に、``BPM``はアニメーションの速度調整に使う。
速度調整は、``Animation anime``の各``AnimationState state``に対し、``state.speed``を設定すれば良い。厳密には、使うステート名を特定して``anime['StateName'].speed = ...``のように設定する必要があるが、ここは適当に実装した。

安定化の工夫として、異常値検出と間引きを行った。センサの装着が不十分な場合や激しい動作でズレた場合、``BPM``に異常に大きい値が現れる。そのため、人間の心拍では出せない 90拍/分 をアニメーションを制御する上限値とした。また、アニメーションを細かく制御してもしょうがないので、ざっくり10回に1回の頻度に間引いた。

```c#:AnimeSpeed.cs
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]``を追加したデータに基づいて書き換える。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">グラフっぽい何かができた。<a href="https://twitter.com/hashtag/Unity?src=hash&amp;ref_src=twsrc%5Etfw">#Unity</a> <a href="https://t.co/nyT7TmfpiE">pic.twitter.com/nyT7TmfpiE</a></p>&mdash; Limg (@LimgTW) <a href="https://twitter.com/LimgTW/status/1226630562351218688?ref_src=twsrc%5Etfw">February 9, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

```c#:LineGraph.cs
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!
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Heartbeat! <a href="https://t.co/ym0J86mrUZ">pic.twitter.com/ym0J86mrUZ</a></p>&mdash; Limg (@LimgTW) <a href="https://twitter.com/LimgTW/status/1226632046455320576?ref_src=twsrc%5Etfw">February 9, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

# 完成
ギリギリ完成できたので、[第4回 Looking Glass勉強会](https://lookingglass.connpass.com/event/159451/) に乗り込む！LT枠か展示枠を取って堂々と参加しても良かったが、閃いたのが直前過ぎて、実質上一夜漬けで作ったようなもので、とても展示を確約できなかった。幸い、るきベンは野良展示自体を認めているため、堂々と野良で参加できた。心の広い主催者に感謝！

そして自分で撮ってないから、人のツイートを借用！
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">閲覧注意<br><br>心臓！(3DCG)<br>リアルタイムで自分の鼓動と連動してるらしい。<a href="https://twitter.com/hashtag/%E3%82%8B%E3%81%8D%E3%81%B9%E3%82%93?src=hash&amp;ref_src=twsrc%5Etfw">#るきべん</a> <a href="https://t.co/ZMuw4rKqGt">pic.twitter.com/ZMuw4rKqGt</a></p>&mdash; シロフード (@sirohood_exp) <a href="https://twitter.com/sirohood_exp/status/1226830245682040832?ref_src=twsrc%5Etfw">February 10, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

これで心を開い人になれた。