水槽内で暮らすメダカの様子をリアルタイムでかわいく可視化・配信する見守りIoTシステムを作成しました
エッジAIで画像からメダカの位置を特定し、座標データのみを抽出することで、プライバシーの確保と通信量削減を両立しています
動機
厳しい寒さが続く今日この頃(執筆日時:2026年1月)、水槽の水温も下がり、メダカたちが底の方でじっとしている時間が増えました。
外出先にいても「ちゃんと生きてるかな」「今どこにいるんだろう」と気になってしまいます。
遠隔地から様子を確認したいが、カメラ映像をそのまま垂れ流すのはプライバシー的にちょっと.....
そこで私はひらめきました。
「メダカの動きだけをトラッキングして、バーチャルな姿で再現すればいいのでは?」
そうして誕生したのが、画面上で生きる世界初(?)のVtuberメダカ "たいようくん"です。
作ったもの
↓実際のデモです(クリックで動きます)
今回制作したのは、水槽内で暮らすメダカの様子をリアルタイムで可視化、配信する見守りシステムです。
M5stackやWEBページで実際の水槽とリアルタイムで同期したバーチャル水槽が閲覧でき、たいようくんの活動量や水温が、アバターの動きとして直感的に分かるようになっています。
UI
M5Stack Core2側では、ディスプレイに以下の二種類の画面が表示されます。M5Stackの3つのボタンを活用し、用途に合わせて画面を瞬時に切り替えられるようにしました。
・A(左)ボタン:M5Stackが受信したデータの一覧
(下の画像がぼけていて分かりにくいのですが)画面下部にはSPRESENSEやWi-fiとの接続可否が表示されており、デバッグ時に便利です。
・B(中央)ボタン:バーチャル水槽モード
数値データをドット絵のアニメーションに変換し、画面内に実際の水槽の状態をリアルタイムで再現します。たいようくんの様子を直感的に楽しめます。
WEBページにアクセスすると、このような画面が表示されます。M5Stackのディスプレイで確認できる情報が遠隔地でも閲覧可能です。
システム構成
本システムでは、センシングデバイス(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の中身バレリスクゼロ
個人の見守り用途だけでなく、不特定多数に対する愛魚の公開にも利用可能です。
2.愛のあるUI/UX
映像を配信しない分、M5Stack上の「バーチャルたいようくん」の見た目や動きのかわいさにこだわりました。離れていても、間近で生きている気配が感じられるようなビジュアルにしています。
また、たいようくんの健康を最優先し、夜間(22:00~05:00)は水槽を照らすLEDを消灯します。システムも「Sleep Mode」へ移行し、カメラ画像からの位置推定を停止します。(水温のセンシングは継続)
さらに、Sleep Mode専用の画面を表示しM5Stackのディスプレイの輝度を落とすことで、さらなる省電力化を実現しました。
人間とメダカどちらにも優しいシステムを目指しました。
3.高い拡張性
本システムでは、Spresense側で推論した位置・状態と、M5Stack側での見た目表現を完全に分離して設計しています。
そのため、今回はたいようくんの見た目に寄せたスプライトを使用していますが、ロジック部分には一切手を加えることなく、全く異なるキャラクターや抽象的な表現へ差し替えることも可能です。
メダカだけに限らず、ハムスターなどの他のペットにも応用可能な汎用性があります。
制作過程
用意したもの
・Spresense(拡張ボード)
・Spresense HDRカメラモジュール
・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枚)をインポートし、アノテーションを行います。
こちらのチュートリアルに沿って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)があったので、それを元に検出プログラムを作成しました。
コンパイルと書き込みに数分ほど時間がかかり、ついにPCが壊れてしまったのかと焦った記憶があります。(Edge Inpulseでつくったモデルをincludeしたコードは最初のコンパイルにかなり時間がかかるようです)
また、SPRESENSEのメモリ設定を1536KBに変更する必要があります。(メモリが足りず、カメラの初期化が完了しないときがある)
動作させてみると、水槽の縁にできる強い反射をメダカとして判断することが多く、うまくトラッキングができていませんでした。
照明の反射による誤検出を減らすために、被写体やコードの調整を行いました。
一定時間座標が変わらない場合や、bBoxの大きさが不自然な場合は弾くなどの処理を追加したのですが、一番効果があったのは、カメラと水槽の距離を近づけて反射が強い部分が映らないようにすることでした......
メダカが含まれない水槽の画像もデータ画像に含めるべきだったかもしれません。
調整の結果、以下のようにかなりの精度で位置の特定ができるようになりました。(クリックで動きます)
SPRESENSE-M5Stack間の通信
前項で作成したメダカの座標特定コードと、水温センサのセンシングを統合したSPRESENSE用プログラムを作成しました。
SPRESENSEでは標準のOneWireライブラリが動作しないので、こちらの記事を元にライブラリに変更を加える必要があります。
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をローカルネットワークに配信する部分を抜粋して掲載します。
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 = "";
}
}
}
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で防水温度センサを使ってみた
SpreM5GPSense: SPRESENSEとM5Stackで作るGPSシステム
・スプライトファイルの格納
ArduinoIDE 2でESP32のファイルシステムにファイルを格納する方法
・Edge Impulse関連
Object detection with centroids
Edge ImpulseでSpresense用の物体検出モデル作成(その2)


-
miurite
さんが
前の金曜日の16:06
に
編集
をしました。
(メッセージ: 初版)
-
miurite
さんが
前の金曜日の16:39
に
編集
をしました。
-
miurite
さんが
前の金曜日の18:00
に
編集
をしました。
-
miurite
さんが
前の金曜日の21:31
に
編集
をしました。
-
miurite
さんが
前の金曜日の22:22
に
編集
をしました。
-
miurite
さんが
前の土曜日の3:07
に
編集
をしました。
-
miurite
さんが
前の土曜日の4:26
に
編集
をしました。
-
miurite
さんが
前の土曜日の5:00
に
編集
をしました。
-
miurite
さんが
前の土曜日の14:02
に
編集
をしました。
ログインしてコメントを投稿する