リアルタイム心拍LEDビジュアライザー – ECGパワード
部品と材料
| 部品 | 数量 | 備考 |
|---|---|---|
| uECGデバイス | 1 | オープンソース着用型ECGデバイス |
| Arduino Nano R3 | 1 | |
| Adafruit NeoPixelリング: WS2812 5050 RGB LED | 1 | 16ピクセル |
| リチウムイオンバッテリー 1000mAh | 1 |
必要な工具と機器
- はんだごて
プロジェクト概要
長年、心拍に合わせて光るLED作品を作りたいと思っていました(じっとしているときだけでなく、動いているときも、たまに拍動をスキップするような本物の心拍で)。これが驚くほど難しく、何年も試行錯誤して失敗し続けました。でももう違います!
実は重い処理はすべてuECGがやってくれます。これは小型の着用型ECGデバイスで、オープンソースかつArduinoフレンドリーな出力ピン(心拍ごとにHigh/Lowが切り替わる)を備えています。このピン状態を処理するのは、生のECG信号を処理するより遥かに簡単で、これを最大限活用しました。
更新: 第2弾プロジェクト(無線リンクでデータ受信版)もチェックしてみてください。
1. 回路図
デジタル信号のみを扱うため非常にシンプルです。しかし着用型として使うなら、ほとんどの接続をはんだ付けしたほうが信頼性が高く(小型化も可能)。クイックテストなら不要ですが、激しい運動中に着用するなら強く推奨します。
- LEDリングのDIピン → Arduino D11(コードで変更可)
- uECGのDRVピン → Arduino D3(コードで変更可)
- バッテリー+ → Arduino 5V、LEDリング 5V入力
- バッテリー- → Arduino GND、リング GND、uECG GND
LiPoバッテリーを直接5V入力に接続しています。間違いではありません。Vinに接続すると電圧レギュレーターのドロップで安定動作しません。Arduinoは入力電圧が3.4V以上なら安定動作します。LiPoは満充電4.2Vから始まり、3.4Vになるのは残量15%未満のとき。したがって〜200mAh以上のバッテリーなら十分な動作時間が得られます。バッテリーはコネクター経由で接続してください(充電のために着脱する必要があるため)。
2. コード
プログラムはシンプルに動作します:D3ピンを常時読み取り、変化を検出したらそのタイムスタンプを20要素の配列に追加。最初と最後の要素の時間差を20で割ると平均拍動間隔(ミリ秒)が求まり、60000(1分)をそれで割るとBPM値になります。配列要素数は調整可能。少なければ応答が速いが不安定、多ければ安定だが急激なBPM変化への追従が遅くなります。
BPMを色(青→緑→黄→ピンク→赤、低→高)と点灯LED数(80BPMで8個、110BPMで11個など、スケール調整可)にマッピングします。
#include <Adafruit_NeoPixel.h>
#ifdef __AVR__
#include <avr/power.h>
#endif
// LEDリングのDIピン
#define PIN 11
// リングのピクセル数
#define NUMPIXELS 16
// uECG接続用入力ピン
int in_pin = 3;
Adafruit_NeoPixel pixels = Adafruit_NeoPixel(NUMPIXELS, PIN, NEO_GRB + NEO_KHZ800);
void setup() {
pixels.begin();
pinMode(in_pin, INPUT);
digitalWrite(in_pin, 1); // プルアップ有効化:重要!uECGに内部プルアップなし
}
// 直近20拍を保存してBPMを平均化
// 値を大きくすると信頼性向上だが、BPM変化への反応が遅くなる
#define BEAT_HIST 20
long beats[BEAT_HIST];
void push_beat(long ms) {
for (int x = 0; x < BEAT_HIST-1; x++) {
beats[x] = beats[x+1];
}
beats[BEAT_HIST-1] = ms;
}
int get_bpm() {
long dt = beats[BEAT_HIST-1] - beats[0];
long bpm = BEAT_HIST * 60000 / dt;
return bpm;
}
long last_pix_upd = 0;
int prev_in_state = 0;
void loop() {
long ms = millis();
int in_state = digitalRead(in_pin); // 拍動なし=1, 拍動中=0
if (in_state == 1 && prev_in_state == 0) {
push_beat(ms);
}
prev_in_state = in_state;
if (ms - last_pix_upd > 10) {
int r, g, b;
last_pix_upd = ms;
int bpm = get_bpm();
int max_bright = 120; // 最大輝度(最大255だが必ずしも最大にする必要なし)
float dd = 20; // 色変化のBPM間隔(青→緑→黄→ピンク→赤)
float t1 = 90, t2, t3, t4; // t1: 基準BPM、これ未満は青
t2 = t1 + dd;
t3 = t2 + dd;
t4 = t3 + dd;
// BPMに応じた色計算
if (bpm < t1) { r = 0; g = 0; b = max_bright; }
else if (bpm < t2) { r = 0; g = max_bright * (bpm-t1)/dd; b = max_bright - g; }
else if (bpm < t3) { r = max_bright * (bpm-t2)/dd; g = max_bright - r; b = r/4; }
else if (bpm < t4) { r = max_bright; g = 0; b = max_bright/2 - max_bright * (bpm-t3)/(2*dd); }
else { r = max_bright; g = 0; b = 0; }
// 拍動間は1/4輝度にして拍動瞬間を強調
if (in_state) {
r *= 0.25; g *= 0.25; b *= 0.25;
}
int on_pixels = (bpm + 5) / 10; // 点灯LED数: 60BPMで6個, 120BPMで12個など
for (int i = 0; i < NUMPIXELS; i++) {
if (i < on_pixels) pixels.setPixelColor(i, pixels.Color(r, g, b));
else pixels.setPixelColor(i, pixels.Color(0, 0, 0));
}
pixels.show();
}
}
3. 着用型としての組み立て
Arduinoをリング内部に収めるとサイズがほぼぴったり。バッテリーも近くに配置可能。uECGは胸に装着するため、コネクター付きワイヤーで接続します。先にuECGを装着し、その上から他コンポーネント内蔵のシャツを着て、最後にコネクターを接続する順序が重要です。逆だと着用が非常に面倒です(経験済み)。
すべて正しく接続すれば、コネクター接続後30秒以内に点灯し、BPMを表示開始します。
4. 実地テスト
歩行とランニングでテスト。ランニング中、バッテリーがECGセンサーの上で跳ねて読み取りを歪ませることが判明。位置をずらすと、今度はuECG-Arduino接続ワイヤーが短すぎて、歩行のたびにECGセンサーを引っ張り、また歪みが発生。全体として、歩行と静止時は確実に拍動検出できましたが、ランニング時はまだ不安定です(改善予定)。別のシャツでセンサー単体テストしたときは、ランニング中もアプリで正しいBPMが表示されていました。
また、胸のLEDは見た目はカッコいいですが、実用性は低いです。自分の脈拍を確認するために下を向くのは不便。次期版ではリストバンド型で拍動表示する予定です。
P.S. uECGプロジェクトに興味があれば、Hackadayページに技術詳細、PCB設計、議論、プロジェクトログが豊富にあります。
コード
- uECG_pixel_ring.ino
uECG_pixel_ring.ino (Arduino)
#include <Adafruit_NeoPixel.h>
#ifdef __AVR__
#include <avr/power.h>
#endif
#define PIN 11
#define NUMPIXELS 16
int in_pin = 3;
Adafruit_NeoPixel pixels = Adafruit_NeoPixel(NUMPIXELS, PIN, NEO_GRB + NEO_KHZ800);
void setup() {
pixels.begin();
pinMode(in_pin, INPUT);
digitalWrite(in_pin, 1);
}
#define BEAT_HIST 20
long beats[BEAT_HIST];
void push_beat(long ms) {
for (int x = 0; x < BEAT_HIST-1; x++) {
beats[x] = beats[x+1];
}
beats[BEAT_HIST-1] = ms;
}
int get_bpm() {
long dt = beats[BEAT_HIST-1] - beats[0];
long bpm = BEAT_HIST * 60000 / dt;
return bpm;
}
long last_pix_upd = 0;
int prev_in_state = 0;
void loop() {
long ms = millis();
int in_state = digitalRead(in_pin);
if (in_state == 1 && prev_in_state == 0) {
push_beat(ms);
}
prev_in_state = in_state;
if (ms - last_pix_upd > 10) {
int r, g, b;
last_pix_upd = ms;
int bpm = get_bpm();
int max_bright = 120;
float dd = 20;
float t1 = 90, t2, t3, t4;
t2 = t1 + dd;
t3 = t2 + dd;
t4 = t3 + dd;
if (bpm < t1) { r = 0; g = 0; b = max_bright; }
else if (bpm < t2) { r = 0; g = max_bright * (bpm-t1)/dd; b = max_bright - g; }
else if (bpm < t3) { r = max_bright * (bpm-t2)/dd; g = max_bright - r; b = r/4; }
else if (bpm < t4) { r = max_bright; g = 0; b = max_bright/2 - max_bright * (bpm-t3)/(2*dd); }
else { r = max_bright; g = 0; b = 0; }
if (in_state) {
r *= 0.25; g *= 0.25; b *= 0.25;
}
int on_pixels = (bpm + 5) / 10;
for (int i = 0; i < NUMPIXELS; i++) {
if (i < on_pixels) pixels.setPixelColor(i, pixels.Color(r, g, b));
else pixels.setPixelColor(i, pixels.Color(0, 0, 0));
}
pixels.show();
}
}
回路図
製造プロセス