SSブログ

一月の電子工作「SPRESENSEでタイムラプスカメラを作ろう」ーついに完成!ー [SPRESENSE]

SPRESENSEのタイムラプスカメラ完成しました!当初のスケッチとは大きく変わってしまいましたが、構想よりも可愛らしくできあがりました。

大きさは本体が58mm x 29mm x 26mm、カメラ部が31mm x 31mm 。重量は驚異の37g!300mAhのバッテリー内蔵で、タイムラプス画像はAVIで記録できます!


sADSC02027.jpg


横置きももちろん可能です。


sDSC02029.jpg


ソーラーパネルで充電しながらを想定し、お尻もあげられるようにしました。ペンギンみたいでかわいい。


sEDSC02032.jpg


さっそく、今宵の月を1分単位で1時間半ほど撮影してみました!





では制作過程のご紹介です。


■ 筐体デザインの構想
当初、スティック型を目指しましたが、プログラム用USBの位置がカメラの方向とかぶるので背後に置くのは無理。バッテリはソーラー充電も考えると尻尾につけたい。


original_idea.png


いろいろ悩んだ末に、カメラと本体を分離することにしました。録画開始ボタンも電源ON/OFFで代用し、無くすことにしました。

そのときのスケッチの様子です。(本体だけですが、、、カメラ部は次ページにスケッチしていますが、見せるほどのものではないので省略しました)


sDSC02013.jpg




■ Fusion360 で筐体デザイン
Fusion360を使って筐体デザインをしていきます。電源スイッチはボンドで固めてしまったので、位置の調整に苦労しました。


SprTimelapseBodyDesign.jpg


カメラ部は基板むき出しも考えたのですが、ちょうど黒いM2ネジがあったので、レンズの黒とネジの黒でデザイン的に映えるかなと思い、表面を覆うことにしました。


SprTimelapseCamDesign.jpg


余談ですが、Fusion360のマウス操作とCuraのマウス操作を一緒にできないものですかね。とっても煩わしい。


■ 我が家のオンボロ3Dプリンタで出力
我が家のオンボロ3Dプリンタをフル稼働。このプリンタ、筐体本体がABS樹脂で3Dプリンタで出力したもの。もう5年目で筐体が経年劣化で伸縮し、ホットベッドの支柱が微妙にねじれてしまいノズルとホットベッドの間隔が均一でなくなっています。なのでプリント品質がイマイチ…


sDSC02011.jpg


出力したパーツをニッパで切り出しました。遠目で見るときれいに見えても近くでみると”あ~”って感じ。3Dプリンタも激安になってきたから、そろそろ買い直そうかな。


sDSC02016.jpg



■ 本体の組み上げ
結構、攻めた設計をしたので、本体はギチギチ。プリンタの出力誤差もあって、SPRESENSEメインボードの場所は微妙に狭く、無理やりねじ込みました。これで壊れないのはさすがソニー製。(ラズパイカメラは無理やりねじ込んで、あっけなく壊れた悲しい思い出が…)

一方、バッテリは膨れるので部屋の大きさは余裕目に用意しました。


sDSC02017.jpg


カメラはカバーと腕を接着剤で固めて、ネジ止めするだけ。楽ちん。


sDSC02025.jpg


最後に足をつけて完成です!充電用のUSBコネクタが曲がっているのはご愛嬌。


sDSC02026.jpg



■ タイムラプスのソフトウェアを作る
JPG画像をSDカードに記録していくだけにしようかなとも思ったのですが、せっかくなら映像で記録し、すぐにPCで見れるようにしたい。ということでAVIで記録するようにしました。昔作ったコードを流用してます。



#include <SPI.h>
#include <SPISD.h>
#include <Camera.h>
#include <LowPower.h>
#include <RTC.h>


SpiSDClass SD(SPI5);
SpiFile infFile;
SpiFile aviFile;

static const String aviFilename = "movie.avi";
static const String infFilename = "info.txt";
static const int img_width = 1280;
static const int img_height = 960;

static const uint8_t WIDTH_1 = (img_width & 0x00ff);
static const uint8_t WIDTH_2 = (img_width & 0xff00) >> 8;
static const uint8_t HEIGHT_1 = (img_height & 0x00ff);
static const uint8_t HEIGHT_2 = (img_height & 0xff00) >> 8;

static uint16_t rec_frame_addr = 0x00;
static uint16_t movi_size_addr = 0x08;
static uint16_t total_size_addr = 0x10;
static uint32_t rec_frame = 0;
static uint32_t movi_size = 0;
static uint16_t exposure_time = 1000; // 100 msec
static uint16_t interval_time = 60; // 60 sec

#define TOTAL_FRAMES 300
#define AVIOFFSET 240

const char avi_header[AVIOFFSET+1] = {
  0x52, 0x49, 0x46, 0x46, 0xD8, 0x01, 0x0E, 0x00, 0x41, 0x56, 0x49, 0x20, 0x4C, 0x49, 0x53, 0x54,
  0xD0, 0x00, 0x00, 0x00, 0x68, 0x64, 0x72, 0x6C, 0x61, 0x76, 0x69, 0x68, 0x38, 0x00, 0x00, 0x00,
  0xA0, 0x86, 0x01, 0x00, 0x80, 0x66, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00,
  0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  WIDTH_1, WIDTH_2, 0x00, 0x00, HEIGHT_1, HEIGHT_2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4C, 0x49, 0x53, 0x54, 0x84, 0x00, 0x00, 0x00,
  0x73, 0x74, 0x72, 0x6C, 0x73, 0x74, 0x72, 0x68, 0x30, 0x00, 0x00, 0x00, 0x76, 0x69, 0x64, 0x73,
  0x4D, 0x4A, 0x50, 0x47, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x01, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x73, 0x74, 0x72, 0x66,
  0x28, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, WIDTH_1, WIDTH_2, 0x00, 0x00, HEIGHT_1, HEIGHT_2, 0x00, 0x00,
  0x01, 0x00, 0x18, 0x00, 0x4D, 0x4A, 0x50, 0x47, 0x00, 0x84, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4C, 0x49, 0x53, 0x54,
  0x10, 0x00, 0x00, 0x00, 0x6F, 0x64, 0x6D, 0x6C, 0x64, 0x6D, 0x6C, 0x68, 0x04, 0x00, 0x00, 0x00,
  0x64, 0x00, 0x00, 0x00, 0x4C, 0x49, 0x53, 0x54, 0x00, 0x01, 0x0E, 0x00, 0x6D, 0x6F, 0x76, 0x69,
  0x00
};


static void inline uint32_write_to_aviFile(uint32_t v) { 
  char value = v % 0x100;
  aviFile.write(value);  
  v = v >> 8; 
  value = v % 0x100;
  aviFile.write(value);  
  v = v >> 8;
  value = v % 0x100;
  aviFile.write(value);  
  v = v >> 8; 
  value = v;
  aviFile.write(value);
}


void setup() {
  Serial.begin(115200);
  while (!SD.begin()) {
    Serial.println("Insert SD Card");
  }

  LowPower.begin();
  RTC.begin();
  bootcause_e bc = LowPower.bootCause();

  digitalWrite(LED3, HIGH);
  digitalWrite(LED2, HIGH);
  
  if (SD.exists(infFilename)) {
    infFile = SD.open(infFilename, FILE_READ);
    if (!infFile) {
      Serial.println("Information File Open Error for reading");
      while(1);
    }
    rec_frame = infFile.readStringUntil('\n').toInt();
    movi_size = infFile.readStringUntil('\n').toInt();
    exposure_time = infFile.readStringUntil('\n').toFloat();
    interval_time = infFile.readStringUntil('\n').toInt();
    Serial.println("Read Rec Frame: " + String(rec_frame));
    Serial.println("Read Movie Size: " + String(movi_size));
    Serial.println("Read Exposure Time: " + String(exposure_time));
    Serial.println("Read Interval Time: " + String(interval_time));
    infFile.close();
  } else {
    // default
    // rec_frame = 0;
    // movi_size = 0;
    // exposure_time = 1000; /* 100msec */ 
    // interval_time = 60;  /* sec */
  }

   // check for recording for the first time.
  if (bc != DEEP_RTC) {
    Serial.println("Power on reset");
    if (SD.exists(aviFilename)) {
      SD.remove(aviFilename);
      rec_frame = 0;
      movi_size = 0;
      Serial.println("removed " + aviFilename);
    }
  } 
  aviFile = SD.open(aviFilename ,FILE_WRITE);
  if (!aviFile) {
    Serial.println("Movie File Open Error!");
    while(1);
  }
  
  if (rec_frame == 0) {
    Serial.println("First time: write header");
    aviFile.write(avi_header, AVIOFFSET);
    sleep(3); // wait for 3sec
  }

  Serial.println("Recording...");
  theCamera.begin();
  theCamera.setAbsoluteExposure(exposure_time); // 0.1 sec
  theCamera.setStillPictureImageFormat(
      img_width
     ,img_height
     ,CAM_IMAGE_PIX_FMT_JPG);
}

void loop() {
  
  CamImage img = theCamera.takePicture();
  if (!img.isAvailable()) {
    Serial.println("faile to take a picture");
    return;
  }

  if (rec_frame != 0) {
    aviFile.seek(aviFile.size());
  }
  aviFile.write("00dc", 4);

  uint32_t jpeg_size = img.getImgSize();
  uint32_write_to_aviFile(jpeg_size);
  
  aviFile.write(img.getImgBuff() ,jpeg_size);
  movi_size += jpeg_size;
  ++rec_frame;
  theCamera.end(); // to save power consumption

  /* Spresense's jpg file is assumed to be 16bits aligned 
   * So, there's no padding operation */

  float duration_sec = 0.1; // fix 10fps for Timelapse
  float fps_in_float = 10.0f; // fix 10fps for Timelapse
  float us_per_frame_in_float = 1000000.0f / fps_in_float;
  uint32_t fps = round(fps_in_float);
  uint32_t us_per_frame = round(us_per_frame_in_float);

  /* overwrite riff file size */
  aviFile.seek(0x04);
  uint32_t total_size = movi_size + 12*rec_frame + 4;
  uint32_write_to_aviFile(total_size);

  /* overwrite hdrl */
  /* hdrl.avih.us_per_frame */
  aviFile.seek(0x20);
  uint32_write_to_aviFile(us_per_frame);
  uint32_t max_bytes_per_sec = movi_size * fps / rec_frame;
  aviFile.seek(0x24);
  uint32_write_to_aviFile(max_bytes_per_sec);

  /* hdrl.avih.tot_frames */
  aviFile.seek(0x30);
  uint32_write_to_aviFile(rec_frame);
  aviFile.seek(0x84);
  uint32_write_to_aviFile(fps);   

  /* hdrl.strl.list_odml.frames */
  aviFile.seek(0xe0);
  uint32_write_to_aviFile(rec_frame);
  aviFile.seek(0xe8);
  uint32_write_to_aviFile(movi_size);

  aviFile.close();

  if (SD.exists(infFilename)) SD.remove(infFilename);
  infFile = SD.open(infFilename, FILE_WRITE);
  if (!infFile) {
    Serial.println("Information File Open Error for writing");
    while(1);
  }
  infFile.println(String(rec_frame));
  infFile.println(String(movi_size));
  infFile.println(String(exposure_time));
  infFile.println(String(interval_time));
  infFile.close();
  Serial.println("Information File Update: ");
  Serial.println("Write Rec Frame: " + String(rec_frame));
  Serial.println("Write Movie Size: " + String(movi_size));

  Serial.println("Movie saved");
  Serial.println(" File size (kB): " + String(total_size));
  Serial.println(" Captured Frame: " + String(rec_frame)); 
  Serial.println(" Duration (sec): " + String(duration_sec));
  Serial.println(" Frame per sec : " + String(fps));
  Serial.println(" Max data rate : " + String(max_bytes_per_sec));

  digitalWrite(LED2, LOW);
  digitalWrite(LED3, LOW);
  LowPower.deepSleep(interval_time); // Go to deep sleep for 60 seconds
} 



最初はパラメータの保存にSpresense用のEEPROMを使おうと思ったのですが、なぜだかEEPROMを使うと自作のSPISDライブラリのファイルオープンでエラーが返って来てしまいます。何かが競合しているみたいです。もし、SPISDライブラリを使おうと考えている人がいたら注意してください。


■ 作り終えた感想
電源オンで勝手にタイプラプス画像を作ってくれる小さくて軽量なカメラは、想像以上に楽しいものに仕上がりました。何より電池の持ちがいいのが良い!これからどれくらい持つかは試してみたいと思いますが、ひょっとしたら一日くらい余裕かも。ソーラーパネルによって充電フリーにできないか試したいと思います。

露光なども自分で設定できるので、シチュエーションにあわせて調整できるのもいいですね。(露光時間などパラメータをSDカードの情報ファイルで設定できるようにしよう、そうしよう)

高感度カメラがつながると暗い場所の撮影にも使えるのでさらに用途が広がりそうです。どこか出してくれないかな。
( ̄ー ̄).。oO


関連リンク
一月の電子工作「SPRESENSEでタイムラプスカメラを作ろう」
https://makers-with-myson.blog.ss-blog.jp/2021-01-04
一月の電子工作「SPRESENSEでタイムラプスカメラを作ろう」ー充電回路の検討ー
https://makers-with-myson.blog.ss-blog.jp/2021-01-10
一月の電子工作「SPRESENSEでタイムラプスカメラを作ろう」ーSDカードの検討ー
https://makers-with-myson.blog.ss-blog.jp/2021-01-17
一月の電子工作「SPRESENSEでタイムラプスカメラを作ろう」ーSDカードドライバの検討ー





SONY SPRESENSE メインボード CXD5602PWBMAIN1

SONY SPRESENSE メインボード CXD5602PWBMAIN1

  • 出版社/メーカー: スプレッセンス(Spresense)
  • メディア: Tools & Hardware



SONY SPRESENSE カメラモジュール CXD5602PWBCAM1

SONY SPRESENSE カメラモジュール CXD5602PWBCAM1

  • 出版社/メーカー: スプレッセンス(Spresense)
  • メディア: Tools & Hardware



KKHMF SDカードスロットソケットリーダーモジュールArduino用 [並行輸入品]

KKHMF SDカードスロットソケットリーダーモジュールArduino用 [並行輸入品]

  • 出版社/メーカー: Apple Trees E-commerce co., LT
  • メディア:



nice!(21)  コメント(1) 
共通テーマ:趣味・カルチャー

nice! 21

コメント 1

大島

とても小型でコンパクトな構造が素晴らしいと思いました!
自分も作成してみたいので、モデルのファイルをgithub等で上げていただくことは可能ですか?
by 大島 (2023-10-27 15:25) 

コメントを書く

お名前:
URL:
コメント:
画像認証:
下の画像に表示されている文字を入力してください。

この広告は前回の更新から一定期間経過したブログに表示されています。更新すると自動で解除されます。