akinoのアイコン画像
akino 2026年01月26日作成
製作品 製作品 閲覧数 29
akino 2026年01月26日作成 製作品 製作品 閲覧数 29

顔画像からの脈拍検出

顔画像からの脈拍検出

概要

SPRESENSE+HDRカメラで、顔画像からの脈拍検出を行う

実現させたいこと、その背景

随分以前ですが顔画像(動画)から脈拍を検出するという記事を読んで興味を持っていました。この技術は、rPPG(remote Photoplethysmography:遠隔光電脈波法)と呼ばれ、皮膚の色の微細な変化を検出して、心拍数や心拍変動などのバイタルサインを推定するものとありました。
最近、ネット検索でスマホアプリとしてこの機能を提供しているものがあることを知り、それならばSPRESENSEでも可能ではないか、どこまでできるか検討してみたいと思いました。
また、SPRESENSEが優秀とは言え限られた資源(自分の能力も含め)の中で、どこまでの精度を得られるのか。この検証のため、画像をラズパイ(もしくはPC)に転送し、pythonなどを使い先人の知恵を拝借して脈拍検出を行った結果と比較してみようとも考えました。

構成

  • Spresense+HDRカメラ を基本構成とする
  • +拡張ボード+LCDパネル で撮影画像・測定部位の確認、脈拍値の表示を行う
  • +Wi-Fiアドオンボード でラズパイに画像(もしくはデータ)を転送する

構成図

キャプションを入力できます

接続情報

キャプションを入力できます

使用部品

製品名 品番 メーカー
SPRESENSEメインボード SPRESENSE SONY
SPRESENSE拡張ボード SPRESENSE SONY
SPRESENSE HDRカメラボード SPRESENSE SONY
ILI9341搭載2.8インチSPI制御タッチパネル付TFT液晶 MSP2807 秋月電子
SPRESENSE Wi-Fi Add-onボード iS110B IDY

※HDRカメラ付属のフラットケーブル(80mm)では短いので、amazonで100mm品を購入し挿し替えています。
※拡張ボードとLCDパネルはコネクターケーブルを自作し接続しています。

現在の状況

  • タイトルの写真のように、カメラ・LCDパネルを配置しました。
  • アルミ板を挟んで背面にメイン基板・拡張基板・Wi-Fiアドオンボードをセットしました。
  • カメラ部を上にして縦置きで使います。顔の全体像(縦長)を撮影・LCDパネル表示しています。

機能

  • HDRカメラで顔を撮影し、この画像から脈拍値を算出します。
  • 顔認識、測定部位の自動設定は行っていません。LCDパネルの中心部に赤枠(60 x 60 画素)を表示し、ここに測定部位が入るように顔を動かします。
  • この測定部位の全画素値の緑成分の平均値を毎フレームごとに求めます。
  • 脈拍算出間隔(5秒)経過後、平均値の時系列データから「自己相関関数」により脈拍値を算出します。
  • 算出した脈拍値をLCDパネルに表示・更新します。同時にシリアルモニタにも表示します。

プログラム開発

SPRESENSEで検出

開発環境

  • VSCode+PlatformIO を使い Arduino で行いました。
  • Spresenseのパッケージは 3.2.0 です。

処理の流れ

  • HDRカメラのストリーミング画像(QVGA、RGB565)を使用します。バッファ数を'2'とすることで、約30fpsで取得できています。
  • 脈拍検出処理は、全てそのコールバック関数(CamCB( ))内で行います。従って loop( ) では何もしていません。
  • calcGreenMean2( ):毎フレーム、測定部位の全画素値の緑成分の平均値を求めリングバッファに格納していきます。
  • computeBPM_normalized( ):所定時間(5秒)経過後、リングバッファにある時系列データから「自己相関関数」により脈拍値を算出しています。以降この間隔で算出を繰り返します。
  • LCDパネルへの表示は subcore2 で行います。maincoreから構造体で定義した複製画像データ他をアドレス渡しで送っています。
  • 顔を動かして部位を合わせるのでディスプレイは必須ですが、maincore側で表示処理(画像と赤枠2回の表示で約35ms)を行うとフレームレートを低下させてしまいます。
  • subcore2 での画像表示でコマ落ちが発生しても、測定部位の確認や測定値の表示用なので支障はありません。
  • LCDパネルへの表示は ’LovyanGFX’ を使っています。赤枠のチラつき防止やデータ表示の文字の向き変更に 'sprite' 機能の使用を考えていました。しかし、単純に行うと subcore2 のメモリに384KBの割当が必要と分かったので、現状は 'sprite’ 機能を使用していません。256KBで収まります。
  • 脈拍検出の計算部は今後の改善に向け subcore1 に独立させることを考えているので、メモリ使用量には余裕を見ておきたいためです。

結果(現状)

  • 顔の測定部位をいろいろと変えて試しましたが、かなり残念な状況です。まれにそれらしい脈拍値を示すことはありますが、検出アルゴリズム改善に大きなブレークスルーが必要なようです。
  • 年齢が若い人の肌の方が検出しやすいということなので、8歳児の顔でも確認してみましたがダメでした。
  • 但し、レンズを手の指で押さえるような状態では、かなり信頼できる値を示すことが分かりました。スマートウォッチでの測定と比較しています。
  • このとき「teleplot」によるプロッター機能を使い、毎フレームの緑成分平均値を表示させたのが下図です。それなりの波形が得られています。
    キャプションを入力できます

ソースコード

ファイル構成

src ├─ main │ ├─ main.cpp │ └─ rppg.cpp └─ sub2   ├─ sub2.cpp   └─ LGFX_SPRESENSE.hpp

maincore

main/main.cpp

#include <Arduino.h> #include <Camera.h> #include <MP.h> extern float computeBPM_normalized(const float* buf_in, int len, float fps); const int ROI_X = 130; const int ROI_Y = 90; const int ROI_W = 60; // 測定部位枠サイズ const int ROI_H = 60; const int BUF_LEN = 180; // 約6秒分(30fps×6) float greenBuf[BUF_LEN]; // リングバッファ int bufIndex = 0; // 同インデックス unsigned long lastCalc = 0; // BPM計算時にクリア const unsigned long calcInterval = 5000; // BPM計算間隔(ms) unsigned long lasttime = 0; // CamCB()への前回アクセス int frameCount = 0; // calcInterval中のCamCB()実行数、FPS確認の代用 float fpsEstimate = 10.0f; // 実FR値(FPS、計算値) float bpm = 0.0f; int32_t ev = 0; int iso = 0; // 構造体定義 struct img_Packet { volatile int status; // 0 = SubCoreが処理済、1 = 新規画像あり uint32_t img_size; // 画像サイズ uint8_t img_buf[153600]; // 320*240*2 }; img_Packet *packet; int subcore2 = 2; // 送信先subcore番号 int8_t msgid2 = 20; // Subcore2用メッセージID /* ====== ROI内の平均G成分算出 ===== */ float calcGreenMean(uint8_t* imgBuf) { // YUV422: Yが輝度に相当) long sum = 0; int count = 0; for (int y = ROI_Y; y < ROI_Y + ROI_H; y++) { for (int x = ROI_X; x < ROI_X + ROI_W; x += 2) { int idx = (y * CAM_IMGSIZE_QVGA_H + x) * 2; // YUV422 = 2byte/pixel uint8_t Y = imgBuf[idx]; sum += Y; count++; } } return (float)sum / count; } float calcGreenMean2(uint8_t* imgBuf) { // RGB565:ROI内の平均G成分を求める long sum = 0; int count = 0; for (int y = ROI_Y; y < ROI_Y + ROI_H; y++) { for (int x = ROI_X; x < ROI_X + ROI_W; x++) { int idx = (y * CAM_IMGSIZE_QVGA_H + x) * 2; uint8_t low = imgBuf[idx]; // [7:0] uint8_t high = imgBuf[idx + 1]; // [15:8] // RGB565構成: high: RRRRRGGG, low : GGGBBBBB、バッファ上に word はリトルエンディアンで格納 uint8_t G = ((high & 0x07) << 3) | ((low & 0xE0) >> 5); // 6bitのG G = (G << 2) | (G >> 4); // 6bit→8bit相当にスケーリング sum += G; count++; } } return (float)sum / count; } /* ====== HDRカメラ ===== */ void printError(enum CamErr err) { //エラーコードによりその内容を表示 Serial.print("Error: "); switch (err) { case CAM_ERR_NO_DEVICE: Serial.println("No Device"); break; case CAM_ERR_ILLEGAL_DEVERR: Serial.println("Illegal device error"); break; case CAM_ERR_ALREADY_INITIALIZED: Serial.println("Already initialized"); break; case CAM_ERR_NOT_INITIALIZED: Serial.println("Not initialized"); break; case CAM_ERR_NOT_STILL_INITIALIZED: Serial.println("Still picture not initialized"); break; case CAM_ERR_CANT_CREATE_THREAD: Serial.println("Failed to create thread"); break; case CAM_ERR_INVALID_PARAM: Serial.println("Invalid parameter"); break; case CAM_ERR_NO_MEMORY: Serial.println("No memory"); break; case CAM_ERR_USR_INUSED: Serial.println("Buffer already in use"); break; case CAM_ERR_NOT_PERMITTED: Serial.println("Operation not permitted"); break; default: break; } exit(1); } void CamCB(CamImage img) { // callback関数 unsigned long t1, rp; t1 = millis(); rp = t1 - lasttime; if (lasttime != 0) { // 実FPS計算 float instFPS = 1000.0f / rp; fpsEstimate = 0.9f * fpsEstimate + 0.1f * instFPS; // 移動平均で安定化、0.1 は平滑化係数(0.0~1.0) } lasttime = t1; if(img.isAvailable()) { // MPLog("rp = %ld ms, time = %ld\n", rp, t1); if (packet->status == 0) { // subcore2の前データ受信を確認 memcpy(packet->img_buf, img.getImgBuff(), img.getImgSize()); // 画像複製 packet->img_size = img.getImgSize(); packet->status = 1; // 画像更新フラグをセット // MPLog("Camcb packet size = %d\n", sizeof(img_Packet)); MP.Send(msgid2, packet, subcore2); } float gmean = calcGreenMean2(img.getImgBuff()); // 平均G成分算出(RGB565) //Serial.printf(">gmean:%f\n", gmean); // teleplotによるシリアルプロッタ greenBuf[bufIndex] = gmean; bufIndex = (bufIndex + 1) % BUF_LEN; // FIFOリングバッファー frameCount++; // CamCB()実行数 if (millis() - lastCalc > calcInterval) { bpm = computeBPM_normalized(greenBuf, BUF_LEN, fpsEstimate); if (bpm > 40 && bpm < 180) { Serial.printf(" BPM = %.1f\n", bpm); } else { Serial.printf(" out of range. BPM = %.1f\n", bpm); } lastCalc = millis(); frameCount = 0; ev = theCamera.getAbsoluteExposure(); // 露光時間(100usec単位)を取得 iso = theCamera.getISOSensitivity(); // ISO感度を取得 Serial.printf(" FR = %f, EV = %ld, ISO = %d\n", fpsEstimate, ev, iso); } } else { Serial.println("Failed to get video stream image"); } } void init_Cam() { Serial.println("Prepare camera"); CamErr err = theCamera.begin( // 電源on、初期化、defaultは (buff_num=1, 30FPS, QVGA, YUV422)、.end()でoff 2, // 3, CAM_VIDEO_FPS_30, CAM_IMGSIZE_QVGA_H, CAM_IMGSIZE_QVGA_V, // CAM_IMAGE_PIX_FMT_YUV422, CAM_IMAGE_PIX_FMT_RGB565, 7 ); if (err != CAM_ERR_SUCCESS) printError(err); Serial.println("Set AW stop"); // AWB停止 err = theCamera.setAutoWhiteBalance(false); if (err != CAM_ERR_SUCCESS) printError(err); /* // HDRの設定 Serial.println("Set HDR"); err = theCamera.setHDR(CAM_HDR_MODE_ON); if (err != CAM_ERR_SUCCESS) { Serial.println("setHDR ERROR"); } // ISOの設定 Serial.println("Set ISO Sensitivity"); // err = theCamera.setISOSensitivity(CAM_ISO_SENSITIVITY_1600); err = theCamera.setAutoISOSensitivity(true); if (err != CAM_ERR_SUCCESS) { Serial.println("setAutoISOSensitivity ERROR"); } // 露出の設定 // err = theCamera.setAutoExposure(true); err = theCamera.setAutoExposure(false); int exposure = 20; err = theCamera.setAbsoluteExposure(exposure); if (err != CAM_ERR_SUCCESS) { Serial.println("setAbsoluteExposure ERROR"); } */ Serial.println("Start streaming"); // Start video stream. err = theCamera.startStreaming(true, CamCB); // callback関数登録,streamingを停止する場合はfalse if (err != CAM_ERR_SUCCESS) printError(err); } void setup() { Serial.begin(115200); while (!Serial); Serial.println("Start rppg_multi4_260117"); int ret = MP.begin(subcore2); // subcore2起動、MP.RecvTimeout()はdefaultのBLOCKING if (ret < 0) MPLog("MP.begin(subcore2) error = %ld, time = %ld\n", ret, millis()); packet = (img_Packet*)MP.AllocSharedMemory(sizeof(img_Packet)); // 共有メモリ確保 if (!packet) { Serial.println("Shared memory alloc failed"); while(1); } memset(packet, 0, sizeof(img_Packet)); // 明示的に初期化、少なくとも status=0 でないとまずい // MPLog("packet size = %d\n", sizeof(img_Packet)); MP.RecvTimeout(MP_RECV_POLLING); // Polling mode を指定 init_Cam(); // カメラ初期設定 } void loop() { ; }
  • rppg.cpp は脈拍値の算出部:computeBPM_normalized( ) だけですが、この後の精度アップと subcore1 での実行を考え別ファイルにしてあります。

main/rppg.cpp

#include <Arduino.h> // 改良コード(正規化+安全処理付き) float computeBPM_normalized(const float* buf_in, int len, float fps) { if (len < 30) return NAN; // 短すぎると不安定 // 平均と分散を計算(元配列は変更しない) double mean = 0.0; for (int i=0; i<len; i++) mean += buf_in[i]; mean /= len; double var = 0.0; for (int i=0; i<len; i++) { double d = buf_in[i] - mean; var += d * d; } var /= len; double sigma = sqrt(var); if (sigma <= 1e-6) return NAN; // 定数信号 // 正規化自己相関を探索 int bestLag = 0; double bestR = 0.0; for (int lag = 10; lag <= len/2; lag++) { double num = 0.0; int n = len - lag; for (int i=0; i<n; i++) num += (buf_in[i] - mean) * (buf_in[i+lag] - mean); double r = num / (n * var); // 正規化した相関係数(概ね -1..1) if (r > bestR) { bestR = r; bestLag = lag; } } // 信頼度チェック if (bestR < 0.1) return NAN; // 周期→BPM double periodSec = (double)bestLag / fps; if (periodSec <= 0.0) return NAN; double bpm = 60.0 / periodSec; //Serial.printf(" bestLag = %d, bestR = %.2f, realfps = %.2f, frameCount = %d\n", bestLag, bestR, fpsEstimate, frameCount); // 確認: return (float)bpm; }

subcore2

  • LCDパネル用には LovyanGFX を使用しています。
  • 'LGFX_SPRESENSE.hpp' は Spresense との接続に合せ各ピンの指定を変更、またCS端子は sub2.cpp で設定できるように変更してあります。
  • 現状、LCDパネルへの測定結果の表示はまだ対応できていません。

sub2/sub2.cpp

#define SUBCORE 2 #if (SUBCORE != 2) #error "Core selection is wrong!!" #endif #include <Arduino.h> #include <MP.h> // MultiCore 使用、subCore2 #include "LGFX_SPRESENSE.hpp" // lgfx_user/LGFX_SPRESENSE_sample.hppを小修整 // USER_HEAP_SIZE(256 * 1024); // sprite(全画面)を使うとき → Used memory size: 384[KByte] // 構造体定義 struct img_Packet { volatile int status; // 0 = SubCoreが処理済, 1 = 新規画像あり uint32_t img_size; // 画像サイズ uint8_t img_buf[153600]; // 320*240*2 }; img_Packet *packet; int8_t msgid; unsigned long lasttime = 0; // loop() の前回実行時間 int cs_pin = 14; int TFT_BL = 15; // LCD(Back Light)、Lアクティブ static LGFX lcd(cs_pin); // LGFXのインスタンスを作成、CS端子を指定 // static LGFX_Sprite sprite1(&lcd); // lcdに描画するスプライト作成、カメラ画像全体 // static LGFX_Sprite sprite2(&sprite1); // カメラ画像に重ねるスプライト作成、メータ―数値部の矩形 #define ROI_X 130 #define ROI_Y 90 #define ROI_W 60 #define ROI_H 60 void ili9341_init() { lcd.init(); lcd.setRotation(3); // LCD 表示を90度回転、実装状態に合わせた lcd.setSwapBytes(true); // バイト順変換を有効にする(エンディアン対策) // sprite1.createSprite(320, 240); // スプライト使用時(赤枠のチラつき対策) // sprite1.setSwapBytes(true); // バイト順変換を有効にする // sprite2.createSprite(ROI_W, ROI_H); pinMode(TFT_BL, OUTPUT); // LCDバックライトのon/off、Lアクティブ digitalWrite(TFT_BL, HIGH); // HIGH で点灯(起動時一瞬点灯) } void setup() { Serial.begin(115200); while (!Serial); MP.begin(); // MainCore に起動完了を通知 MPLog("subcore2 start\n"); MP.RecvTimeout(MP_RECV_BLOCKING); // MP_RECV_BLOCKING がdefault ili9341_init(); } void loop() { unsigned long t1, t2, t3, sub2_rp; t1 = millis(); sub2_rp = t1 - lasttime; // loop() 実行間隔 lasttime = t1; int ret = MP.Recv(&msgid, &packet); if (ret > 0) { // MPLog("success. size = %u, status = %d\n", packet->img_size, packet->status); if (packet->status == 1) { t2 = millis(); lcd.pushImage(0, 0, 320, 240, (uint16_t *)packet->img_buf); lcd.drawRect(ROI_X, ROI_Y, ROI_W, ROI_H, TFT_RED); // ROIの枠 lcd.drawRect(ROI_X - 1, ROI_Y -1 , ROI_W + 2, ROI_H + 2, TFT_RED); // sprite1.pushImage(0, 0, 320, 240, (uint16_t *)packet->img_buf); // sprite2.drawRect(0, 0, ROI_W, ROI_H, TFT_RED); // 検出枠を表示 // sprite2.drawRect(1, 1 , ROI_W - 2, ROI_H - 2, TFT_RED); // sprite2.pushSprite(130,90,TFT_BLACK); // 画像を隠さないように透過 // sprite1.pushSprite(0,0); //一括書換、(x,y)は320x240画面での位置 t3 = millis(); packet->status = 0; // MPLog("sub2_rp = %ld, disp = %ld, loop = %ld\n", sub2_rp, t3 - t2, t1); // disp はLCDの表示処理時間 } } else { MPLog("MP.Recv Error\n"); } }

画像転送してラズパイで検出

  • こちらはまったくの未完成状態です。構想と検討用のコードですが記します。
  • WI-Fiアドオンボードからラズパイに画像をTCP通信で転送します。
  • 画素の緑成分を扱うのでRGB565(もしくはYUV422)を考えていましたが、画像データサイズが大きく脈拍検出に必要なフレームレート(最低10fps、20~30fpsは欲しい)を確保できないと思われます。
  • ラズパイ側の処理によっては、測定部位(60×60)のデータ、もしくは緑成分の時系列データを送信するだけでよいと思います。それであれば大丈夫そうです。
  • 調べてみると、rPPG技術では画素単位を見るのではなく、その領域内での平均値を見るので、JPEG画像でも品質を下げ過ぎないようにすれば問題ないとありました。
  • 画像転送のソースコードとして、Spresenseでの送信部、ラズパイでの受信部を以下に記します。

ソースコード

Spresense

  • is110Bは、ATCMD_PSAVE_ALWAYS_ON に設定します。ATCMD_PSAVE_DEFAULT では接続できないWi-Fiルータがありました。
  • 画像データの転送は、最初に画像データサイズを送り、続いて画像データを分割(1400byte)して送信しています。
  • 画像をQVGAのJPEGとした場合、その画像データサイズは21942byteでした。 RGB565 に対し 1/7 となりますが、それでもフレームレートは 6.5fps 程度でした。
  • setJPEGQuality( ) でJPEG品質を下げ画像データサイズを縮小しようと試しました。しかし画像品質は変わりましたが画像データサイズは変わりませんでした。
  • 調べてみると画像データサイズは begin( ) の 'jpgbufsize_divisor' の設定値から [ video_width * video_height * 2 / jpgbufsize_divisor ] の式で決められ固定されてしまうようでした。実JPEGデータの後ろは余白で埋め画像データサイズを一定にしているようです。
  • そこで実JPEGデータの EOIマーカ(FFD9) を検出して、そこまでを画像データとして送信するようにしました。
  • ちなみに、jpgbufsize_divisor = 7(default)のとき、setJPEGQuality( )の設定値による実JPEGデータのサイズは、70(default) ≒ 18KB、50 ≒ 13KB、40 ≒ 10KB、20 ≒ 6KB でした。

main/main.cpp

#include <Arduino.h> #include <Camera.h> #include <TelitWiFi.h> const char* WIFI_SSID = "**********"; const char* WIFI_PASS = "**********"; const char* SERVER_IP = "192.168.**.**"; // TCPServe const char* SERVER_PORT = "5000"; TelitWiFi gs2200; TWIFI_Params gsparams; #define IMG_BUF_SIZE 200000 // JPEG最大想定サイズ uint8_t img_buf[IMG_BUF_SIZE]; size_t img_size = 0; unsigned long t1,t2,t3; volatile int status = 0; // 0: idle, 1: ready to send char server_cid; void printError(enum CamErr err) { //エラーコードによりその内容を表示 Serial.print("Error: "); switch (err) { case CAM_ERR_NO_DEVICE: Serial.println("No Device"); break; case CAM_ERR_ILLEGAL_DEVERR: Serial.println("Illegal device error"); break; case CAM_ERR_ALREADY_INITIALIZED: Serial.println("Already initialized"); break; case CAM_ERR_NOT_INITIALIZED: Serial.println("Not initialized"); break; case CAM_ERR_NOT_STILL_INITIALIZED: Serial.println("Still picture not initialized"); break; case CAM_ERR_CANT_CREATE_THREAD: Serial.println("Failed to create thread"); break; case CAM_ERR_INVALID_PARAM: Serial.println("Invalid parameter"); break; case CAM_ERR_NO_MEMORY: Serial.println("No memory"); break; case CAM_ERR_USR_INUSED: Serial.println("Buffer already in use"); break; case CAM_ERR_NOT_PERMITTED: Serial.println("Operation not permitted"); break; default: break; } exit(1); } void CamCB(CamImage img) { if (!img.isAvailable()) return; size_t size = img.getImgBuffSize(); // Serial.printf("image size = %d\n",size); if (size > IMG_BUF_SIZE) return; if (status == 0) { memcpy(img_buf, img.getImgBuff(), size); img_size = size; status = 1; } } void init_Cam() { // カメラ初期化 Serial.println("Prepare camera"); CamErr err = theCamera.begin( // 電源on、初期化、defaultは (buff_num=1, 30FPS, QVGA, YUV422)、.end()でoff 2, CAM_VIDEO_FPS_30, CAM_IMGSIZE_QVGA_H, CAM_IMGSIZE_QVGA_V, // CAM_IMAGE_PIX_FMT_YUV422, //CAM_IMAGE_PIX_FMT_RGB565, CAM_IMAGE_PIX_FMT_JPG, 7 ); if (err != CAM_ERR_SUCCESS) printError(err); } void setup() { Serial.begin(115200); Serial.println("TCPClient-3 start"); AtCmd_Init(); // Wi-Fi 初期化 Init_GS2200_SPI_type(iS110B_TypeC); // 'Is Your module iS110B_TypeC ?','GS2200 is ready to go.' の表示 gsparams.mode = ATCMD_MODE_STATION; gsparams.psave = ATCMD_PSAVE_ALWAYS_ON; /* WiFi receiver is always on, never go to sleep mode */ // gsparams.psave = ATCMD_PSAVE_DEFAULT; // Power save mode if (gs2200.begin(gsparams)) { // 'Normal Boot.' の表示 Serial.println("GS2200 init failed"); while (1); } if (gs2200.activate_station(WIFI_SSID, WIFI_PASS)) { // 'Start TCP Client','Connected','IP: 192.168.**.**' Serial.println("WiFi association failed"); while (1); } server_cid = gs2200.connect( // TCP 接続 SERVER_IP, SERVER_PORT ); if (server_cid == ATCMD_INVALID_CID) { Serial.println("TCP connect failed"); while (1); } init_Cam(); // カメラ初期化 CamErr err = theCamera.setJPEGQuality(**); if (err != CAM_ERR_SUCCESS) printError(err); Serial.printf("JPEGQuality %d\n", theCamera.getJPEGQuality()); err = theCamera.startStreaming(true, CamCB); if (err != CAM_ERR_SUCCESS) printError(err); } void loop() { if (status == 1) { t1 = millis(); Serial.printf("img_size = %d\n", img_size); size_t jpeg_size = 0; // バッファ内のJPEGデータのサイズ確認 for (size_t i = 0; i < img_size - 1; i++) { if (img_buf[i] == 0xFF && img_buf[i+1] == 0xD9) { // EOIマーカ(FFD9) を検出 jpeg_size = i + 2; break; } } Serial.printf("JPEG file size = %d\n", jpeg_size); if (jpeg_size > 0) { // jpeg_size バイトだけ送信 uint32_t net_size = (uint32_t)jpeg_size; // サイズを送信(4byte) gs2200.write(server_cid, (uint8_t*)&net_size, 4); size_t sent = 0; while (sent < jpeg_size) { // JPEGデータ送信 size_t chunk = min(jpeg_size - sent, MAX_RECEIVED_DATA - 100); if (gs2200.write(server_cid, img_buf + sent, chunk)) { sent += chunk; } else { delay(10); } } t2 = millis() -t1; Serial.printf("Image sent. %lu ms\n", t2); } status = 0; WiFi_InitESCBuffer(); } delay(10); }

ラズパイ

  • ラズパイ側は python でTCPサーバを用意しました。
  • 起動後、Spresenseからの接続を待ち、接続後は画像を10枚受信・保存し、切断・終了する確認用のものです。
  • この後のラズパイ側での脈拍検出処理は時間切れで未完成です。

rec_image2.py

import socket import struct import datetime HOST = '0.0.0.0' PORT = 5000 MAX_IMAGES = 10 def recv_exact(sock, size): buf = b'' # byte型、''で初期化(空のバイト列を作成) while len(buf) < size: data = sock.recv(size - len(buf)) if not data: raise ConnectionError("Unexpected EOF") buf += data return buf server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # ソケット設定 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # SO_REUSEADDRをONにする server.bind((HOST, PORT)) server.listen(1) print(f"Waiting for connection on {HOST}:{PORT}...") client, addr = server.accept() print(f"Connected from {addr}") try: for i in range(MAX_IMAGES): # サイズ受信(4byte) size_bytes = recv_exact(client, 4) img_size = struct.unpack("<I", size_bytes)[0] # '<':little endian、'I':unsigned int print(f"[{i}] Image size = {img_size} bytes") # JPEGデータ受信 img_data = recv_exact(client, img_size) filename = f"image_{i:03d}.jpg" with open(filename, "wb") as f: f.write(img_data) print(f"[{i}] Saved {filename}") except (ConnectionError, KeyboardInterrupt) as e: print("Connection closed:", e) finally: client.close() server.close() print("Finished.")

後記

  • なんとかここまでこぎつけましたが、本題の脈拍検出については全然未達です。
  • 今回は自身のステップアップのためサブテーマとして、SDKでの開発、GitHubの利用を掲げていましたが、どちらもまだ不十分な状態です。ちょっと欲張りすぎでした。
  • 前準備や、ステップアップ項目のところ、そして本題でも何度も「はまって」しまい、肝心の脈拍検出のアルゴリズムを深掘りできませんでした。
  • 今後、脈拍検出のアルゴリズムの高度化、FFTやAIの利用、ノイズ低減対策など検討したいと考えています。そのためにもSDK開発の習得、GitHubの活用を急ぎたいと思います。
  • また、HDRカメラの設定に関しても、脈拍検出に合わせたシャッター速度、ゲインなどの最適化が必要と考えています。
  • いずれはSpresenseのAI機能で顔認識・部位検出もしたいと思います。

謝辞

未達部分を多く残していますが、モニター品を提供頂いておりますので投稿いたしました。(毎回同じことを書いているようで心苦しいです)

参考にさせて頂いたサイトの方々、モニター品を提供頂いた各社様、コンテストを企画して頂いたelchikaさん、そしてSONYさんに深く感謝いたします。

また、今回は「ChatGPT」に大変多くのアドバイスをもらいました。感謝、感謝です。
これに絡んで昨年、大塚 あみさんの著書「#100日チャレンジ」を読み「目からうろこ」の刺激を受けました。こちらにも感謝いたします。

  • akino さんが 2026/01/26 に 編集 をしました。 (メッセージ: 初版)
ログインしてコメントを投稿する