miurite が 2026年01月31日03時07分41秒 に編集
コメント無し
タイトルの変更
【世界初?】Spresense×M5Stackで愛魚をVtuber化してみた-遠隔見守りシステム-
【世界初?】Spresense×M5Stackで愛魚をVtuber化してみた-遠隔みまもりシステム-
本文の変更
==水槽内で暮らすメダカの様子をリアルタイムでかわいく可視化・配信する省電力な見守りIoTシステムを作成しました==
# 動機 厳しい寒さが続く今日この頃(執筆日時:2026年1月)、水槽の水温も下がり、メダカたちが底の方でじっとしている時間が増えました。 外出先にいても「ちゃんと生きてるかな」「今どこにいるんだろう」と気になってしまいます。 遠隔地から様子を確認したいが、カメラ映像をそのまま垂れ流すのはプライバシー的にちょっと..... そこで私はひらめきました。 **「メダカの動きだけをトラッキングして、バーチャルな姿で再現すればいいのでは?」** そうして誕生したのが、画面上で生きる世界初(?)のVtuberメダカ "たいようくん"です。 # 作ったもの 今回制作したのは、水槽内で暮らすメダカの様子をリアルタイムで可視化、配信する見守りシステムです。 M5stackやWEBページでバーチャル水槽が閲覧でき、たいようくんの活動量や水温が、アバターの動きとして直感的に分かるようになっています。 ## システム構成  本システムでは、センシングデバイス(SPRESENSE)と表示デバイス(M5Stack)の役割を完全に分離しています。
**1.SPRESENSE** カメラモジュールで水槽画像を撮影し、FOMO による物体検出でメダカの位置(座標)を推定します。同時に、水温センサ(DS18B20)から水温を取得し、これらの「座標+水温」をまとめて UART通信で M5Stack Core2 に送信します。 **2.M5Stack** presense から受け取ったデータを扱いやすいIoTデータ(JSON)に整形し、画面上にバーチャル水槽として可視化します。また、NTP により時刻を取得して、データにタイムスタンプを付与できるようにしています。整形したJSONデータはWi-Fi経由でクラウドへ送信されます。 **3.クラウド** クラウド側では、受信したデータを DBに時系列データとして保存し、PCやスマートフォンから遠隔モニタリングできるようにします。これにより、外出先でも水温やメダカの位置変化を継続的に観察でき、後から分析にも利用できます。
## 特徴 **1.JSONによる軽量かつ安全なデータ通信** SPRESENSEでセンシングしたデータを、M5StackでJSON形式に変換しインターネット上へ配信しています。映像を送らないため通信量が極めて少なく、本魚や飼い主のプライバシーも守られます。~~Vtuderの中身バレリスクゼロ~~
個人の見守り用途だけでなく、不特定多数に対する愛魚の公開にも利用可能です。  **2.愛のあるUI/UX** 映像を配信しない分、M5Stack上の「バーチャルたいようくん」の見た目や動きのかわいさにこだわりました。 また、たいようくんの健康を最優先し、夜間(22:00~05:00)は水槽を照らすLEDを消灯します。システムも「Sleep Mode」へ移行し、カメラ画像からの位置推定を停止します。(水温のセンシングは継続) さらに、Sleep Mode専用の画面を表示しM5Stackのディスプレイの輝度を落とすことで、さらなる省電力化を実現しました。  人間とメダカどちらにも優しいシステムを目指しました。 **3.高い拡張性** 本システムでは、Spresense側で推論した位置・状態と、M5Stack側での見た目表現を完全に分離して設計しています。 そのため、今回はたいようくんの見た目に寄せたスプライトを使用していますが、ロジック部分には一切手を加えることなく、全く異なるキャラクターや抽象的な表現へ差し替えることも可能です。 メダカだけに限らず、ハムスターなどの他のペットにも応用可能な汎用性があります。
# 用意したもの
# 制作過程 ## 用意したもの
・Spresense(拡張ボード) ・Spresenseカメラモジュール ・M5stack Core2 for AWS ・温度センサ(DS18B20) ・撮影用水槽 ・メダカ
# 制作過程
## ハードウェアの配線
Spresense ⇔ M5Stack (UART通信)
このように配線します。 **Spresense ⇔ M5Stack (UART通信)**
| Spresense (拡張ボード) | M5Stack Core2 | 役割 | | :--- | :--- | :--- | | D00 (UART2_TX) | R2 (GPIO13) | データ送信 (TX -> RX) | | D01 (UART2_RX) | T2 (GPIO14) | データ受信 (RX <- TX) | | GND | GND | グランド共通化 |
Spresense ⇔ 水温センサ (DS18B20)
**Spresense ⇔ 水温センサ (DS18B20)**
| Spresense (拡張ボード) | DS18B20 | 役割 | | :--- | :--- | :--- | | 3.3V / 5V | VCC (赤) | 電源供給 | | D2 (任意のGPIO) | DATA (黄/白) | 信号線 (要プルアップ) | | GND | GND (黒) | グランド |
## (位置推定)
## 機械学習によるメダカの位置検出 一番苦労した部分です...... 位置推定ロジックはSPRESENSE上で動作させるので、当初はSONYのNeural Network Console (NNC) で物体検出モデルを作成するつもりでした。しかし、いろいろ試行錯誤したのですがネットワーク構築や調整に苦戦し、実用に足るモデルの作成ができなかったため、Edge Inpulseでの開発に変更しました。いつかリベンジしたい...... ### Edge InpulseによるAIモデルの構築 SPRESNSEで撮影した水槽の画像(273枚)をインポートし、アノテーションを行います。  [こちら](https://docs.edgeimpulse.com/tutorials/end-to-end/object-detection-centroids)のチュートリアルに沿ってFOMOを用いた物体検出モデルを作成しました。今回作成したモデルの設定は以下の通りです。 **・Image data:96×96(gray scale) ・processing block:image ・learning block:Object Ditection ・Model optimizations:Quantized (int8)** トレーニングとテスト後の結果はこちらです。F1スコアが96.5%と、かなり高精度なモデルを作成することができました。   Edge InpulseのDeproyタブから、作成したモデルをArduino Library形式でビルドし、ダウンロードします。 ### SPRESENSEで動作確認 ダウンロードした推論モデルを、ライブラリとしてインクルードします。(ArduinoIDEのスケッチ→ライブラリのインクルード→.zip形式のライブラリをインストール ) モデルファイルに同梱されていたサンプルコードに、SPRESENSE用のカメラを用いたもの(sony_spresense_camera)があったので、それを元に検出プログラムを作成しました。 動作させてみると、  照明の反射による誤検出を減らすために、被写体やコードの調整を行いました。 一定時間座標が変わらない場合や、bBoxの大きさが不自然な場合は弾くなどの処理を追加したのですが、一番効果があったのは、カメラと水槽の距離を近づけて反射が強い部分が映らないようにすることでした...... メダカが含まれない水槽の画像もデータ画像に含めるべきだったかもしれません。 調整の結果、以下のようにかなりの精度で位置の特定ができるようになりました。(クリックで動きます) 
## SPRESENSE-M5Stack間の通信
前項のメダカの座標特定コードと、 ```arduino:Lチカの例 /* Spresense + Edge Impulse + UART(M5) * - メダカを物体検出 → bbox中心(cx,cy)を取り出す * - M5へ UART(Serial2) で CSV: mode,time,x,y,temp を送信 * * 送信フォーマット(1行): * <mode>,<time>,<x>,<y>,<temp>\n * mode: 0=TRACK, 1=SLEEP * time: HH:MM:SS (millis由来) * x,y : 0..319 / 0..239 (QVGA換算) 検出できなければ -1,-1 * temp: DS18B20(℃) 取得失敗時は -999.0 */ #include <Arduino.h> #include <Camera.h> #include <medakaDitection.h> //Edge Inpulseで作成したモデル #include "edge-impulse-sdk/dsp/image/image.hpp" #include <OneWire.h> #include <DallasTemperature.h> // 通信設定 static const uint32_t PC_BAUD = 115200; // USBログ用 static const uint32_t M5_BAUD = 9600; // M5へUART // 送信周期 static const uint32_t SEND_INTERVAL_MS = 20; // M5へ送る周期 static const uint32_t INFER_INTERVAL_MS = 5; // 推論周期 static const uint32_t TEMP_INTERVAL_MS = 1000; // 温度読む周期 // 検出パラメータ #define DETECTION_THRESHOLD 0.50f static const bool FILTER_BY_LABEL = false; static const char* TARGET_LABEL = "medaka"; // 出力座標のスケール // M5のディスプレイサイズに合わせる static const int OUT_W = 320; static const int OUT_H = 240; #define EI_CAMERA_RAW_FRAME_BUFFER_COLS CAM_IMGSIZE_QVGA_H #define EI_CAMERA_RAW_FRAME_BUFFER_ROWS CAM_IMGSIZE_QVGA_V #define ALIGN_PTR(p,a) ((p & (a-1)) ?(((uintptr_t)p + a) & ~(uintptr_t)(a-1)) : p) typedef struct { size_t width; size_t height; } ei_device_resize_resolutions_t; // EI globals static bool debug_nn = false; static bool is_initialised = false; static uint8_t *ei_camera_capture_out = NULL; // DS18B20 #define ONE_WIRE_BUS 2 #define SENSER_BIT 12 OneWire oneWire(ONE_WIRE_BUS); DallasTemperature sensors(&oneWire); static float latestTempC = NAN; static uint32_t lastTempMs = 0; // 状態 static int latestX = -1; static int latestY = -1; static float latestScore = 0.0f; static uint32_t lastSendMs = 0; static uint32_t lastInferMs = 0; bool ei_camera_init(void); void ei_camera_deinit(void); bool ei_camera_capture(uint32_t img_width, uint32_t img_height); int calculate_resize_dimensions(uint32_t out_width, uint32_t out_height, uint32_t *resize_col_sz, uint32_t *resize_row_sz, bool *do_resize); static int ei_camera_get_data(size_t offset, size_t length, float *out_ptr); // 時刻整形 static void formatTime(char *out, size_t outSize, uint32_t ms) { uint32_t sec = ms / 1000; uint32_t h = (sec / 3600) % 24; uint32_t m = (sec / 60) % 60; uint32_t s = sec % 60; snprintf(out, outSize, "%02lu:%02lu:%02lu", (unsigned long)h, (unsigned long)m, (unsigned long)s); } // 温度更新 static void updateTemperatureIfNeeded() { uint32_t now = millis(); if (now - lastTempMs < TEMP_INTERVAL_MS) return; lastTempMs = now; sensors.requestTemperatures(); float t = sensors.getTempCByIndex(0); if (t > -100.0f && t < 125.0f) latestTempC = t; else latestTempC = NAN; } // bbox中心を計算して QVGA にスケール static void setPositionFromBBox(const ei_impulse_result_bounding_box_t& bb) { // bbox中心(モデル入力座標系) uint32_t cx = bb.x + (bb.width / 2); uint32_t cy = bb.y + (bb.height / 2); // QVGA(320x240)へ拡大 int x = (int)((float)cx * (float)OUT_W / (float)EI_CLASSIFIER_INPUT_WIDTH); int y = (int)((float)cy * (float)OUT_H / (float)EI_CLASSIFIER_INPUT_HEIGHT); // clamp if (x < 0) x = 0; if (x >= OUT_W) x = OUT_W - 1; if (y < 0) y = 0; if (y >= OUT_H) y = OUT_H - 1; latestX = x; latestY = y; } // 推論を1回回す static bool runOneInference() { if (!ei_camera_capture((uint32_t)EI_CLASSIFIER_INPUT_WIDTH, (uint32_t)EI_CLASSIFIER_INPUT_HEIGHT)) { Serial.println("ERR: capture"); return false; } ei::signal_t signal; signal.total_length = EI_CLASSIFIER_INPUT_WIDTH * EI_CLASSIFIER_INPUT_HEIGHT; signal.get_data = &ei_camera_get_data; ei_impulse_result_t result = {0}; EI_IMPULSE_ERROR err = run_classifier(&signal, &result, debug_nn); if (err != EI_IMPULSE_OK) { Serial.print("ERR: run_classifier "); Serial.println((int)err); return false; } #if EI_CLASSIFIER_OBJECT_DETECTION == 1 int best_i = -1; float best_score = 0.0f; for (uint32_t i = 0; i < result.bounding_boxes_count; i++) { auto bb = result.bounding_boxes[i]; if (bb.value <= 0) continue; if (bb.value < DETECTION_THRESHOLD) continue; if (FILTER_BY_LABEL) { if (strcmp(bb.label, TARGET_LABEL) != 0) continue; } if (bb.value > best_score) { best_score = bb.value; best_i = (int)i; } } if (best_i < 0) { // 未検出 latestX = -1; latestY = -1; latestScore = 0.0f; return true; } auto bb = result.bounding_boxes[best_i]; latestScore = bb.value; setPositionFromBBox(bb); // PCログ Serial.print("det: "); Serial.print(bb.label); Serial.print(" score="); Serial.print(bb.value, 3); Serial.print(" x="); Serial.print(latestX); Serial.print(" y="); Serial.println(latestY); return true; #else Serial.println("ERR: Not an object detection impulse"); return false; #endif } void setup() { Serial.begin(PC_BAUD); delay(1500); Serial.println("Spresense: EI detect -> UART(M5) sender"); Serial2.begin(M5_BAUD); Serial.println("Serial2.begin done"); sensors.begin(); sensors.setResolution(SENSER_BIT); Serial.println("DS18B20 begin done"); if (!ei_camera_init()) { Serial.println("ERR: Camera init failed"); } else { Serial.println("Camera initialized"); } } void loop() { uint32_t now = millis(); updateTemperatureIfNeeded(); int mode = 0; // 0=TRACK, 1=SLEEP // 推論 if (now - lastInferMs >= INFER_INTERVAL_MS) { lastInferMs = now; (void)runOneInference(); } // M5へ送信 if (now - lastSendMs >= SEND_INTERVAL_MS) { lastSendMs = now; char t[9]; formatTime(t, sizeof(t), now); float tempToSend = isnan(latestTempC) ? -999.0f : latestTempC; // 未検出なら x,y=-1 を送る Serial2.printf("%d,%s,%d,%d,%.2f\n", mode, t, latestX, latestY, tempToSend); // PCログ Serial.printf("send: %d,%s,%d,%d,%.2f (score=%.3f)\n", mode, t, latestX, latestY, tempToSend, latestScore); } } /* ---------------- Camera / EI helpers ---------------- */ bool ei_camera_init(void) { if (is_initialised) return true; CamErr err; err = theCamera.begin(); if (err != CAM_ERR_SUCCESS) return false; if (theCamera.getDeviceType() == CAM_DEVICE_TYPE_UNKNOWN) return false; err = theCamera.setAutoWhiteBalanceMode(CAM_WHITE_BALANCE_AUTO); if (err != CAM_ERR_SUCCESS) return false; err = theCamera.setStillPictureImageFormat( EI_CAMERA_RAW_FRAME_BUFFER_COLS, EI_CAMERA_RAW_FRAME_BUFFER_ROWS, CAM_IMAGE_PIX_FMT_YUV422 ); if (err != CAM_ERR_SUCCESS) return false; ei_camera_capture_out = (uint8_t*)ei_malloc(EI_CAMERA_RAW_FRAME_BUFFER_COLS * EI_CAMERA_RAW_FRAME_BUFFER_ROWS * 3 + 32); ei_camera_capture_out = (uint8_t*)ALIGN_PTR((uintptr_t)ei_camera_capture_out, 32); if (ei_camera_capture_out == nullptr) return false; is_initialised = true; return true; } void ei_camera_deinit(void) { if (ei_camera_capture_out) { ei_free(ei_camera_capture_out); ei_camera_capture_out = nullptr; } is_initialised = false; } bool ei_camera_capture(uint32_t img_width, uint32_t img_height) { if (!is_initialised) return false; bool do_resize = false; CamImage img = theCamera.takePicture(); if (!img.isAvailable()) return false; if (ei::EIDSP_OK != ei::image::processing::yuv422_to_rgb888( ei_camera_capture_out, img.getImgBuff(), img.getImgSize(), ei::image::processing::BIG_ENDIAN_ORDER)) { return false; } uint32_t resize_col_sz, resize_row_sz; calculate_resize_dimensions(img_width, img_height, &resize_col_sz, &resize_row_sz, &do_resize); if (do_resize) { ei::image::processing::crop_and_interpolate_rgb888( ei_camera_capture_out, EI_CAMERA_RAW_FRAME_BUFFER_COLS, EI_CAMERA_RAW_FRAME_BUFFER_ROWS, ei_camera_capture_out, resize_col_sz, resize_row_sz ); } return true; } static int ei_camera_get_data(size_t offset, size_t length, float *out_ptr) { size_t pixel_ix = offset * 3; for (size_t i = 0; i < length; i++) { out_ptr[i] = (ei_camera_capture_out[pixel_ix] << 16) | (ei_camera_capture_out[pixel_ix + 1] << 8) | (ei_camera_capture_out[pixel_ix + 2]); pixel_ix += 3; } return 0; } int calculate_resize_dimensions(uint32_t out_width, uint32_t out_height, uint32_t *resize_col_sz, uint32_t *resize_row_sz, bool *do_resize) { const ei_device_resize_resolutions_t list[] = { {64, 64}, {96, 96}, {160, 120}, {160, 160}, {320, 240}, }; *resize_col_sz = EI_CAMERA_RAW_FRAME_BUFFER_COLS; *resize_row_sz = EI_CAMERA_RAW_FRAME_BUFFER_ROWS; *do_resize = false; for (size_t ix = 0; ix < (sizeof(list) / sizeof(list[0])); ix++) { if ((out_width <= list[ix].width) && (out_height <= list[ix].height)) { *resize_col_sz = list[ix].width; *resize_row_sz = list[ix].height; *do_resize = true; break; } } return 0; } #if !defined(EI_CLASSIFIER_SENSOR) || EI_CLASSIFIER_SENSOR != EI_CLASSIFIER_SENSOR_CAMERA #error "Invalid model for current sensor" #endif ```
## # 今後の展望 **・複数個体対応** うちで飼っているメダカは複数いるのなので **・行動パターン解析** 元気度や、気温と活動量の相関とか解析したい センサを増やして水の汚れとかもとりたい **・双方向通信** # 参考文献
・SPRESENSEと他パーツの通信
[Arduinoで防水温度センサを使ってみた](https://knkomko.hatenablog.com/entry/2019/09/29/041827)
[SpreM5GPSense: SPRESENSEとM5Stackで作るGPSシステム](https://elchika.com/article/822e2b64-3f4a-4bbf-b4d6-03f1a059b858/) ・スプライトファイルの格納
[ArduinoIDE 2でESP32のファイルシステムにファイルを格納する方法](https://qiita.com/kumakumao/items/be51f174bfeb0e4a6a06)
[SpreM5GPSense: SPRESENSEとM5Stackで作るGPSシステム](https://elchika.com/article/822e2b64-3f4a-4bbf-b4d6-03f1a059b858/)
・Edge Impulse関連
[Object detection with centroids](https://docs.edgeimpulse.com/tutorials/end-to-end/object-detection-centroids) [Edge ImpulseでSpresense用の物体検出モデル作成(その2)](https://tomosoft.jp/design/?p=45697)