KTR-27Sのアイコン画像
KTR-27S 2025年01月27日作成 (2025年01月28日更新) © MIT
製作品 製作品 閲覧数 120
KTR-27S 2025年01月27日作成 (2025年01月28日更新) © MIT 製作品 製作品 閲覧数 120

Spresenseで作る金魚みまもりカメラ

作ったもの

金魚の活動量見守りカメラ

なんで作ったの?

ペットを飼っている方なら、ペットが健康かどうかは気になりますよね

犬や猫のようにある程度人と触れ合ってコミュニケーションが取れるような動物ならともなく、金魚のような動物はひたすら観察して様子を見るしかありません。

ただ、平日仕事で家を空けている場合は昼間どんな様子なのかずっと見るわけにもいかないし、金魚は人間のことを「餌をくれる大きな存在」としか思っていないので、近づいて水槽を覗き込むと完全にエサくれモードになって浮き足立ってしまい、素の様子を確認できません。

というわけで、省電力なSpresenseで常時稼働する水槽見守りカメラを作り、昼間の金魚の活動量を監視してみることにします。

大まかな方針

まずはカメラで水槽を撮影します。部屋の明かりや水槽の照明など、明るさ条件が多少変わっても明瞭な画像を撮れるよう、モニター提供いただいたHDRカメラを使用します。
次に、取得したカメラ画像の各ピクセルのRGB値を解析します。金魚が写っているピクセルはオレンジ色、その他の領域はオレンジ以外の色となります。カメラ画像内のオレンジ色の領域の中心座標を、そのときの金魚の位置と認識させることにします。

必要なもの

・Spresense メインボード(プログラムの実行のため)
・Spresense 拡張ボード(ログをSDカードに保存するため)
・Spresense HDRカメラボード(水槽を撮影するため ※HDRではない普通のカメラボードでも可)

金魚の位置判定

金魚の飼育では、壁や床などの色は暗い方がストレスを感じないらしいので、壁も底面の砂利も黒にしています。ですので、各ピクセルのRGB値値を抽出して、それぞれ事前に決めておいたオレンジ色の閾値に基づき、オレンジ色か否かを判定します。閾値は照明条件によって変わってきますので、コード内の数値を参考に適宜調整してください。
オレンジ領域判定の動作確認のため、オレンジ色領域の中心座標に青い印を打ってSDカードに保存するコードを用意し、これを実行してみます。
青い印を打つためにプログラム側でカメラ画像のピクセルRGB値をいじっている都合上、保存する画像ファイル形式はBMPとします。jpg形式で保存してしまうと、パソコン側ではファイルが破損している扱いとなってしまい、画像ファイルを開くことができませんでした。
takePhotoAndSave()は、動作確認のためにただ写真を撮影して保存するだけの関数です。消してもいいですが一応残しています。
それから、画像ピクセルの解析は大きい画像サイズ(すなわち高解像度での撮影)では途中でエラーが出てフリーズしてしまったので、せっかく良いカメラデバイスなのですが画質はQVGAまで落としています(私のスキルの問題でこうせざるを得なかっただけで、解決策があるのかもしれません…)

金魚の位置のログを取るコード(動作確認用の処理も含む)

#include <Arduino.h> #include <Camera.h> #include <SDHCI.h> #include <stdio.h> /* for sprintf */ #include <stdint.h> SDClass theSD; File myFile; void drawCircle(CamImage img, int width, int height, int centerX, int centerY); void savePointLog(int x, int y); #define BMP_HEADER_SIZE 54 // BMPファイルヘッダーサイズ #define ORANGE_TH_R 200 #define ORANGE_TH_G 150 #define ORANGE_TH_B 100 int cnt; 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; } } void writeBmpFile(const char *filename, uint8_t *imageData, int width, int height) { File file = theSD.open(filename, FILE_WRITE); if (!file) { Serial.println("Failed to open file for writing."); return; } // BMPヘッダー作成 uint32_t fileSize = 54 + (width * height * 2); // ヘッダー+データサイズ(RGB565) uint16_t reserved = 0; uint32_t dataOffset = 54; uint32_t headerSize = 40; uint16_t planes = 1; uint16_t bitCount = 16; uint32_t compression = 3; // BI_BITFIELDS (RGB565) uint32_t imageSize = width * height * 2; uint32_t xPelsPerMeter = 2835; // 解像度: 72 DPI uint32_t yPelsPerMeter = 2835; uint32_t clrUsed = 0; uint32_t clrImportant = 0; uint32_t redMask = 0xF800; // RGB565 Red uint32_t greenMask = 0x07E0; // RGB565 Green uint32_t blueMask = 0x001F; // RGB565 Blue // ファイルヘッダー file.write('B'); file.write('M'); file.write((uint8_t *)&fileSize, 4); file.write((uint8_t *)&reserved, 2); file.write((uint8_t *)&reserved, 2); file.write((uint8_t *)&dataOffset, 4); // 情報ヘッダー file.write((uint8_t *)&headerSize, 4); file.write((uint8_t *)&width, 4); file.write((uint8_t *)&height, 4); file.write((uint8_t *)&planes, 2); file.write((uint8_t *)&bitCount, 2); file.write((uint8_t *)&compression, 4); file.write((uint8_t *)&imageSize, 4); file.write((uint8_t *)&xPelsPerMeter, 4); file.write((uint8_t *)&yPelsPerMeter, 4); file.write((uint8_t *)&clrUsed, 4); file.write((uint8_t *)&clrImportant, 4); // マスク file.write((uint8_t *)&redMask, 4); file.write((uint8_t *)&greenMask, 4); file.write((uint8_t *)&blueMask, 4); // ピクセルデータ(上下反転) for (int y = height - 1; y >= 0; y--) { file.write(&imageData[y * width * 2], width * 2); } file.close(); Serial.printf("Image saved as BMP file named %s\n", filename); } // オレンジ領域の中心座標を計算 void processImage(CamImage img) { // 画像フォーマットを RGB565 に変換 img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565); // 画像データをポインタから取得 uint8_t *image = img.getImgBuff(); int width = img.getWidth(); int height = img.getHeight(); Serial.printf("image size = %d x %d\n", width, height); // オレンジ領域の中心座標を計算 long totalX = 0, totalY = 0; int orangePixelCount = 0; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { int index = (y * width + x) * 2; //ピクセルのRGB値の取り出し uint16_t pixel = image[index] | (image[index + 1] << 8); int r = (pixel >> 11) & 0x1F; int g = (pixel >> 5) & 0x3F; int b = pixel & 0x1F; //RGB565形式のRGBデータを0~255に変換 r = (r * 255) / 31; g = (g * 255) / 63; b = (b * 255) / 31; //オレンジ色の判定(RGBの閾値はカメラや照明条件次第で適宜調整) if (r > ORANGE_TH_R && g > ORANGE_TH_G && b < ORANGE_TH_B) { totalX += x; totalY += y; orangePixelCount++; } } } if (orangePixelCount > 0) { int centerX = totalX / orangePixelCount; int centerY = totalY / orangePixelCount; //見つかったオレンジ領域の中心座標をシリアル出力 Serial.print("Orange area center: ("); Serial.print(centerX); Serial.print(", "); Serial.print(centerY); Serial.println(")"); //見つかったオレンジ領域の中心座標をテキストファイルに追記 savePointLog(centerX, centerY); //見つかったオレンジ領域の中心座標に青い丸を打つ処理(動作確認用) drawCircle(img, width,height,centerX, centerY); } } // 画像に青丸を描画する関数 void drawCircle(CamImage img, int width, int height, int centerX, int centerY) { // 画像データをポインタから取得 uint8_t *image = img.getImgBuff(); int radius = 10; for (int y = -radius; y <= radius; y++) { for (int x = -radius; x <= radius; x++) { if (x * x + y * y <= radius * radius) { // 円の内部判定 int drawX = centerX + x; int drawY = centerY + y; if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { int index = (drawY * width + drawX) * 2; image[index] = 0x1F; // ピクセルのRGB値を青 (RGB565で青=0x001F)にする image[index + 1] = 0x00; } } } } Serial.println("draw circle process finish"); char filename[16] = {0}; sprintf(filename, "PICT%03d.bmp", cnt); writeBmpFile(filename,image, width, height); cnt++; } void savePointLog(int x, int y){ // log.txtファイルを開く File logFile = theSD.open("log.txt", FILE_WRITE); if (logFile) { // "x, y"の形式で値を書き込む logFile.print(x); logFile.print(", "); logFile.println(y); // ファイルを閉じる logFile.close(); Serial.println("log.txt saved."); } else { Serial.println("log.txtファイルを開けませんでした。"); } } void CamCB(CamImage img){ if(img.isAvailable()){ img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565);//カメラの取得値(16bit YUV422)をRGB565に変換 } } void setup(){ //オンボードLEDを動作確認のため点灯させるので、出力設定 pinMode(LED0, OUTPUT); pinMode(LED1, OUTPUT); pinMode(LED2, OUTPUT); pinMode(LED3, OUTPUT); digitalWrite(LED0, LOW); Serial.begin(115200); while (!Serial){ digitalWrite(LED3, HIGH); } digitalWrite(LED3, LOW); CamErr err; // カメラ初期化 Serial.println("Prepare camera"); err = theCamera.begin(); if (err != CAM_ERR_SUCCESS) { digitalWrite(LED3, HIGH); printError(err); } digitalWrite(LED3, LOW); Serial.println("Start streaming"); err = theCamera.startStreaming(true, CamCB); if (err != CAM_ERR_SUCCESS) { digitalWrite(LED3, HIGH); printError(err); } digitalWrite(LED3, LOW); // SDカード初期化 while (!theSD.begin()) { /* wait until SD card is mounted. */ Serial.println("Insert SD card."); digitalWrite(LED2, HIGH); } digitalWrite(LED2, LOW); Serial.println("Set still picture format"); err = theCamera.setStillPictureImageFormat( CAM_IMGSIZE_QVGA_H, CAM_IMGSIZE_QVGA_V, CAM_IMAGE_PIX_FMT_RGB565); if (err != CAM_ERR_SUCCESS) { digitalWrite(LED3, HIGH); printError(err); } digitalWrite(LED3, LOW); Serial.println("Set Auto white balance parameter"); err = theCamera.setAutoWhiteBalanceMode(CAM_WHITE_BALANCE_DAYLIGHT); if (err != CAM_ERR_SUCCESS) { digitalWrite(LED3, HIGH); printError(err); } digitalWrite(LED3, LOW); // HDRの設定(必要に応じて) // Serial.println("Set HDR"); // err = theCamera.setHDR(CAM_HDR_MODE_ON); // if (err != CAM_ERR_SUCCESS) // { // digitalWrite(LED3, HIGH); // printError(err); // } digitalWrite(LED0, HIGH); Serial.println("Setup complete"); } void takePhotoAndSave(){ // カメラから画像を取得 CamImage img = theCamera.takePicture(); //画像サイズを取得&コンソール表示 int imgWidth = img.getWidth(); int imgHeight = img.getHeight(); Serial.printf("Camera img width = %d\n", imgWidth); Serial.printf("Camera img height = %d\n", imgHeight); //ファイルとして写真を保存するため、ファイル名を作成 char filename[16] = {0}; sprintf(filename, "PICT%03d.JPG", cnt); //ファイルを保存する if (img.isAvailable()) { /* Create file name */ char filename[16] = {0}; sprintf(filename, "PICT%03d.JPG", cnt); Serial.print("Save taken picture as "); Serial.print(filename); Serial.println(""); theSD.remove(filename); File myFile = theSD.open(filename, FILE_WRITE); myFile.write(img.getImgBuff(), img.getImgSize()); myFile.close(); } else { Serial.println("Failed to take picture"); digitalWrite(LED2, HIGH); digitalWrite(LED3, HIGH); } } void loop(){ // カメラから画像を取得 CamImage img = theCamera.takePicture(); Serial.println("カメラ画像からオレンジ色を探す処理"); processImage(img); digitalWrite(LED1, HIGH); delay(100); digitalWrite(LED1, LOW); delay(1900); // 次の撮影まで待機 }

実行結果はこんな感じ
キャプションを入力できます
何枚か撮影して試してみましたが、ちゃんと金魚に重なって青い丸が打たれているので、金魚の検出には問題なさそうです。
ちなみに上の写真はHDRカメラで撮影したものですが、通常のカメラだとこんな感じ。
キャプションを入力できます
HDRの方は暗いところが真っ黒にならずに明るく写っていますね。全体的に若干色味というか明るさが違って写っているので、オレンジ色を判定するときのRGB値の閾値は、使うカメラや照明条件でうまく調整したほうが良さそうです。

金魚の動き(位置)のログを取るため、見つかったオレンジ色領域の中心座標x, yを、テキストデータとして保存します。先程のコード内のsavePointLog(int x, int y)という関数ですね。

撮影して座標の記録を行う一連の処理は2秒に1回実行することにしています。ここは実際の金魚の活発具合によって、間隔を延ばしたり縮めたりしてもいいでしょう。金魚がその気になれば、2秒もあれば60cm水槽の端から端まで泳いで移動することは普通にできるので、正確にログを取りたいなら2秒よりももっと短い間隔にしましょう。
また、実際にロガーとして常時稼働させる場合は、画像を保存する処理である writeBmpFile(filename,image, width, height)の処理は実行しないようにコードを変更したほうが良いと思います。残していてもプログラムとしては動作しますが、ファイルサイズの大きいBMP形式の画像データを2秒に1枚保存するのはSDカードの容量を圧迫するし、画像ファイル自体は見守りカメラの動作には必要ないので……。

というわけで、水槽の正面に本作品をセットして、このコードを実行してしばらく放置したあとSDカード内のテキストファイルを確認してみると、こんな感じになっていました。

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

ちゃんと金魚のいる位置を記録しているっぽいです。
一応動作確認として画像も保存させていたのでそれらと見比べてみましたが、正しく座標を判定、記録できているようでした。
キャプションを入力できます
(青丸が大きすぎて金魚が完全に隠れてしまっている写真もあって分かりづらいですが、ちゃんと継続的に金魚の位置に青丸が打たれています。)

ログデータの可視化(PC)

テキストデータのままだと分かりづらいので、金魚の動きをタイムラプス的に可視化します。こちらはspresenseでの処理ではなく、実際にログを確認するパソコン側での処理となります。
Spresenseコンテストの対象からやや外れるような感じもするので、コードだけ貼り付けておきます。サラッと説明すると、テキストとして保存された座標値から、金魚の動きをgifアニメーションにして保存するpythonコードです。

log.txtの内容をgifアニメにする処理

import os import re from PIL import Image, ImageDraw, ImageSequence def parse_coordinates(line): match = re.match(r"\s*(\d+)\s*,\s*(\d+)\s*", line) if match: return int(match.group(1)), int(match.group(2)) return None def main(): # ユーザーからの入力でlog.txtのパスを取得 log_path = input("Enter the absolute path to log.txt: ").strip() # ファイルの存在を確認 if not os.path.isfile(log_path): print("Error: log.txt file not found at the specified path.") return # 画像サイズ、色の指定 width, height = 320, 240 white = (255, 255, 255) orange = (255, 165, 0) black = (0, 0, 0) # フレームを格納するリスト frames = [] try: with open(log_path, 'r') as file: lines = file.readlines() previous_point = None for i, line in enumerate(lines): # 座標をパース point = parse_coordinates(line) if point is None: print(f"Warning: Skipping invalid line {i + 1}: {line.strip()}") continue # 新しい画像を作成 image = Image.new("RGB", (width, height), white) draw = ImageDraw.Draw(image) # 前のフレームがあればそれをコピー if frames: image.paste(frames[-1]) # 点を描画 draw.ellipse( (point[0] - 2, point[1] - 2, point[0] + 2, point[1] + 2), fill=orange ) # 直前の点と線を結ぶ if previous_point is not None: draw.line((previous_point, point), fill=black, width=1) # 現在の画像をフレームに追加 frames.append(image) # 現在の点を次回用に保存 previous_point = point # GIFを保存 if frames: output_path = os.path.join(os.path.dirname(log_path), "output.gif") frames[0].save( output_path, save_all=True, append_images=frames[1:], duration=250, # 0.25秒間隔 loop=0 ) print(f"GIF saved as {output_path}") else: print("Error: No valid points were found in log.txt.") except Exception as e: print(f"Error: {e}") if __name__ == "__main__": main()

クリックするとgifが動いて見えるはず

水槽内を金魚が泳いでる様子がわかりますね!(ちゃんとgif画像が動いて表示されるかな?私の環境だと、記事中の画像では動いていませんがクリックしたら動いて表示されました。)
このログの様子を見て、ずーっと底に沈んでいたり、逆に水面近くに浮きっぱなしだったりすると、ちょっと健康状態が怪しいかも……というのが分かりそうですね。

ちなみに、今回Spresenseとカメラを固定して設置できるケースを3Dプリンターで出力・使用しています。(当記事のサムネイルの写真)
こちらは、dtech氏のArduino UNO用ケースの3Dデータを改変して、サイズを微調整したりカメラ固定部を追加したりしています。
通常カメラとHDRカメラの両方に対応しようとして、穴の位置と形状がかなり無理矢理な感じになってるし、HDRカメラボードは若干大きいので4隅ではなく下2つの穴でしか固定できませんが、使えるからヨシ!
キャプションを入力できます
需要があるかはわかりませんが、3Dモデルを下記リンクにて公開しておきます。
https://www.thingiverse.com/thing:6929113

今後の展望

今回は無線通信のできる拡張ボードが手元になかったので、定期的にログが保存されたSDカードを抜き取って読み込むという方法になりましたが、これは若干面倒なのでゆくゆくは無線でログデータをPCに送りたいですね。
あと今回のオレンジ色領域判定は、オレンジ色の各ピクセルの重心を金魚の位置座標とするという方針のため、水槽内に金魚が1匹しかいないような、オレンジ色領域が1つだけの場合にしか使えません。複数のオレンジ色領域に対応できるよう、ピクセル探索部分のアルゴリズムを変更したいです。

おまけ

一番の心残りは、Spresenseの特色であるディープラーニング推論実行をできなかったこと…!
もともとモニター応募の時点では単なるピクセルRGB解析じゃなくてディープラーニング画像判定で複数の金魚に対応、かつ個体識別します!と言って申請したのですがそこは達成できませんでした。ソニーさんごめんなさい。
一応苦労の過程だけ記録しておきます。

データセットの用意

ディープラーニングの学習には、大量の画像データが必要となります。NEWRAL NETWORK CONSOLE(以降、NNC)の使用方法を解説するYouTube動画を見ると、画像は10万点くらい欲しい、みたいな発言がありました。そんなに用意できないよ!
しかも我が家の金魚たちを個体識別するには、個体ごとに数万枚の写真を撮らないといけない?
それは流石に厳しいので、一旦は個体識別は諦めて、金魚が写っているかどうかをAIで判別しようと思います。
データセットは、フリーのデータセットを使います。画像データのほか、物体検出に必要なラベルと座標などが記録されたラベルデータもついています。Roboflowにアップロードされている以下のデータセットを使用しました。

https://universe.roboflow.com/icarcife-training/training-wywgb/dataset/1

NNCで学習

このデータセットを、Youtubeの解説動画を参考にNNCで使用可能なデータセット形式に処理して、NNCのサンプルプロジェクトであるsynthetic_image_object_detectionをベースにニューラルネットを組みます。
キャプションを入力できます

今回使用するデータセットの画像は、サンプルと違い画像がモノクロではなくカラーだったり画像サイズが違っていたりするので、InputChやInputWidthなどの変数を変えて、学習を実行してみます。
キャプションを入力できます

これは……学習できているのか……?AIのことなんもわからん……
とりあえず学習の処理自体は完了したので、モデルをエクスポートします。

動作確認(PC上のPython)

一旦動作確認のため、これを使っていきなりSpresenseで物体検出はせず、PC上でPythonプログラムで実行してみます。
さて、はたしてちゃんと金魚は認識されるかな?ちゃんと認識していれば金魚を青い枠が囲むような感じで画像が出力されるはずですが……

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

全然できてない!!!
データセットの画像枚数が少ないのがいけなかったのか(140枚くらいしか無い)、NNCでの学習のさせ方がそもそも良くなかったのか、私のAI知識が未熟なために原因はわかりませんが、とにかくNNCでの物体検出は今回は失敗してしまいました。今後リベンジしたいです。

ログインしてコメントを投稿する