shokiku が 2026年01月31日12時30分16秒 に編集
初版
タイトルの変更
カメラ映像に動きがあったら録画する
タグの変更
SPRESENSE
Arduino
CAMERA
メイン画像の変更
記事種類の変更
セットアップや使用方法
ライセンスの変更
(MIT) The MIT License
本文の変更
# はじめに 知識もなく何を作るかも決めてないのにSONYが好きだからという理由だけで申し込んでしまいました。 もちろん他のデバイスや材料もないので、モニター品だけを使ってできそうな「カメラ映像に動きがあったら録画する」いわゆる監視カメラ的なものを作ってみることにしました。 # できたもの      # 作り方 Arduinoが何かもわかっていませんので、基本的にはClaudeCodeというコーディングが得意なChatGPT的なAIにお願いしました。 人間がやったのは配線作業とAIの嘘を許し続けることだけです。 # 構成 基本的にはモニター品のみを使用しました。 こちらで用意したのはSDカード、給電・通信用のUSBケーブル、ジャンパ線、ブレッドボードくらいです。 ## 部品 | 部品名・ツール名 | 備考・用途など | |:---:|:---| | SPRESENSE メインボード | モニター品。給電、PCとの通信、カメラ接続 | | SPRESENSE 拡張ボード | モニター品。SDカード挿入 | | W5500-Ether | モニター品。LANケーブル接続(とりあえず使わねばと思い、録画ファイルダウンロード用のHttpサーバーを用意) | | Spresense HDRカメラボード | モニター品 | | ILI9341搭載2.8インチSPI制御タッチパネル付TFT液晶 MSP2807 | モニター品 。ステータス表示、操作 | | SDカード | 昔Switchで使ってたやつ。録画ファイル保存 | | Type-A to MicroUSBケーブル | 昔Androidで使ってたやつ。給電・通信 | | ジャンパ線 | 20本くらい必要?Amazonで1000円くらい | | ブレッドボード | 1つのピンに2本繋がなきゃいけなくて必要でした。Amazonで1000円くらい | ## 配線図  ## ソースコード | ファイル | 用途 | |---------|------| | SPRESENSE-1.ino | Arduinoエントリーポイント。Appクラスのsetup/loopを呼ぶだけ | | App.h | メインアプリケーションクラスのヘッダ。ピン定義、定数、クラス宣言 | | App.cpp | メインアプリケーション実装。カメラ/LCD/タッチ/SD初期化、画面管理、タッチ処理 | | MotionDetector.h | 動体検知クラスのヘッダ | | MotionDetector.cpp | JPEGサイズ変化率による動体検知 | | AviRecorder.h | AVI録画/再生クラスのヘッダ | | AviRecorder.cpp | MJPEG AVI形式での録画・再生 | | WebServer.h | Webサーバークラスのヘッダ | | WebServer.cpp | W5500イーサネット経由のファイル操作 | ```arduino:SPRESENSE-1.ino #include "App.h" App app; void setup() { app.setup(); } void loop() { app.loop(); } ``` ```cpp:App.h #ifndef APP_H #define APP_H #include <SPI.h> #include <Adafruit_GFX.h> #include <Adafruit_ILI9341.h> #include <SDHCI.h> #include <RTC.h> #include <Camera.h> #include <JPEGDEC.h> #include <XPT2046_Touchscreen.h> #include "MotionDetector.h" #include "AviRecorder.h" #include "WebServer.h" // ピン設定(LCD) #define TFT_CS 10 // タッチ併用時はGPIO必須(-1/GNDは不可) #define TFT_RST 8 #define TFT_DC 9 // タッチパネル設定 #define T_CS 7 // D7 #define T_IRQ 6 // D6 // W5500-Ether専用ボード設定 #define ETH_CS 24 // D24 #define ETH_RST 21 // D21 // カメラ設定 #define CAM_WIDTH 320 #define CAM_HEIGHT 240 #define RECORD_FPS 10 // JPEGバッファ設定(RAMキャッシュ) #define JPEG_BUFFER_FRAMES 5 #define JPEG_MAX_SIZE 15000 // 動体検知設定(JPEGサイズ変化率%) #define MOTION_THRESHOLD 10 #define RECORD_DELAY_MS 3000 // ステータス画面更新間隔 #define STATUS_UPDATE_MS 1000 // ファイル一覧設定 #define MAX_FILE_LIST 20 // 最大表示ファイル数 #define FILES_PER_PAGE 5 // 1ページあたりのファイル数 // 画面状態 enum ScreenState { SCREEN_STATUS, // ステータス画面(監視中) SCREEN_FILE_LIST, // ファイル一覧画面 SCREEN_PLAYBACK // 再生画面 }; // WebServerから参照されるグローバル変数 extern bool g_isRecording; extern int g_motionPercent; extern int g_fileCount; class App { public: App(); void setup(); void loop(); static void onCameraFrame(CamImage img); private: static Adafruit_ILI9341 _tft; static XPT2046_Touchscreen _touch; static SDClass _sd; static MotionDetector _detector; static AviRecorder _recorder; static WebServer _webServer; static bool _cameraReady; static bool _sdReady; static bool _networkReady; static unsigned long _lastMotionTime; static unsigned long _lastFrameTime; static unsigned long _frameInterval; static JPEGDEC _jpeg; static uint16_t _rgbBuffer[CAM_WIDTH * CAM_HEIGHT]; // JPEGバッファ(RAMキャッシュ) static uint8_t _jpegBuffer[JPEG_BUFFER_FRAMES][JPEG_MAX_SIZE]; static uint32_t _jpegSizes[JPEG_BUFFER_FRAMES]; static volatile int _bufferWriteIdx; static volatile int _bufferReadIdx; static volatile int _bufferCount; void initLCD(); void initSD(); void initCamera(); void initNetwork(); void showStatus(const char* msg, uint16_t color); void updateStatusScreen(bool forceRedraw = false); void drawFileListScreen(); void drawPlaybackScreen(); void changeScreen(ScreenState newState); void loadFileList(); void playSelectedFile(); void stopPlayback(); void handleSerialCommand(char cmd); void handleTouch(); static unsigned long _lastStatusUpdate; // 画面状態 static ScreenState _currentScreen; static bool _screenNeedsRedraw; // ファイル一覧 struct FileInfo { char name[32]; uint32_t size; }; static FileInfo _fileList[MAX_FILE_LIST]; static int _fileListCount; static int _fileListPage; static int _selectedFileIndex; // 再生状態 static bool _isPlaying; static int _playbackFrame; static int _playbackTotalFrames; static unsigned long _lastPlaybackTime; static void printCamError(CamErr err); static void generateFilename(char* buf, int bufSize); static int countAviFiles(); static int jpegDrawCallback(JPEGDRAW *pDraw); }; #endif // APP_H ``` ```cpp:App.cpp #include "App.h" bool g_isRecording = false; int g_motionPercent = 0; int g_fileCount = 0; Adafruit_ILI9341 App::_tft = Adafruit_ILI9341(&SPI, TFT_DC, TFT_CS, TFT_RST); XPT2046_Touchscreen App::_touch = XPT2046_Touchscreen(T_CS, T_IRQ); SDClass App::_sd; MotionDetector App::_detector; AviRecorder App::_recorder; WebServer App::_webServer; bool App::_cameraReady = false; bool App::_sdReady = false; bool App::_networkReady = false; unsigned long App::_lastMotionTime = 0; unsigned long App::_lastFrameTime = 0; unsigned long App::_frameInterval = 1000 / RECORD_FPS; JPEGDEC App::_jpeg; uint16_t App::_rgbBuffer[CAM_WIDTH * CAM_HEIGHT]; // JPEGバッファ(RAMキャッシュ) uint8_t App::_jpegBuffer[JPEG_BUFFER_FRAMES][JPEG_MAX_SIZE]; uint32_t App::_jpegSizes[JPEG_BUFFER_FRAMES]; volatile int App::_bufferWriteIdx = 0; volatile int App::_bufferReadIdx = 0; volatile int App::_bufferCount = 0; unsigned long App::_lastStatusUpdate = 0; // 画面状態 ScreenState App::_currentScreen = SCREEN_STATUS; bool App::_screenNeedsRedraw = true; // ファイル一覧 App::FileInfo App::_fileList[MAX_FILE_LIST]; int App::_fileListCount = 0; int App::_fileListPage = 0; int App::_selectedFileIndex = -1; // 再生状態 bool App::_isPlaying = false; int App::_playbackFrame = 0; int App::_playbackTotalFrames = 0; unsigned long App::_lastPlaybackTime = 0; App::App() {} void App::setup() { Serial.begin(115200); while (!Serial && millis() < 3000); Serial.println("=== SPRESENSE Camera System ==="); RTC.begin(); RtcTime compiledTime(__DATE__, __TIME__); RTC.setTime(compiledTime); Serial.print("LCD... "); initLCD(); Serial.println("OK"); Serial.print("Touch... "); _touch.begin(SPI); _touch.setRotation(1); // LCD rotation(3)に対して180度補正 Serial.println("OK"); Serial.print("SD... "); initSD(); Serial.println(_sdReady ? "OK" : "FAIL"); Serial.print("Camera... "); initCamera(); Serial.println(_cameraReady ? "OK" : "FAIL"); // ネットワーク初期化(失敗してもスキップして続行) Serial.print("Network... "); initNetwork(); if (_networkReady) { Serial.println("OK"); } else { Serial.println("SKIP (no cable?)"); } if (_cameraReady) { theCamera.startStreaming(true, onCameraFrame); Serial.println("Streaming started"); } // 初期画面表示 _tft.fillScreen(ILI9341_BLACK); updateStatusScreen(); } void App::loop() { unsigned long now = millis(); // シリアルコマンド処理 if (Serial.available()) { char cmd = Serial.read(); handleSerialCommand(cmd); } // タッチ処理 handleTouch(); // 画面状態に応じた処理 switch (_currentScreen) { case SCREEN_STATUS: // 画面遷移時は強制再描画 if (_screenNeedsRedraw) { _screenNeedsRedraw = false; updateStatusScreen(true); // 強制全体再描画 } // 録画中: バッファからSDカードに書き出し if (g_isRecording && _bufferCount > 0) { _recorder.addFrame(_jpegBuffer[_bufferReadIdx], _jpegSizes[_bufferReadIdx]); _bufferReadIdx = (_bufferReadIdx + 1) % JPEG_BUFFER_FRAMES; _bufferCount--; } // ステータス画面更新(1秒間隔) if (now - _lastStatusUpdate >= STATUS_UPDATE_MS) { _lastStatusUpdate = now; updateStatusScreen(); } break; case SCREEN_FILE_LIST: // ファイル一覧画面(タッチ入力は後で追加) if (_screenNeedsRedraw) { drawFileListScreen(); _screenNeedsRedraw = false; } break; case SCREEN_PLAYBACK: // 再生画面 if (_screenNeedsRedraw) { drawPlaybackScreen(); _screenNeedsRedraw = false; } // フレーム再生 if (_isPlaying && _playbackFrame < _playbackTotalFrames) { int fps = _recorder.getPlaybackFps(); unsigned long frameInterval = 1000 / fps; if (now - _lastPlaybackTime >= frameInterval) { _lastPlaybackTime = now; // JPEGフレームを読み込み uint32_t actualSize; if (_recorder.readFrame(_playbackFrame, _jpegBuffer[0], JPEG_MAX_SIZE, &actualSize)) { // JPEGをRGB565にデコード if (_jpeg.openRAM(_jpegBuffer[0], actualSize, jpegDrawCallback)) { _jpeg.setPixelType(RGB565_LITTLE_ENDIAN); // Adafruit_ILI9341は内部でバイトスワップするためリトルエンディアン int decodeResult = _jpeg.decode(0, 0, 0); _jpeg.close(); if (decodeResult != 1) { Serial.print("Decode failed: "); Serial.println(decodeResult); } // LCD表示はコールバック内で直接行うため不要 } else { Serial.print("openRAM failed for frame "); Serial.println(_playbackFrame); } // フレーム番号表示 _tft.fillRect(260, 220, 60, 20, ILI9341_BLACK); _tft.setCursor(260, 224); _tft.setTextColor(ILI9341_WHITE); _tft.setTextSize(1); _tft.print(_playbackFrame + 1); _tft.print("/"); _tft.print(_playbackTotalFrames); _playbackFrame++; } } } else if (_isPlaying && _playbackFrame >= _playbackTotalFrames) { // 再生完了 - ファイル一覧画面に戻る _isPlaying = false; _recorder.closePlayback(); Serial.println("Playback complete"); _currentScreen = SCREEN_FILE_LIST; _screenNeedsRedraw = true; } break; } // ネットワーク処理(全画面共通) if (_networkReady) { _webServer.handleClient(_sd); } } void App::initLCD() { _tft.begin(); _tft.setRotation(3); _tft.fillScreen(ILI9341_BLACK); _tft.setTextColor(ILI9341_WHITE); _tft.setTextSize(2); _tft.setCursor(20, 100); _tft.println("SPRESENSE Cam"); _tft.setTextSize(1); _tft.setCursor(20, 130); _tft.println("Initializing..."); } void App::initSD() { int retryCount = 0; while (!_sd.begin() && retryCount < 10) { delay(1000); retryCount++; } if (retryCount < 10) { _sdReady = true; g_fileCount = countAviFiles(); } } void App::initCamera() { // JPEGストリーミング(JPEG→RGB565はJPEGDECでデコード) CamErr err = theCamera.begin(3, CAM_VIDEO_FPS_15, CAM_WIDTH, CAM_HEIGHT, CAM_IMAGE_PIX_FMT_JPG); if (err != CAM_ERR_SUCCESS) return; theCamera.setAutoWhiteBalanceMode(CAM_WHITE_BALANCE_AUTO); theCamera.setJPEGQuality(80); // JPEG品質設定 _detector.begin(); _detector.setThreshold(MOTION_THRESHOLD); _cameraReady = true; } void App::initNetwork() { // W5500イーサネットモジュールのハードウェアリセット pinMode(ETH_RST, OUTPUT); digitalWrite(ETH_RST, LOW); delay(10); digitalWrite(ETH_RST, HIGH); delay(100); // MACアドレス設定 byte mac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED }; // DHCP接続試行(タイムアウト付き) // beginDHCP内部でタイムアウト処理が行われる // LANケーブル未接続時は自動的にスキップ if (_webServer.beginDHCP(mac, ETH_CS)) { _networkReady = true; Serial.print("IP: "); // IPアドレスはWebServerクラス内で表示される } } void App::showStatus(const char* msg, uint16_t color) { _tft.fillRect(0, CAM_HEIGHT, CAM_WIDTH, 40, ILI9341_BLACK); _tft.setCursor(4, CAM_HEIGHT + 4); _tft.setTextColor(color); _tft.setTextSize(1); _tft.println(msg); } void App::onCameraFrame(CamImage img) { if (!img.isAvailable()) return; // JPEGデータを取得 uint8_t* jpegData = img.getImgBuff(); uint32_t jpegSize = img.getImgSize(); // デバッグ: JPEGサイズを10フレームごとに出力 static int dbgFrameCount = 0; if (dbgFrameCount++ % 10 == 0) { Serial.print("JPEG size: "); Serial.println(jpegSize); } // 動体検知(JPEGサイズ比較方式、デコード不要) bool motionDetected = _detector.detect(jpegSize); g_motionPercent = _detector.getLastChangePercent(); // 動きを検知したら録画開始/継続 if (motionDetected) { _lastMotionTime = millis(); if (!g_isRecording) { char filename[32]; generateFilename(filename, sizeof(filename)); Serial.print("REC START: "); Serial.println(filename); if (_recorder.start(_sd, filename, CAM_WIDTH, CAM_HEIGHT, RECORD_FPS)) { g_isRecording = true; _lastFrameTime = millis(); Serial.println("Recording started"); } else { Serial.println("Failed to start recording"); } } } // 録画中ならJPEGフレームをRAMバッファに追加 if (g_isRecording) { unsigned long now = millis(); if (now - _lastFrameTime >= _frameInterval) { _lastFrameTime = now; // バッファに空きがあれば追加 if (_bufferCount < JPEG_BUFFER_FRAMES && jpegSize <= JPEG_MAX_SIZE) { memcpy(_jpegBuffer[_bufferWriteIdx], jpegData, jpegSize); _jpegSizes[_bufferWriteIdx] = jpegSize; _bufferWriteIdx = (_bufferWriteIdx + 1) % JPEG_BUFFER_FRAMES; _bufferCount++; } } // 録画停止チェック(動体検知から一定時間経過) unsigned long elapsed = now - _lastMotionTime; if (elapsed > RECORD_DELAY_MS) { // バッファに残っているフレームをすべて書き出し while (_bufferCount > 0) { _recorder.addFrame(_jpegBuffer[_bufferReadIdx], _jpegSizes[_bufferReadIdx]); _bufferReadIdx = (_bufferReadIdx + 1) % JPEG_BUFFER_FRAMES; _bufferCount--; } Serial.print("REC STOP: frames="); Serial.println(_recorder.getFrameCount()); _recorder.finish(); _recorder.writeDebugLog(_sd); g_isRecording = false; g_fileCount = countAviFiles(); // バッファインデックスをリセット _bufferWriteIdx = 0; _bufferReadIdx = 0; } } } void App::printCamError(CamErr err) {} void App::generateFilename(char* buf, int bufSize) { RtcTime rtc = RTC.getTime(); snprintf(buf, bufSize, "/%04d%02d%02d_%02d%02d%02d.avi", rtc.year(), rtc.month(), rtc.day(), rtc.hour(), rtc.minute(), rtc.second()); } int App::countAviFiles() { int count = 0; File root = _sd.open("/"); if (root) { while (true) { File entry = root.openNextFile(); if (!entry) break; const char* name = entry.name(); int len = strlen(name); if (len > 4 && strcasecmp(name + len - 4, ".avi") == 0) count++; entry.close(); } root.close(); } return count; } // JPEGDECコールバック:デコードされたピクセルを_rgbBufferにコピー(再生時に使用) int App::jpegDrawCallback(JPEGDRAW *pDraw) { // デコードしたブロックを直接LCDに描画(バッファ経由せず高速化) _tft.drawRGBBitmap(pDraw->x, pDraw->y, pDraw->pPixels, pDraw->iWidth, pDraw->iHeight); return 1; // 1=継続、0=中断 } // ステータス画面更新(値変化時のみ更新でチラつき防止) void App::updateStatusScreen(bool forceRedraw) { static bool firstDraw = true; static bool lastRecording = true; // 初期値を逆にして初回描画を強制 static int lastMotion = -1; static int lastFiles = -1; static int lastMonth = -1; static int lastDay = -1; static int lastHour = -1; static int lastMin = -1; static int lastSec = -1; // 強制再描画時はリセット if (forceRedraw) { _tft.fillScreen(ILI9341_BLACK); firstDraw = true; lastRecording = !g_isRecording; lastMotion = -1; lastFiles = -1; lastMonth = -1; lastDay = -1; lastHour = -1; lastMin = -1; lastSec = -1; } // 初回のみ固定部分を描画 if (firstDraw) { _tft.fillScreen(ILI9341_BLACK); // タイトル _tft.setTextColor(ILI9341_CYAN); _tft.setTextSize(2); _tft.setCursor(40, 10); _tft.println("SPRESENSE CAM"); // 区切り線 _tft.drawFastHLine(0, 35, 320, ILI9341_DARKGREY); // ラベル部分 _tft.setTextColor(ILI9341_WHITE); _tft.setTextSize(2); _tft.setCursor(10, 50); _tft.print("Status:"); _tft.setCursor(10, 80); _tft.print("Motion:"); _tft.setCursor(10, 110); _tft.print("Files:"); _tft.setCursor(10, 140); _tft.print("Net:"); // ネットワーク状態(固定) _tft.setCursor(70, 140); if (_networkReady) { _tft.setTextColor(ILI9341_GREEN); IPAddress ip = _webServer.getIP(); _tft.print(ip[0]); _tft.print("."); _tft.print(ip[1]); _tft.print("."); _tft.print(ip[2]); _tft.print("."); _tft.print(ip[3]); } else { _tft.setTextColor(ILI9341_YELLOW); _tft.print("Disabled"); } // 日時の区切り文字(固定) MM/DD HH:MM:SS (TextSize2=12px/文字) // 月:10, /:34, 日:46, 空白:70, 時:82, ::106, 分:118, ::142, 秒:154 _tft.setTextColor(ILI9341_WHITE); _tft.setCursor(34, 180); _tft.print("/"); // 月/日の間 _tft.setCursor(106, 180); _tft.print(":"); // 時:分の間 _tft.setCursor(142, 180); _tft.print(":"); // 分:秒の間 // 区切り線 _tft.drawFastHLine(0, 210, 320, ILI9341_DARKGREY); // ヒント _tft.setTextSize(1); _tft.setTextColor(ILI9341_DARKGREY); _tft.setCursor(50, 220); _tft.println("Serial 'h' for help, 'f' for files"); firstDraw = false; } _tft.setTextSize(2); // ステータス(変化時のみ) if (g_isRecording != lastRecording) { _tft.fillRect(94, 50, 200, 20, ILI9341_BLACK); _tft.setCursor(94, 50); if (g_isRecording) { _tft.setTextColor(ILI9341_RED); _tft.print("RECORDING"); } else { _tft.setTextColor(ILI9341_GREEN); _tft.print("Monitoring"); } lastRecording = g_isRecording; } // 動体変化率(変化時のみ) if (g_motionPercent != lastMotion) { _tft.fillRect(94, 80, 80, 20, ILI9341_BLACK); _tft.setTextColor(ILI9341_WHITE); _tft.setCursor(94, 80); _tft.print(g_motionPercent); _tft.print("%"); lastMotion = g_motionPercent; } // ファイル数(変化時のみ) if (g_fileCount != lastFiles) { _tft.fillRect(94, 110, 80, 20, ILI9341_BLACK); _tft.setTextColor(ILI9341_WHITE); _tft.setCursor(94, 110); _tft.print(g_fileCount); lastFiles = g_fileCount; } // 日時(各パーツ変化時のみ) RtcTime rtc = RTC.getTime(); _tft.setTextColor(ILI9341_WHITE); // MM/DD HH:MM:SS 配置 (TextSize2=12px/文字) // 月:10, /:34, 日:46, 空白:70, 時:82, ::106, 分:118, ::142, 秒:154 // 月 (x=10) if (rtc.month() != lastMonth) { _tft.fillRect(10, 180, 24, 20, ILI9341_BLACK); _tft.setCursor(10, 180); if (rtc.month() < 10) _tft.print("0"); _tft.print(rtc.month()); lastMonth = rtc.month(); } // 日 (x=46) if (rtc.day() != lastDay) { _tft.fillRect(46, 180, 24, 20, ILI9341_BLACK); _tft.setCursor(46, 180); if (rtc.day() < 10) _tft.print("0"); _tft.print(rtc.day()); lastDay = rtc.day(); } // 時 (x=82) if (rtc.hour() != lastHour) { _tft.fillRect(82, 180, 24, 20, ILI9341_BLACK); _tft.setCursor(82, 180); if (rtc.hour() < 10) _tft.print("0"); _tft.print(rtc.hour()); lastHour = rtc.hour(); } // 分 (x=118) if (rtc.minute() != lastMin) { _tft.fillRect(118, 180, 24, 20, ILI9341_BLACK); _tft.setCursor(118, 180); if (rtc.minute() < 10) _tft.print("0"); _tft.print(rtc.minute()); lastMin = rtc.minute(); } // 秒 (x=154) if (rtc.second() != lastSec) { _tft.fillRect(154, 180, 24, 20, ILI9341_BLACK); _tft.setCursor(154, 180); if (rtc.second() < 10) _tft.print("0"); _tft.print(rtc.second()); lastSec = rtc.second(); } } // ファイル一覧画面描画 void App::drawFileListScreen() { _tft.fillScreen(ILI9341_BLACK); // タイトル _tft.setTextColor(ILI9341_CYAN); _tft.setTextSize(2); _tft.setCursor(70, 10); _tft.println("FILE LIST"); _tft.drawFastHLine(0, 35, 320, ILI9341_DARKGREY); if (_fileListCount == 0) { _tft.setTextColor(ILI9341_YELLOW); _tft.setTextSize(2); _tft.setCursor(60, 100); _tft.println("No files found"); } else { // ファイル一覧表示 int startIdx = _fileListPage * FILES_PER_PAGE; int endIdx = min(startIdx + FILES_PER_PAGE, _fileListCount); for (int i = startIdx; i < endIdx; i++) { int row = i - startIdx; int y = 45 + row * 32; // 選択中のファイルをハイライト if (i == _selectedFileIndex) { _tft.fillRect(0, y - 2, 320, 30, ILI9341_NAVY); } // ファイル番号 _tft.setTextColor(ILI9341_DARKGREY); _tft.setTextSize(1); _tft.setCursor(5, y + 4); _tft.print(i + 1); _tft.print("."); // ファイル名 _tft.setTextColor(ILI9341_WHITE); _tft.setTextSize(2); _tft.setCursor(25, y); _tft.print(_fileList[i].name); // ファイルサイズ _tft.setTextColor(ILI9341_DARKGREY); _tft.setTextSize(1); _tft.setCursor(260, y + 8); _tft.print(_fileList[i].size / 1024); _tft.print("KB"); } // ページ情報 int totalPages = (_fileListCount + FILES_PER_PAGE - 1) / FILES_PER_PAGE; _tft.setTextColor(ILI9341_DARKGREY); _tft.setTextSize(1); _tft.setCursor(130, 210); _tft.print("Page "); _tft.print(_fileListPage + 1); _tft.print("/"); _tft.print(totalPages); } // ボタン表示 _tft.drawFastHLine(0, 205, 320, ILI9341_DARKGREY); // 戻るボタン (x: 5-55) _tft.fillRect(5, 215, 50, 22, ILI9341_DARKGREY); _tft.setTextColor(ILI9341_WHITE); _tft.setTextSize(1); _tft.setCursor(15, 221); _tft.print("BACK"); if (_selectedFileIndex >= 0) { // 削除ボタン (x: 60-110) _tft.fillRect(60, 215, 50, 22, ILI9341_RED); _tft.setTextColor(ILI9341_WHITE); _tft.setCursor(73, 221); _tft.print("DEL"); // 再生ボタン (x: 115-165) _tft.fillRect(115, 215, 50, 22, ILI9341_GREEN); _tft.setTextColor(ILI9341_BLACK); _tft.setCursor(125, 221); _tft.print("PLAY"); } // ページ切替ボタン if (_fileListCount > FILES_PER_PAGE) { _tft.fillRect(240, 215, 35, 22, ILI9341_DARKGREY); _tft.setTextColor(ILI9341_WHITE); _tft.setCursor(252, 221); _tft.print("<"); _tft.fillRect(280, 215, 35, 22, ILI9341_DARKGREY); _tft.setCursor(292, 221); _tft.print(">"); } } // 再生画面描画 void App::drawPlaybackScreen() { _tft.fillScreen(ILI9341_BLACK); // 再生中表示 _tft.setTextColor(ILI9341_GREEN); _tft.setTextSize(1); _tft.setCursor(10, 224); _tft.print("PLAYING: "); if (_selectedFileIndex >= 0 && _selectedFileIndex < _fileListCount) { _tft.print(_fileList[_selectedFileIndex].name); } // 停止ボタン _tft.fillRect(260, 215, 55, 22, ILI9341_RED); _tft.setTextColor(ILI9341_WHITE); _tft.setCursor(272, 221); _tft.print("STOP"); } // 画面遷移 void App::changeScreen(ScreenState newState) { if (_currentScreen == newState) return; // 旧画面の後処理 if (_currentScreen == SCREEN_PLAYBACK) { stopPlayback(); } // 新画面の前処理 if (newState == SCREEN_FILE_LIST) { loadFileList(); _selectedFileIndex = (_fileListCount > 0) ? 0 : -1; _fileListPage = 0; } else if (newState == SCREEN_STATUS) { // カメラストリーミング再開 if (_cameraReady && !g_isRecording) { theCamera.startStreaming(true, onCameraFrame); } } _currentScreen = newState; _screenNeedsRedraw = true; Serial.print("Screen changed to: "); Serial.println(newState); } // ファイル一覧読み込み void App::loadFileList() { _fileListCount = 0; File root = _sd.open("/"); if (!root) { Serial.println("Failed to open SD root"); return; } while (_fileListCount < MAX_FILE_LIST) { File entry = root.openNextFile(); if (!entry) break; const char* fullPath = entry.name(); const char* name = strrchr(fullPath, '/'); name = name ? name + 1 : fullPath; int len = strlen(name); if (len > 4 && strcasecmp(name + len - 4, ".avi") == 0) { strncpy(_fileList[_fileListCount].name, name, sizeof(_fileList[0].name) - 1); _fileList[_fileListCount].name[sizeof(_fileList[0].name) - 1] = '\0'; _fileList[_fileListCount].size = entry.size(); _fileListCount++; } entry.close(); } root.close(); Serial.print("Loaded "); Serial.print(_fileListCount); Serial.println(" files"); } // 選択ファイルを再生 void App::playSelectedFile() { if (_selectedFileIndex < 0 || _selectedFileIndex >= _fileListCount) { Serial.println("No file selected"); return; } // カメラストリーミング停止 if (_cameraReady) { theCamera.startStreaming(false, NULL); } // AVIファイルを開く char path[40]; snprintf(path, sizeof(path), "/%s", _fileList[_selectedFileIndex].name); if (!_recorder.openForPlayback(_sd, path)) { Serial.println("Failed to open for playback"); changeScreen(SCREEN_FILE_LIST); return; } _playbackFrame = 0; _playbackTotalFrames = _recorder.getPlaybackFrameCount(); _lastPlaybackTime = millis(); _isPlaying = true; changeScreen(SCREEN_PLAYBACK); } // 再生停止 void App::stopPlayback() { if (!_isPlaying && !_recorder.isPlaybackOpen()) return; _isPlaying = false; _recorder.closePlayback(); Serial.println("Playback stopped"); } // タッチ処理 void App::handleTouch() { if (!_touch.touched()) return; TS_Point p = _touch.getPoint(); // タッチ座標をLCD座標に変換(XPT2046は0-4095の範囲) // 回転3なので X/Y の変換が必要 int x = map(p.x, 200, 3900, 0, 320); int y = map(p.y, 200, 3900, 0, 240); // 範囲チェック if (x < 0) x = 0; if (x > 319) x = 319; if (y < 0) y = 0; if (y > 239) y = 239; // デバッグ出力 Serial.print("Touch: x="); Serial.print(x); Serial.print(" y="); Serial.println(y); // デバウンス(連続タッチ防止) static unsigned long lastTouchTime = 0; if (millis() - lastTouchTime < 300) return; lastTouchTime = millis(); switch (_currentScreen) { case SCREEN_STATUS: // 画面タップでファイル一覧へ Serial.println("Touch: -> File list"); changeScreen(SCREEN_FILE_LIST); break; case SCREEN_FILE_LIST: // ボタン領域チェック (y: 215-237) if (y >= 215) { // BACKボタン (x: 5-55) if (x >= 5 && x <= 55) { Serial.println("Touch: BACK"); changeScreen(SCREEN_STATUS); } // DELボタン (x: 60-110) else if (x >= 60 && x <= 110 && _selectedFileIndex >= 0) { Serial.println("Touch: DELETE"); char path[40]; snprintf(path, sizeof(path), "/%s", _fileList[_selectedFileIndex].name); if (_sd.remove(path)) { Serial.print("Deleted: "); Serial.println(path); g_fileCount = countAviFiles(); loadFileList(); if (_selectedFileIndex >= _fileListCount) { _selectedFileIndex = _fileListCount - 1; } } _screenNeedsRedraw = true; } // PLAYボタン (x: 115-165) else if (x >= 115 && x <= 165 && _selectedFileIndex >= 0) { Serial.println("Touch: PLAY"); playSelectedFile(); } // <ボタン (x: 240-275) else if (x >= 240 && x <= 275 && _fileListPage > 0) { Serial.println("Touch: Prev page"); _fileListPage--; _screenNeedsRedraw = true; } // >ボタン (x: 280-315) else if (x >= 280 && x <= 315) { int totalPages = (_fileListCount + FILES_PER_PAGE - 1) / FILES_PER_PAGE; if (_fileListPage < totalPages - 1) { Serial.println("Touch: Next page"); _fileListPage++; _screenNeedsRedraw = true; } } } // ファイル一覧領域 (y: 45-205) else if (y >= 45 && y < 205 && _fileListCount > 0) { int row = (y - 45) / 32; int idx = _fileListPage * FILES_PER_PAGE + row; if (idx < _fileListCount) { _selectedFileIndex = idx; _screenNeedsRedraw = true; Serial.print("Touch: Selected "); Serial.println(_fileList[_selectedFileIndex].name); } } break; case SCREEN_PLAYBACK: // STOPボタン (x: 260-315, y: 215-237) if (x >= 260 && x <= 315 && y >= 215) { Serial.println("Touch: STOP"); stopPlayback(); _currentScreen = SCREEN_FILE_LIST; _screenNeedsRedraw = true; } break; } } // シリアルコマンド処理 void App::handleSerialCommand(char cmd) { switch (cmd) { case 'h': case 'H': case '?': // ヘルプ表示 Serial.println(); Serial.println("=== Serial Commands ==="); Serial.println("h/? : Show this help"); Serial.println("s : Go to Status screen"); Serial.println("f : Go to File list screen"); Serial.println("u/d : Select prev/next file"); Serial.println("p : Play selected file"); Serial.println("x : Stop playback"); Serial.println("r : Delete selected file"); Serial.println("</> : Prev/Next page"); Serial.println("i : Show current info"); Serial.println("======================="); break; case 's': case 'S': // ステータス画面へ Serial.println("-> Status screen"); changeScreen(SCREEN_STATUS); break; case 'f': case 'F': // ファイル一覧画面へ Serial.println("-> File list screen"); changeScreen(SCREEN_FILE_LIST); break; case 'u': case 'U': // 前のファイルを選択 if (_currentScreen == SCREEN_FILE_LIST && _fileListCount > 0) { _selectedFileIndex--; if (_selectedFileIndex < 0) _selectedFileIndex = _fileListCount - 1; // ページ調整 _fileListPage = _selectedFileIndex / FILES_PER_PAGE; _screenNeedsRedraw = true; Serial.print("Selected: "); Serial.println(_fileList[_selectedFileIndex].name); } break; case 'd': case 'D': // 次のファイルを選択 if (_currentScreen == SCREEN_FILE_LIST && _fileListCount > 0) { _selectedFileIndex++; if (_selectedFileIndex >= _fileListCount) _selectedFileIndex = 0; // ページ調整 _fileListPage = _selectedFileIndex / FILES_PER_PAGE; _screenNeedsRedraw = true; Serial.print("Selected: "); Serial.println(_fileList[_selectedFileIndex].name); } break; case 'p': case 'P': // 再生開始 if (_currentScreen == SCREEN_FILE_LIST && _selectedFileIndex >= 0) { Serial.println("Starting playback..."); playSelectedFile(); } else { Serial.println("Select a file first (use 'f' then 'u'/'d')"); } break; case 'x': case 'X': // 再生停止 if (_currentScreen == SCREEN_PLAYBACK) { Serial.println("Stopping playback..."); stopPlayback(); _currentScreen = SCREEN_FILE_LIST; _screenNeedsRedraw = true; } break; case 'r': case 'R': // ファイル削除 if (_currentScreen == SCREEN_FILE_LIST && _selectedFileIndex >= 0) { char path[40]; snprintf(path, sizeof(path), "/%s", _fileList[_selectedFileIndex].name); Serial.print("Deleting: "); Serial.println(path); if (_sd.remove(path)) { Serial.println("Deleted"); g_fileCount = countAviFiles(); loadFileList(); if (_selectedFileIndex >= _fileListCount) { _selectedFileIndex = _fileListCount - 1; } _screenNeedsRedraw = true; } else { Serial.println("Delete failed"); } } else { Serial.println("Select a file first (use 'f' then 'u'/'d')"); } break; case '<': case ',': // 前のページ if (_currentScreen == SCREEN_FILE_LIST && _fileListPage > 0) { _fileListPage--; _screenNeedsRedraw = true; Serial.print("Page: "); Serial.println(_fileListPage + 1); } break; case '>': case '.': // 次のページ if (_currentScreen == SCREEN_FILE_LIST) { int totalPages = (_fileListCount + FILES_PER_PAGE - 1) / FILES_PER_PAGE; if (_fileListPage < totalPages - 1) { _fileListPage++; _screenNeedsRedraw = true; Serial.print("Page: "); Serial.println(_fileListPage + 1); } } break; case 'i': case 'I': // 現在の状態表示 Serial.println(); Serial.println("=== Current Info ==="); Serial.print("Screen: "); switch (_currentScreen) { case SCREEN_STATUS: Serial.println("STATUS"); break; case SCREEN_FILE_LIST: Serial.println("FILE_LIST"); break; case SCREEN_PLAYBACK: Serial.println("PLAYBACK"); break; } Serial.print("Recording: "); Serial.println(g_isRecording ? "YES" : "NO"); Serial.print("Motion: "); Serial.print(g_motionPercent); Serial.println("%"); Serial.print("Files: "); Serial.println(g_fileCount); Serial.print("Network: "); Serial.println(_networkReady ? "Connected" : "Disabled"); if (_currentScreen == SCREEN_FILE_LIST) { Serial.print("Selected: "); if (_selectedFileIndex >= 0) { Serial.println(_fileList[_selectedFileIndex].name); } else { Serial.println("(none)"); } } if (_currentScreen == SCREEN_PLAYBACK) { Serial.print("Frame: "); Serial.print(_playbackFrame); Serial.print("/"); Serial.println(_playbackTotalFrames); } Serial.println("===================="); break; default: // 改行などは無視 if (cmd != '\n' && cmd != '\r') { Serial.print("Unknown command: "); Serial.println(cmd); Serial.println("Press 'h' for help"); } break; } } ``` ```cpp:MotionDetector.h #ifndef MOTION_DETECTOR_H #define MOTION_DETECTOR_H #include <stdint.h> // 検知パラメータのデフォルト値 #define DEFAULT_CHANGE_THRESHOLD 10 // サイズ変化率のしきい値(%) class MotionDetector { public: MotionDetector(); // 初期化 void begin(); // JPEGサイズから動き検出 // jpegSize: 現在のJPEGフレームサイズ(バイト) // 戻り値: 動きが検出されたらtrue bool detect(uint32_t jpegSize); // 検出感度設定 // threshold: サイズ変化率のしきい値(%) void setThreshold(int threshold) { _threshold = threshold; } // 最後の検出結果(サイズ変化率%) int getLastChangePercent() { return _lastChangePercent; } // 参照サイズを強制リセット void resetReference(); private: int _threshold; // 検知しきい値(%) int _lastChangePercent; // 最後の変化率(%) bool _hasReference; // 参照サイズが設定済みか float _refSize; // 参照サイズ(指数移動平均) }; #endif // MOTION_DETECTOR_H ``` ```cpp:MotionDetector.cpp #include "MotionDetector.h" #include <Arduino.h> // 指数移動平均の係数(0.9 = 90%旧値 + 10%新値) #define EMA_ALPHA 0.9f MotionDetector::MotionDetector() : _threshold(DEFAULT_CHANGE_THRESHOLD) , _lastChangePercent(0) , _hasReference(false) , _refSize(0.0f) { } void MotionDetector::begin() { _hasReference = false; _refSize = 0.0f; _lastChangePercent = 0; Serial.println("MotionDetector: JPEG size comparison mode"); } bool MotionDetector::detect(uint32_t jpegSize) { // サイズが0の場合は無視 if (jpegSize == 0) { return false; } // 初回フレーム: 参照サイズを設定 if (!_hasReference) { _refSize = (float)jpegSize; _hasReference = true; _lastChangePercent = 0; Serial.print("MotionDetector: initial ref size = "); Serial.println(jpegSize); return false; } // 変化率を計算(%) float diff = (float)jpegSize - _refSize; float changePercent = (diff * 100.0f) / _refSize; // 絶対値を取得 if (changePercent < 0) { changePercent = -changePercent; } _lastChangePercent = (int)changePercent; // 参照サイズを指数移動平均で更新(90%旧 + 10%新) _refSize = EMA_ALPHA * _refSize + (1.0f - EMA_ALPHA) * (float)jpegSize; // しきい値を超えたら動体検知 return (_lastChangePercent >= _threshold); } void MotionDetector::resetReference() { _hasReference = false; _refSize = 0.0f; _lastChangePercent = 0; } ``` ```cpp:AviRecorder.h #ifndef AVI_RECORDER_H #define AVI_RECORDER_H #include <SDHCI.h> #include <Camera.h> #define MAX_FRAMES 1800 #define FOURCC(a,b,c,d) ((uint32_t)(a) | ((uint32_t)(b)<<8) | ((uint32_t)(c)<<16) | ((uint32_t)(d)<<24)) class AviRecorder { public: AviRecorder(); // 録画機能 bool start(SDClass& sd, const char* filename, int width, int height, int fps); bool addFrame(uint8_t* yuvData, uint32_t yuvSize); bool finish(); bool isRecording() { return _recording; } uint32_t getFrameCount() { return _frameCount; } void writeDebugLog(SDClass& sd); // 再生機能 bool openForPlayback(SDClass& sd, const char* filename); bool readFrame(int frameIndex, uint8_t* buffer, uint32_t bufferSize, uint32_t* actualSize); void closePlayback(); bool isPlaybackOpen() { return _playbackOpen; } int getPlaybackFrameCount() { return _playbackFrameCount; } int getPlaybackWidth() { return _playbackWidth; } int getPlaybackHeight() { return _playbackHeight; } int getPlaybackFps() { return _playbackFps; } private: // 録画用 File _file; bool _recording; int _width; int _height; int _fps; uint32_t _frameCount; uint32_t _totalVideoSize; uint32_t _frameSize; uint32_t _frameOffsets[MAX_FRAMES]; uint32_t _frameSizes[MAX_FRAMES]; uint32_t _moviStart; uint32_t _riffSizePos; uint32_t _moviSizePos; uint32_t _avihFramePos; uint32_t _strhLengthPos; // デバッグ用 unsigned long _debugStartTime; unsigned long _debugEndTime; void writeAviHeader(); void writeIndex(); void updateSizes(); void write16(uint16_t val); void write32(uint32_t val); void writeFourCC(const char* cc); // 再生用 File _playbackFile; bool _playbackOpen; int _playbackFrameCount; int _playbackWidth; int _playbackHeight; int _playbackFps; uint32_t _playbackDataStart; // moviチャンク内のデータ開始位置 uint32_t _pbFrameOffsets[MAX_FRAMES]; uint32_t _pbFrameSizes[MAX_FRAMES]; bool parseAviHeader(); bool parseIndex(); uint32_t read32(); }; #endif ``` ```cpp:AviRecorder.cpp #include "AviRecorder.h" AviRecorder::AviRecorder() : _recording(false) , _width(0) , _height(0) , _fps(10) , _frameCount(0) , _totalVideoSize(0) , _frameSize(0) , _playbackOpen(false) , _playbackFrameCount(0) , _playbackWidth(0) , _playbackHeight(0) , _playbackFps(10) , _playbackDataStart(0) { } bool AviRecorder::start(SDClass& sd, const char* filename, int width, int height, int fps) { if (_recording) return false; sd.remove(filename); _file = sd.open(filename, FILE_WRITE); if (!_file) return false; _width = width; _height = height; _fps = fps; _frameCount = 0; _totalVideoSize = 0; _frameSize = width * height; // MJPEG: 推定サイズ(実際は可変) _debugStartTime = millis(); _debugEndTime = 0; writeAviHeader(); _recording = true; return true; } bool AviRecorder::addFrame(uint8_t* yuvData, uint32_t yuvSize) { if (!_recording || _frameCount >= MAX_FRAMES) return false; unsigned long writeStart = millis(); // オフセット = movi識別子の先頭からの相対位置 _frameOffsets[_frameCount] = _file.position() - _moviStart + 4; writeFourCC("00dc"); write32(yuvSize); _file.write(yuvData, yuvSize); // パディング(2バイト境界) if (yuvSize & 1) { uint8_t pad = 0; _file.write(&pad, 1); } uint32_t paddedSize = (yuvSize + 1) & ~1; _frameSizes[_frameCount] = yuvSize; _totalVideoSize += 8 + paddedSize; _frameCount++; unsigned long writeTime = millis() - writeStart; if (_frameCount <= 5 || _frameCount % 10 == 0) { Serial.print("Frame "); Serial.print(_frameCount); Serial.print(": "); Serial.print(writeTime); Serial.println("ms"); } return true; } bool AviRecorder::finish() { if (!_recording) return false; _debugEndTime = millis(); writeIndex(); _file.flush(); // idx1書き込み後にflush updateSizes(); _file.flush(); // サイズ更新後にもflush _file.close(); _recording = false; return true; } void AviRecorder::writeDebugLog(SDClass& sd) { sd.remove("/debug.txt"); File log = sd.open("/debug.txt", FILE_WRITE); if (log) { log.println("=== YUV422 AVI Recording ==="); log.print("Frames: "); log.println(_frameCount); log.print("FrameSize: "); log.println(_frameSize); log.print("Duration(ms): "); log.println(_debugEndTime - _debugStartTime); log.print("TotalSize: "); log.println(_totalVideoSize); if (_frameCount > 0 && (_debugEndTime - _debugStartTime) > 0) { float actualFps = (float)_frameCount * 1000.0f / (float)(_debugEndTime - _debugStartTime); log.print("ActualFPS: "); log.println(actualFps); } log.close(); } } void AviRecorder::writeAviHeader() { writeFourCC("RIFF"); _riffSizePos = _file.position(); write32(0); writeFourCC("AVI "); writeFourCC("LIST"); uint32_t hdrlSizePos = _file.position(); write32(0); writeFourCC("hdrl"); // avih - メインAVIヘッダ writeFourCC("avih"); write32(56); write32(1000000 / _fps); // マイクロ秒/フレーム write32(_frameSize * _fps); // 最大バイトレート write32(0); write32(0x10); // AVIF_HASINDEX _avihFramePos = _file.position(); write32(0); // 総フレーム数(後で更新) write32(0); write32(1); // ストリーム数 write32(_frameSize); // 推奨バッファサイズ write32(_width); write32(_height); write32(0); write32(0); write32(0); write32(0); // strl - ストリームリスト writeFourCC("LIST"); uint32_t strlSizePos = _file.position(); write32(0); writeFourCC("strl"); // strh - ストリームヘッダ writeFourCC("strh"); write32(56); writeFourCC("vids"); writeFourCC("MJPG"); // Motion JPEG write32(0); write16(0); write16(0); write32(0); write32(1); write32(_fps); write32(0); _strhLengthPos = _file.position(); write32(0); // フレーム数(後で更新) write32(0); // 推奨バッファサイズ(可変) write32(0); write32(0); write16(0); write16(0); write16(_width); write16(_height); // strf - ストリームフォーマット(BITMAPINFOHEADER) writeFourCC("strf"); write32(40); write32(40); // biSize write32(_width); write32(_height); write16(1); // biPlanes write16(24); // biBitCount (MJPEG = 24bpp) writeFourCC("MJPG"); // biCompression write32(0); // biSizeImage(可変) write32(0); write32(0); write32(0); write32(0); uint32_t strlEnd = _file.position(); _file.seek(strlSizePos); write32(strlEnd - strlSizePos - 4); _file.seek(strlEnd); uint32_t hdrlEnd = _file.position(); _file.seek(hdrlSizePos); write32(hdrlEnd - hdrlSizePos - 4); _file.seek(hdrlEnd); // movi - ビデオデータリスト writeFourCC("LIST"); _moviSizePos = _file.position(); write32(0); writeFourCC("movi"); _moviStart = _file.position(); } void AviRecorder::writeIndex() { Serial.print("writeIndex: _frameCount="); Serial.print(_frameCount); Serial.print(", idx1 size="); Serial.println(_frameCount * 16); writeFourCC("idx1"); write32(_frameCount * 16); for (uint32_t i = 0; i < _frameCount; i++) { writeFourCC("00dc"); write32(0x10); // AVIIF_KEYFRAME write32(_frameOffsets[i]); write32(_frameSizes[i]); } } void AviRecorder::updateSizes() { uint32_t fileEnd = _file.position(); _file.seek(_moviSizePos); write32(_totalVideoSize + 4); _file.seek(_riffSizePos); write32(fileEnd - 8); _file.seek(_avihFramePos); write32(_frameCount); _file.seek(_strhLengthPos); write32(_frameCount); _file.seek(fileEnd); } void AviRecorder::write16(uint16_t val) { uint8_t buf[2] = { (uint8_t)(val & 0xFF), (uint8_t)((val >> 8) & 0xFF) }; _file.write(buf, 2); } void AviRecorder::write32(uint32_t val) { uint8_t buf[4] = { (uint8_t)(val & 0xFF), (uint8_t)((val >> 8) & 0xFF), (uint8_t)((val >> 16) & 0xFF), (uint8_t)((val >> 24) & 0xFF) }; _file.write(buf, 4); } void AviRecorder::writeFourCC(const char* cc) { _file.write((const uint8_t*)cc, 4); } // ==================== 再生機能 ==================== bool AviRecorder::openForPlayback(SDClass& sd, const char* filename) { if (_playbackOpen) { closePlayback(); } _playbackFile = sd.open(filename, FILE_READ); if (!_playbackFile) { Serial.print("Failed to open: "); Serial.println(filename); return false; } if (!parseAviHeader()) { Serial.println("Failed to parse AVI header"); _playbackFile.close(); return false; } if (!parseIndex()) { Serial.println("Failed to parse index"); _playbackFile.close(); return false; } _playbackOpen = true; Serial.print("Playback open: "); Serial.print(_playbackFrameCount); Serial.print(" frames, "); Serial.print(_playbackWidth); Serial.print("x"); Serial.print(_playbackHeight); Serial.print(" @ "); Serial.print(_playbackFps); Serial.println(" fps"); // デバッグ: 保存された値を確認 Serial.print("Verify _pbFrameSizes[0]="); Serial.println(_pbFrameSizes[0]); return true; } bool AviRecorder::readFrame(int frameIndex, uint8_t* buffer, uint32_t bufferSize, uint32_t* actualSize) { if (!_playbackOpen || frameIndex < 0 || frameIndex >= _playbackFrameCount) { return false; } uint32_t frameSize = _pbFrameSizes[frameIndex]; if (frameSize > bufferSize) { Serial.print("Buffer too small: need "); Serial.print(frameSize); Serial.print(", have "); Serial.println(bufferSize); return false; } // フレームデータの位置へシーク // idx1のオフセットはmoviチャンクの先頭からの相対位置 uint32_t framePos = _playbackDataStart + _pbFrameOffsets[frameIndex]; // フレームヘッダをスキップ("00dc" + size = 8バイト) _playbackFile.seek(framePos + 8); // JPEGデータを読み込み int bytesRead = _playbackFile.read(buffer, frameSize); if (bytesRead != (int)frameSize) { Serial.print("Read error: expected "); Serial.print(frameSize); Serial.print(", got "); Serial.println(bytesRead); return false; } if (actualSize) { *actualSize = frameSize; } return true; } void AviRecorder::closePlayback() { if (_playbackOpen) { _playbackFile.close(); _playbackOpen = false; _playbackFrameCount = 0; } } bool AviRecorder::parseAviHeader() { // RIFFヘッダを確認 char fourcc[5] = {0}; _playbackFile.read((uint8_t*)fourcc, 4); if (strncmp(fourcc, "RIFF", 4) != 0) { Serial.println("Not RIFF"); return false; } read32(); // ファイルサイズ _playbackFile.read((uint8_t*)fourcc, 4); if (strncmp(fourcc, "AVI ", 4) != 0) { Serial.println("Not AVI"); return false; } // チャンクを解析 while (_playbackFile.available()) { uint32_t pos = _playbackFile.position(); _playbackFile.read((uint8_t*)fourcc, 4); uint32_t chunkSize = read32(); if (strncmp(fourcc, "LIST", 4) == 0) { char listType[5] = {0}; _playbackFile.read((uint8_t*)listType, 4); if (strncmp(listType, "hdrl", 4) == 0) { // ヘッダリストを解析 uint32_t listEnd = pos + 8 + chunkSize; while (_playbackFile.position() < listEnd) { char subFourcc[5] = {0}; _playbackFile.read((uint8_t*)subFourcc, 4); uint32_t subSize = read32(); if (strncmp(subFourcc, "avih", 4) == 0) { uint32_t usPerFrame = read32(); _playbackFps = 1000000 / usPerFrame; _playbackFile.seek(_playbackFile.position() + 12); // maxBytesPerSec, reserved, flags _playbackFrameCount = read32(); // totalFrames(暫定、idx1で上書き) _playbackFile.seek(_playbackFile.position() + 8); // initialFrames, streams _playbackFile.seek(_playbackFile.position() + 4); // suggestedBufferSize _playbackWidth = read32(); _playbackHeight = read32(); _playbackFile.seek(_playbackFile.position() + subSize - 32); } else if (strncmp(subFourcc, "LIST", 4) == 0) { // strlリストはスキップ(subSizeはLIST内容全体のサイズ) _playbackFile.seek(_playbackFile.position() + subSize - 4); } else { _playbackFile.seek(_playbackFile.position() + subSize); } } // hdrl解析後、確実に次のチャンク位置へ移動 _playbackFile.seek(pos + 8 + chunkSize); } else if (strncmp(listType, "movi", 4) == 0) { // moviリストの開始位置を記録 _playbackDataStart = _playbackFile.position() - 4; // "movi"の位置 _playbackFile.seek(pos + 8 + chunkSize); } else { _playbackFile.seek(pos + 8 + chunkSize); } } else if (strncmp(fourcc, "idx1", 4) == 0) { // インデックスが見つかった、parseIndexで処理 _playbackFile.seek(pos); return true; } else { // 他のチャンクはスキップ _playbackFile.seek(pos + 8 + chunkSize); } if (_playbackFile.position() >= _playbackFile.size() - 8) { break; } } return (_playbackWidth > 0 && _playbackHeight > 0); } bool AviRecorder::parseIndex() { // ファイル末尾から逆方向にidx1を探す uint32_t fileSize = _playbackFile.size(); char fourcc[5] = {0}; Serial.print("File size: "); Serial.println(fileSize); // ファイル末尾から最大64KBの範囲でidx1を探す uint32_t searchStart = (fileSize > 65536) ? (fileSize - 65536) : 12; _playbackFile.seek(searchStart); while (_playbackFile.position() < fileSize - 8) { uint32_t pos = _playbackFile.position(); _playbackFile.read((uint8_t*)fourcc, 4); if (strncmp(fourcc, "idx1", 4) == 0) { Serial.print("Found idx1 at position: "); Serial.println(pos); uint32_t chunkSize = read32(); Serial.print("idx1 chunk size: "); Serial.println(chunkSize); int numEntries = chunkSize / 16; _playbackFrameCount = 0; for (int i = 0; i < numEntries && _playbackFrameCount < MAX_FRAMES; i++) { char chunkId[5] = {0}; _playbackFile.read((uint8_t*)chunkId, 4); uint32_t flags = read32(); uint32_t offset = read32(); uint32_t size = read32(); (void)flags; // デバッグ: 最初の5エントリのckidを表示 if (i < 5) { Serial.print("Entry "); Serial.print(i); Serial.print(": ckid='"); Serial.print(chunkId); Serial.println("'"); } if (strncmp(chunkId, "00dc", 4) == 0) { _pbFrameOffsets[_playbackFrameCount] = offset; _pbFrameSizes[_playbackFrameCount] = size; // デバッグ: 最初の3フレームの情報を表示 if (_playbackFrameCount < 3) { Serial.print("Frame "); Serial.print(_playbackFrameCount); Serial.print(": offset="); Serial.print(offset); Serial.print(", size="); Serial.println(size); } _playbackFrameCount++; } } Serial.print("Index parsed: "); Serial.print(_playbackFrameCount); Serial.println(" frames"); return (_playbackFrameCount > 0); } // 1バイトずつ進めて探す _playbackFile.seek(pos + 1); } Serial.println("idx1 not found in file"); return false; } uint32_t AviRecorder::read32() { uint8_t buf[4]; _playbackFile.read(buf, 4); return (uint32_t)buf[0] | ((uint32_t)buf[1] << 8) | ((uint32_t)buf[2] << 16) | ((uint32_t)buf[3] << 24); } ``` ```cpp:WebServer.h #ifndef WEB_SERVER_H #define WEB_SERVER_H #include <Ethernet.h> #include <SDHCI.h> #define WEB_PORT 80 #define MAX_REQUEST_SIZE 256 enum RequestType { REQ_NONE, REQ_INDEX, REQ_LIST, REQ_VIDEO, REQ_DELETE, REQ_STATUS, REQ_DEBUG, REQ_NOTFOUND }; class WebServer { public: WebServer(); bool begin(uint8_t* mac, IPAddress ip, IPAddress gateway, IPAddress subnet, int csPin); bool beginDHCP(uint8_t* mac, int csPin); void handleClient(SDClass& sd); IPAddress getIP() { return Ethernet.localIP(); } private: EthernetServer* _server; char _requestBuffer[MAX_REQUEST_SIZE]; int _requestLen; RequestType parseRequest(const char* request, char* param, int paramSize); void sendIndex(EthernetClient& client); void sendFileList(EthernetClient& client, SDClass& sd); void sendVideo(EthernetClient& client, SDClass& sd, const char* filename); void sendDelete(EthernetClient& client, SDClass& sd, const char* filename); void sendStatus(EthernetClient& client); void sendDebug(EthernetClient& client, SDClass& sd); void sendNotFound(EthernetClient& client); void sendHeader(EthernetClient& client, const char* contentType, int contentLength = -1); }; #endif ``` ```cpp:WebServer.cpp #include "WebServer.h" extern int g_fileCount; const char INDEX_HTML[] PROGMEM = R"rawliteral( <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>SPRESENSE Cam</title> <style> *{box-sizing:border-box;margin:0;padding:0} body{font-family:sans-serif;background:#1a1a2e;color:#eee;padding:10px} h1{color:#0f9;margin-bottom:15px;font-size:1.5em} .file-list{list-style:none} .file-item{background:#16213e;margin:5px 0;padding:10px;border-radius:5px;display:flex;justify-content:space-between;align-items:center} .file-name{flex:1} .file-size{color:#888;margin:0 10px} .btn{padding:5px 15px;border:none;border-radius:3px;cursor:pointer;margin-left:5px} .btn-dl{background:#0f9;color:#000} .btn-del{background:#e94;color:#fff} .status{background:#16213e;padding:10px;border-radius:5px;margin-top:10px} pre{background:#16213e;padding:10px;border-radius:5px;margin-top:10px;white-space:pre-wrap} </style> </head> <body> <h1>SPRESENSE Cam</h1> <div class="status" id="status">Loading...</div> <h2 style="margin:15px 0 10px;font-size:1.2em">Recordings</h2> <ul class="file-list" id="file-list"></ul> <h2 style="margin:15px 0 10px;font-size:1.2em">Debug</h2> <pre id="debug">Loading...</pre> <script> function loadFiles(){ fetch('/list').then(r=>r.json()).then(files=>{ const ul=document.getElementById('file-list'); ul.innerHTML=''; files.forEach(f=>{ const li=document.createElement('li'); li.className='file-item'; li.innerHTML='<span class="file-name">'+f.name+'</span>'+ '<span class="file-size">'+(f.size/1024).toFixed(1)+'KB</span>'+ '<button class="btn btn-dl" onclick="location.href=\'/video/'+f.name+'\'">DL</button>'+ '<button class="btn btn-del" onclick="delFile(\''+f.name+'\')">Del</button>'; ul.appendChild(li); }); }); } function delFile(name){ if(confirm('Delete '+name+'?')){ fetch('/delete/'+name).then(()=>{loadFiles();updateStatus();}); } } function updateStatus(){ fetch('/status').then(r=>r.json()).then(s=>{ document.getElementById('status').innerHTML= 'Recording: '+(s.recording?'<b style="color:#f55">YES</b>':'No')+ ' | Motion: '+s.motion+'%'+ ' | Files: '+s.files; }); } function loadDebug(){ fetch('/debug').then(r=>r.text()).then(t=>{ document.getElementById('debug').textContent=t||'No debug info'; }); } setInterval(updateStatus,2000); updateStatus(); loadFiles(); loadDebug(); setInterval(loadDebug,5000); </script> </body> </html> )rawliteral"; WebServer::WebServer() : _server(NULL), _requestLen(0) {} bool WebServer::begin(uint8_t* mac, IPAddress ip, IPAddress gateway, IPAddress subnet, int csPin) { Ethernet.init(csPin); Ethernet.begin(mac, ip, gateway, gateway, subnet); _server = new EthernetServer(WEB_PORT); _server->begin(); return true; } bool WebServer::beginDHCP(uint8_t* mac, int csPin) { Ethernet.init(csPin); if (Ethernet.begin(mac) == 0) return false; _server = new EthernetServer(WEB_PORT); _server->begin(); return true; } void WebServer::handleClient(SDClass& sd) { EthernetClient client = _server->available(); if (!client) return; _requestLen = 0; unsigned long timeout = millis() + 1000; while (client.connected() && millis() < timeout) { if (client.available()) { char c = client.read(); if (_requestLen < MAX_REQUEST_SIZE - 1) _requestBuffer[_requestLen++] = c; if (c == '\n' && _requestLen > 2 && _requestBuffer[_requestLen - 2] == '\r') break; } } _requestBuffer[_requestLen] = '\0'; while (client.available()) client.read(); char param[64] = {0}; RequestType reqType = parseRequest(_requestBuffer, param, sizeof(param)); switch (reqType) { case REQ_INDEX: sendIndex(client); break; case REQ_LIST: sendFileList(client, sd); break; case REQ_VIDEO: sendVideo(client, sd, param); break; case REQ_DELETE: sendDelete(client, sd, param); break; case REQ_STATUS: sendStatus(client); break; case REQ_DEBUG: sendDebug(client, sd); break; default: sendNotFound(client); break; } delay(1); client.stop(); } RequestType WebServer::parseRequest(const char* request, char* param, int paramSize) { if (strncmp(request, "GET ", 4) != 0) return REQ_NOTFOUND; const char* path = request + 4; const char* pathEnd = strchr(path, ' '); if (!pathEnd) return REQ_NOTFOUND; int pathLen = pathEnd - path; if (pathLen == 1 && path[0] == '/') return REQ_INDEX; if (strncmp(path, "/list", 5) == 0) return REQ_LIST; if (strncmp(path, "/status", 7) == 0) return REQ_STATUS; if (strncmp(path, "/debug", 6) == 0) return REQ_DEBUG; if (strncmp(path, "/video/", 7) == 0) { int len = pathLen - 7; if (len > 0 && len < paramSize) { strncpy(param, path + 7, len); param[len] = '\0'; } return REQ_VIDEO; } if (strncmp(path, "/delete/", 8) == 0) { int len = pathLen - 8; if (len > 0 && len < paramSize) { strncpy(param, path + 8, len); param[len] = '\0'; } return REQ_DELETE; } return REQ_NOTFOUND; } void WebServer::sendIndex(EthernetClient& client) { sendHeader(client, "text/html", strlen(INDEX_HTML)); client.print(INDEX_HTML); } void WebServer::sendFileList(EthernetClient& client, SDClass& sd) { String json = "["; bool first = true; File root = sd.open("/"); if (root) { while (true) { File entry = root.openNextFile(); if (!entry) break; const char* fullPath = entry.name(); const char* name = strrchr(fullPath, '/'); name = name ? name + 1 : fullPath; int len = strlen(name); if (len > 4 && strcasecmp(name + len - 4, ".avi") == 0) { if (!first) json += ","; first = false; json += "{\"name\":\""; json += name; json += "\",\"size\":"; json += String(entry.size()); json += "}"; } entry.close(); } root.close(); } json += "]"; sendHeader(client, "application/json", json.length()); client.print(json); } void WebServer::sendVideo(EthernetClient& client, SDClass& sd, const char* filename) { String path = "/"; path += filename; File file = sd.open(path.c_str(), FILE_READ); if (!file) { sendNotFound(client); return; } uint32_t fileSize = file.size(); client.println("HTTP/1.1 200 OK"); client.println("Content-Type: video/avi"); client.print("Content-Length: "); client.println(fileSize); client.print("Content-Disposition: attachment; filename=\""); client.print(filename); client.println("\""); client.println("Connection: close"); client.println(); uint8_t buf[512]; while (file.available()) { int n = file.read(buf, sizeof(buf)); if (n > 0) client.write(buf, n); } file.close(); } void WebServer::sendDelete(EthernetClient& client, SDClass& sd, const char* filename) { String path = "/"; path += filename; bool success = sd.remove(path.c_str()); if (success) { int count = 0; File root = sd.open("/"); if (root) { while (true) { File entry = root.openNextFile(); if (!entry) break; const char* name = entry.name(); int len = strlen(name); if (len > 4 && strcasecmp(name + len - 4, ".avi") == 0) count++; entry.close(); } root.close(); } g_fileCount = count; } String json = "{\"success\":"; json += success ? "true" : "false"; json += "}"; sendHeader(client, "application/json", json.length()); client.print(json); } void WebServer::sendStatus(EthernetClient& client) { extern bool g_isRecording; extern int g_motionPercent; String json = "{\"recording\":"; json += g_isRecording ? "true" : "false"; json += ",\"motion\":"; json += String(g_motionPercent); json += ",\"files\":"; json += String(g_fileCount); json += "}"; sendHeader(client, "application/json", json.length()); client.print(json); } void WebServer::sendDebug(EthernetClient& client, SDClass& sd) { File file = sd.open("/debug.txt", FILE_READ); if (!file) { const char* msg = "No debug file"; sendHeader(client, "text/plain", strlen(msg)); client.print(msg); return; } uint32_t fileSize = file.size(); sendHeader(client, "text/plain", fileSize); while (file.available()) { client.write(file.read()); } file.close(); } void WebServer::sendNotFound(EthernetClient& client) { const char* msg = "404 Not Found"; client.println("HTTP/1.1 404 Not Found"); client.println("Content-Type: text/plain"); client.print("Content-Length: "); client.println(strlen(msg)); client.println("Connection: close"); client.println(); client.print(msg); } void WebServer::sendHeader(EthernetClient& client, const char* contentType, int contentLength) { client.println("HTTP/1.1 200 OK"); client.print("Content-Type: "); client.println(contentType); if (contentLength >= 0) { client.print("Content-Length: "); client.println(contentLength); } client.println("Connection: close"); client.println(); } ``` # あとがき 今回はじめてSPRESENSEを使った開発をさせてもらってとても勉強になりました。モニター品を貸し出していただき感謝します。 ただ、使えていないモニター品があること、SPRESENSEの性能を発揮させられていないことは申し訳なく思います。今後の課題とさせてください。 ## 大変だったこと 楽しかったことはたくさんありすぎる(モニター品が届いただけで興奮する)ので、大変だったことだけ書きます。 ### リソースに制限がある 数フレームをバッファしたくてもメモリが足りないと言われたり、フレームの差分で動体検知させたかったけど処理が追いつかなかったり、限られたリソースの中で実現させるのに苦労しました。コードの書き方の問題な気もしますが。。 ### 配線の確認が大変 タッチパネルのピンが14本あって、1本1本がSPRESENSEの正しいピンに挿さっていることを確認するのにとても苦労しました。細かいものを見ようとすると視界がぐらぐらするんです。 ### AIの嘘 AIはとてもたくさん嘘を付きます。そしてたくさん言い訳します。しかし人間はそれを許すしかありません。だって自分じゃできないんだもん。 ## あとはちょっとメモをたらたら - カメラから取得できるピクセルフォーマットが数種類ある | フォーマット | 特徴 | 用途 | |-------------|------|------| | RGB565 | 非圧縮、即座に描画可能 | LCD表示、画像処理 | | YUV422 | 非圧縮、色差分離 | 画像認出 | | JPEG | 圧縮済み、小サイズ | 録画保存、ネットワーク送信 | | GRAY | 非圧縮、1バイト/px | QRコード読み取り | 2種類同時には取得できないので、LCD表示と録画を同時にしたい場合は、RGB565→JPEG or JPEG→RGB565 or RGB565のまま保存 となる。 ただし変換には時間がかかる。もしくは保存ファイルサイズが大きくなり書き込みに時間がかかる。 この問題を解決できなかったので、今回はJPEGを採用し、LCDへのリアルタイムプレビューは無しで、監視中か録画中かの表示だけにした。 JPEGを採用したことで差分による動体検知ができなくなったので、サイズの変化を検知する方法に切り替えた。 - SPRESENSEの開発方法が2種類ある | 方式 | 特徴 | |------|------| | Arduino IDE | Arduino互換API、簡単、ライブラリ豊富 | | Spresense SDK (NuttX) | ネイティブOS、マルチコア活用、低レベル制御、高性能 | 簡単だということで今回はArduino版を採用。 Arduino版も転送時にNuttXとしてビルドされてるらしい。 Arduino CLIを使うことで、ビルドも転送もAIにやってもらえた。 Spresense SDKも試したい。 - W5500ではTLS使えないのでHTTPS通信できない つまり、外部のサイトやAPIを利用できない。 当初は録画ファイルをAWS S3にでも上げようかと思っていたが、HTTPS通信できないので断念。SPRESENSEにWebサーバーを立てることでPCから取得できるようにした。 ESP32をWifi子機にできるようなので試したい。 - AIも動かせる Neural Network Consoleというツールを使って簡単に独自のAIモデルを作れるらしい。 自分のAIモデルが動くところまで試したい。 ということで、お疲れ様でした。