編集履歴一覧に戻る
miuriteのアイコン画像

miurite が 2026年01月31日05時00分32秒 に編集

コメント無し

タイトルの変更

-

【世界初?】Spresense×M5Stackで愛魚をVtuber化してみた-遠隔みまもりシステム-

+

【世界初?】Spresense×M5Stackで愛魚をVtuber化-プライバシー完備の見守システム

本文の変更

-

==水槽内で暮らすメダカの様子をリアルタイムでかわいく可視化・配信する省電力な見守りIoTシステムを作成しました==

+

==水槽内で暮らすメダカの様子をリアルタイムでかわいく可視化・配信する見守りIoTシステムを作成しました==

+

==画像からメダカの位置を特定し座標データのみを抽出することで、プライバシーの確保と通信量削減を両立しています==

# 動機  厳しい寒さが続く今日この頃(執筆日時:2026年1月)、水槽の水温も下がり、メダカたちが底の方でじっとしている時間が増えました。 外出先にいても「ちゃんと生きてるかな」「今どこにいるんだろう」と気になってしまいます。 遠隔地から様子を確認したいが、カメラ映像をそのまま垂れ流すのはプライバシー的にちょっと..... そこで私はひらめきました。 **「メダカの動きだけをトラッキングして、バーチャルな姿で再現すればいいのでは?」** そうして誕生したのが、画面上で生きる世界初(?)のVtuberメダカ "たいようくん"です。 # 作ったもの 今回制作したのは、水槽内で暮らすメダカの様子をリアルタイムで可視化、配信する見守りシステムです。 M5stackやWEBページでバーチャル水槽が閲覧でき、たいようくんの活動量や水温が、アバターの動きとして直感的に分かるようになっています。 ## システム構成 ![システム構成図](https://camo.elchika.com/750eec3c89a9b3f30124d0bd6b2d6b6b8c98e3e2/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33653461656537392d326534612d343533612d613133612d3534366637653863326639382f65363761623039342d326633342d343933342d393931332d386266616461663230356335/) 本システムでは、センシングデバイス(SPRESENSE)と表示デバイス(M5Stack)の役割を完全に分離しています。 **1.SPRESENSE** カメラモジュールで水槽画像を撮影し、FOMO による物体検出でメダカの位置(座標)を推定します。同時に、水温センサ(DS18B20)から水温を取得し、これらの「座標+水温」をまとめて UART通信で M5Stack Core2 に送信します。

+

カメラ入力とセンシング、エッジAIでの推論を単体で完結でき、長時間安定して省電力で稼働することができるため、SPRESENSEを採用しました。

**2.M5Stack** presense から受け取ったデータを扱いやすいIoTデータ(JSON)に整形し、画面上にバーチャル水槽として可視化します。また、NTP により時刻を取得して、データにタイムスタンプを付与できるようにしています。整形したJSONデータはWi-Fi経由でクラウドへ送信されます。 **3.クラウド** クラウド側では、受信したデータを DBに時系列データとして保存し、PCやスマートフォンから遠隔モニタリングできるようにします。これにより、外出先でも水温やメダカの位置変化を継続的に観察でき、後から分析にも利用できます。 ## 特徴 **1.JSONによる軽量かつ安全なデータ通信**

-

SPRESENSEでセンシングしたデータを、M5StackでJSON形式に変換しインターネット上へ配信しています。映像を送らないため通信量が極めて少なく、本魚や飼い主のプライバシーも守られます。~~Vtuderの中身バレリスクゼロ~~

+

SPRESENSEでセンシングしたデータを、M5StackでJSON形式に変換しインターネット上へ配信しています。**映像を送らないため通信量が極めて少なく、本魚や飼い主のプライバシーも守られます。**~~Vtuderの中身バレリスクゼロ~~

個人の見守り用途だけでなく、不特定多数に対する愛魚の公開にも利用可能です。 ![キャプションを入力できます](https://camo.elchika.com/9d5efe4d443240ac232640dc296d071438255613/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33653461656537392d326534612d343533612d613133612d3534366637653863326639382f63666637313462322d313664332d346536352d396237612d353735353535316163346130/) **2.愛のあるUI/UX**

-

映像を配信しない分、M5Stack上の「バーチャルたいようくん」の見た目や動きのかわいさにこだわりました。

+

映像を配信しない分、M5Stack上の「バーチャルたいようくん」の見た目や動きのかわいさにこだわりました。離れていても、間近で生きている気配が感じられるようなビジュアルにしています。

 また、たいようくんの健康を最優先し、夜間(22:00~05:00)は水槽を照らすLEDを消灯します。システムも「Sleep Mode」へ移行し、カメラ画像からの位置推定を停止します。(水温のセンシングは継続) さらに、Sleep Mode専用の画面を表示しM5Stackのディスプレイの輝度を落とすことで、さらなる省電力化を実現しました。 ![Sleep Modeの様子](https://camo.elchika.com/a27f1e1883a45e97d525648a9d438a5c36685d99/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33653461656537392d326534612d343533612d613133612d3534366637653863326639382f64303663396631642d613165362d343534362d393933332d316565386466613837643736/) 人間とメダカどちらにも優しいシステムを目指しました。 **3.高い拡張性** 本システムでは、Spresense側で推論した位置・状態と、M5Stack側での見た目表現を完全に分離して設計しています。 そのため、今回はたいようくんの見た目に寄せたスプライトを使用していますが、ロジック部分には一切手を加えることなく、全く異なるキャラクターや抽象的な表現へ差し替えることも可能です。 メダカだけに限らず、ハムスターなどの他のペットにも応用可能な汎用性があります。 # 制作過程 ## 用意したもの ・Spresense(拡張ボード) ・Spresenseカメラモジュール ・M5stack Core2 for AWS ・温度センサ(DS18B20) ・撮影用水槽 ・メダカ ## ハードウェアの配線 このように配線します。 **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 | 役割 | | :--- | :--- | :--- | | 3.3V / 5V | VCC (赤) | 電源供給 | | D2 (任意のGPIO) | DATA (黄/白) | 信号線 (要プルアップ) | | GND | GND (黒) | グランド | ## 機械学習によるメダカの位置検出 一番苦労した部分です...... 位置推定ロジックはSPRESENSE上で動作させるので、当初はSONYのNeural Network Console (NNC) で物体検出モデルを作成するつもりでした。しかし、いろいろ試行錯誤したのですがネットワーク構築や調整に大苦戦し、実用に足るモデルの作成ができなかったため、Edge Inpulseでの開発に変更しました。いつかリベンジしたい...... ### Edge InpulseによるAIモデルの構築 SPRESNSEで撮影した水槽の画像(273枚)をインポートし、アノテーションを行います。 ![キャプションを入力できます](https://camo.elchika.com/c0005cbc124ed938687118932907bafea8ba1cf4/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33653461656537392d326534612d343533612d613133612d3534366637653863326639382f38616261643765372d373866332d343866632d623634342d326661656366613862656463/) [こちら](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%と、かなり高精度なモデルを作成することができました。 ![トレーニング結果](https://camo.elchika.com/9300829eb1270cd7cb831d227d3e8bd8396f07ac/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33653461656537392d326534612d343533612d613133612d3534366637653863326639382f32323063643535622d636132662d343639352d613933332d313934383434343233343463/) ![テスト結果](https://camo.elchika.com/837449d3a5a5a314a6ca9733e59b62bda299efbf/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33653461656537392d326534612d343533612d613133612d3534366637653863326639382f32366330613037642d663634622d346436382d623763352d303662333262346630313635/) Edge InpulseのDeproyタブから、作成したモデルをArduino Library形式でビルドし、ダウンロードします。 ### SPRESENSEで動作確認 ダウンロードした推論モデルを、ライブラリとしてインクルードします。(ArduinoIDEのスケッチ→ライブラリのインクルード→.zip形式のライブラリをインストール ) モデルファイルに同梱されていたサンプルコードに、SPRESENSE用のカメラを用いたもの(sony_spresense_camera)があったので、それを元に検出プログラムを作成しました。 コンパイルと書き込みに数分ほど時間がかかり、ついにPCが壊れてしまったのかと焦った記憶があります。(Edge Inpulseでつくったモデルをincludeしたコードは最初のコンパイルにかなり時間がかかるようです) また、SPRESENSEのメモリ設定を1536KBに変更する必要があります。 動作させてみると、水槽の縁にできる強い反射をメダカとして判断することが多く、うまくトラッキングができていませんでした。 ![キャプションを入力できます](https://camo.elchika.com/c6cc756793989aae350663b14aef0eeccb8c9ba5/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33653461656537392d326534612d343533612d613133612d3534366637653863326639382f35306239376539632d626238382d346434302d393735302d383963313536646534653132/) 照明の反射による誤検出を減らすために、被写体やコードの調整を行いました。 一定時間座標が変わらない場合や、bBoxの大きさが不自然な場合は弾くなどの処理を追加したのですが、一番効果があったのは、カメラと水槽の距離を近づけて反射が強い部分が映らないようにすることでした...... メダカが含まれない水槽の画像もデータ画像に含めるべきだったかもしれません。 調整の結果、以下のようにかなりの精度で位置の特定ができるようになりました。(クリックで動きます) ![リアルタイム座標特定プログラムの検証画面GIF(4倍速)](https://camo.elchika.com/a8ae84a50d9ce9a1ffc0b8289f5c6961d60fc073/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f33653461656537392d326534612d343533612d613133612d3534366637653863326639382f38636239376635382d333737632d343863392d383239342d366561343234653863653736/) ## SPRESENSE-M5Stack間の通信 前項で作成したメダカの座標特定コードと、水温センサのセンシングを統合したSPRESENSE用プログラムを作成しました。 SPRESENSEでは標準のOneWireライブラリが動作しないので、[こちら](https://note.com/regnant_saya/n/n2560adf75690)の記事を元にライブラリに変更を加える必要があります。 ```arduino:SPRESNSE側:センシング&エッジAIで座標推論後、UARTで送信 /* 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 ``` ## M5StackによるJSONパース、配信 M5Stack側のコードはかなり長いので、SPRESENSEから受け取ったデータをJSON形式に変換する部分と、JSONをローカルネットワークに配信する部分を抜粋して掲載します。 ```arduino:UART2JSON.ino extern int latestX, latestY, latestMode; extern float latestTemp; extern String latestTime; extern String latestJson; extern String uartLine; extern int lastBgModeLoaded; String toJson(int mode, const String& timeStr, int x, int y, float temp); extern String nowTimeStr(); extern bool loadBackgroundForMode(int mode); extern void resetBubbles(); extern void initSnow(); bool parseCsv5(const String& s, int &mode, String &timeStr, int &x, int &y, float &temp) { int p1 = s.indexOf(','); if (p1 < 0) return false; int p2 = s.indexOf(',', p1 + 1); if (p2 < 0) return false; int p3 = s.indexOf(',', p2 + 1); if (p3 < 0) return false; int p4 = s.indexOf(',', p3 + 1); if (p4 < 0) return false; String modeStr = s.substring(0, p1); timeStr = s.substring(p1 + 1, p2); String xStr = s.substring(p2 + 1, p3); String yStr = s.substring(p3 + 1, p4); String tStr = s.substring(p4 + 1); modeStr.trim(); timeStr.trim(); xStr.trim(); yStr.trim(); tStr.trim(); mode = modeStr.toInt(); x = xStr.toInt(); y = yStr.toInt(); temp = tStr.toFloat(); if (x < 0) x = 0; if (x > 319) x = 319; if (y < 0) y = 0; if (y > 239) y = 239; return true; } String toJson(int mode, const String& timeStr, int x, int y, float temp) { const char* modeName = (mode == 1) ? "SLEEP" : "TRACK"; bool tempValid = (temp > -100.0f && temp < 125.0f && temp != -999.0f); String js = "{"; js += "\"mode\":\""; js += modeName; js += "\","; js += "\"time\":\""; js += timeStr; js += "\","; js += "\"pos\":{"; js += "\"x\":"; js += String(x); js += ","; js += "\"y\":"; js += String(y); js += "},"; js += "\"temp\":"; if (tempValid) js += String(temp, 2); else js += "null"; js += "}"; return js; } // UI側が文字更新するため extern void updateTextUiValuesIfNeeded(); extern ViewMode viewMode; void handleUartRxAndUpdate() { while (Serial2.available()) { char c = (char)Serial2.read(); if (c == '\n') { String raw = uartLine; uartLine = ""; int mode, x, y; float temp; String spTimeStr; if (parseCsv5(raw, mode, spTimeStr, x, y, temp)) { latestMode = mode; latestX = x; latestY = y; latestTemp = temp; // NTP時刻 latestTime = nowTimeStr(); latestJson = toJson(mode, latestTime, x, y, temp); // 背景の切り替え if (mode != lastBgModeLoaded) { loadBackgroundForMode(mode); resetBubbles(); initSnow(); } // Text画面なら差分更新 if (viewMode == VIEW_TEXT_ONLY) { updateTextUiValuesIfNeeded(); } } else { Serial.print("parse failed: "); Serial.println(raw); } } else if (c != '\r') { uartLine += c; if (uartLine.length() > 220) uartLine = ""; } } } ``` ```arduino:net_server.ino extern WebServer server; extern String latestJson; extern const char* WIFI_SSID; extern const char* WIFI_PASS; extern const char* MDNS_NAME; extern bool ntpReady; static void addCors() { server.sendHeader("Access-Control-Allow-Origin", "*"); server.sendHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); server.sendHeader("Access-Control-Allow-Headers", "Content-Type"); } static void handleOptions() { addCors(); server.send(204); } static void handleJson() { addCors(); server.send(200, "application/json; charset=utf-8", latestJson); } // ルートは /json へリダイレクト static void handleRoot() { addCors(); server.sendHeader("Location", "/json"); server.send(302, "text/plain; charset=utf-8", "Redirect to /json"); } void setupNtp() { configTime(9 * 3600, 0, "ntp.nict.jp", "pool.ntp.org", "time.google.com"); struct tm tm; for (int i = 0; i < 30; i++) { if (getLocalTime(&tm, 200)) { ntpReady = true; Serial.println("NTP synced"); return; } delay(200); } ntpReady = false; Serial.println("NTP sync failed (fallback)"); } String nowTimeStr() { struct tm tm; if (!getLocalTime(&tm, 10)) return "--:--:--"; char buf[9]; snprintf(buf, sizeof(buf), "%02d:%02d:%02d", tm.tm_hour, tm.tm_min, tm.tm_sec); return String(buf); } void setupWifiAndServer() { WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) delay(300); setupNtp(); if (MDNS.begin(MDNS_NAME)) { Serial.printf("mDNS: http://%s.local/\n", MDNS_NAME); } // JSON only server.on("/", HTTP_GET, handleRoot); server.on("/json", HTTP_GET, handleJson); server.on("/json", HTTP_OPTIONS, handleOptions); server.onNotFound([]() { addCors(); server.send(404, "text/plain; charset=utf-8", "Not found"); }); server.begin(); } static String nowDateTimeStr_Min() { struct tm tm; if (!getLocalTime(&tm, 10)) return "----/--/-- --:--"; char buf[18]; // "MM/DD HH:MM" snprintf(buf, sizeof(buf), "%02d/%02d %02d:%02d", tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min); return String(buf); } ``` # 今後の展望 **・複数個体対応** 今回はたいようくん1匹のみをトラッキングしましたが、将来的には水槽内にいるすべてのメダカを同時に見守ることができるシステムへ拡張したいと考えています。 **・行動パターン解析** SPRESENSEで収集した水槽内データはDBに蓄積されていきます。この時系列データを解析することで、 • 活動量の増減による「元気度」の可視化 • 水温と行動量の相関分析 • 普段と異なる行動(異常検知)の早期発見 といった、単なる見守りを超えた健康状態の推定を行いたいと考えています。 将来的には水質センサなども追加し、より総合的な環境センシングへ発展させたいです。 **・双方向通信** メダカ→人間への一方的な情報発信から、双方向に干渉し合える新しいシステムへの進化を目指しています。 例えば、自動給餌機と連携し、WEB上からアバターに対して「ギフト」を送ることで現実の水槽へ実際に餌を与えられる「物理スーパーチャット」システムです。 これにより、遠隔地からでも愛魚にご飯をあげるというふれあいが可能になります。 # 参考文献 ・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) ・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)