M5Camera Timer Camera X とGoogleドライブでタイムラプス撮影 (高解像度版)

建物が建築されていく様子をタイムラプス撮影したいと思い、GoProか何か買うか、引退したスマホを引っ張り出そうか、と考えていたところ、同僚から「M5Cameraがお手軽ですよ」と教えてもらいました。サイトを見たら3千円足らずで買えて、コンピュータとWiFiとBLEが入っているとのことなので、それ以上調べずに衝動買い。

手元に届いて驚いたのは、説明書の類が一切ないこと。あってもあまり読まないけど。笑

一定時間ごとに写真を撮影して撮りためたいのですが、こいつにはストレージがほとんど無いので、撮ったら即座にネットにアップする必要があります。これを設置する予定の場所にはWiFiがあるので、それを使います。

ネットで検索して試した結果、この記事のお世話になりました。

上記1つめの記事は M5Camera 用の話。2つめは M5Camera Timer Camera X 用の話。M5Camera初心者の私は、M5Cameraに複数機種あることや、それらによってアプリのコードを変えなければ全く動かないという文化を知らなかったので、1つめの記事だけで「動かないなー」となっていました。

なお、2つめの記事を実装して成功するまでの注意点は、

  • Arduino IDE に Timer-Camera ライブラリをインストールする必要がありますが、記事には書かれていないようです。

  • 私は Arduino IDE をPCに入れるにあたって最新版の 2.0 を入れてしまったのですが、それだと Timer-Camera ライブラリをインストールできなかったので、IDE 1.8 を入れ直したらうまく行きました。

さて、これでめでたく写真がGoogleドライブにアップロードできるようになったのですが、その写真の画質がイマイチで納得行きません。サイズが 800x600ですし、ファイルサイズが 50KBくらいしかありません。Timer Camera X は「3メガピクセル」と謳っているのに。

そこで、(ようやく)スケッチ(コード)を読んでみると、

  config.jpeg_quality = 10;

という箇所と、

  //drop down frame size for higher initial frame rate
  s->set_framesize(s, FRAMESIZE_SVGA);

という箇所が怪しいです。調べてみると、jpeg_quality という変数には 0から63の数値を指定できて、0が最高画質だそうです。2つめのコードは、撮影した画像をわざわざSVGA(800x600)にしていますね。
というわけで、後者はコメントアウトして、前者はもっと小さい数字にすれば高画質の写真が撮れるはず。

しかし、やってみると残念な結果が。

こんな感じに壊れた画像がアップロードされました。このコードを書いた人は、こういうふうに壊れないギリギリの画質が SVGA & 画質10 だと判断して上記のように書いたのですね、きっと。

じゃあこれが Timer Camera X からGoogleドライブにアップロードできる画質の限界なの? というのは納得いきません。せっかく3Mピクセル(2048x1536)撮れるはずなので。

そこでさらにコードを眺めたところ、これは撮影した画像のJPEGデータをBase64とURLエンコードしてからGAS(Google Apps Script)に送信しています。写真1枚分のデータを丸ごとエンコードしてから送信しているため、それだけのRAMを消費しますが、このハードウェアにはそんなに広大なRAMは無いのでは。IDEでコンパイルしたときに出るメッセージ「ローカル変数で xxxxバイト使うことができます」というところに出る数値は300MB足らずですし。

そこで、全部エンコードしてから送信するのではなく、少しずつエンコードしては送信するというのを繰り返す形にすればメモリ消費は抑えられるはずです。そのように書き換えましょう。

ただし、この送信プロトコルでは、最初の方でデータの総量を送信しなければなりません。

    client.println("Content-Length: " + String(Data.length()+imageFile.length()));

しかし、データの総量はエンコードしてみないと分かりません。Base64は元データの3分の4倍と決まっているのですが、URLエンコードはデータに含まれる記号の数によって変わるので。

そこで、効率は悪いですが2ステップに分けて、最初はエンコード後のデータ量を数えるためだけにエンコードし、2ステップ目でデータを送信しながらのエンコード、と同じエンコードを2回やるように実装しました。

これにより、めでたく QXGA(2048x1536) & 画質3 で撮影した写真を送信することに成功しました。ファイルサイズ500KB強。

なお、元のコードは URLエンコードを String型で行っているので、そこは高速化の余地があるかもしれません。

というわけで、今のところのコードは以下のとおりです。上記以外に、動作中にLEDを点灯させたり、delay()ではなくディープスリープを使ったりしています。冒頭の記事でダウンロードできるスケッチ一式のうちの TimerCameraX_gdrive_pub.ino  を置き換えて使います。
ssid, password, myScript の値はそれぞれの環境に合わせて入れてください。

これでめでたしめでたしなのですが、このカメラ、バッテリーを内蔵しており、電源ボタンを長押ししている間は電源がオンになるのですが、ボタンから指を離すとオフになってしまいます。兄弟モデルの Timer Camera F (広角レンズ版)のページには「PWRボタンを2秒長押しすることで、電源オンになります」とあるのに、Timer Camera X の方はそうではないのか? でもこれだと結局モバイルバッテリーを繋がないと使えないからバッテリーを積んでいる意味が無いのでは?問い合わせてみようかな。。。

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include "soc/soc.h"
#include "soc/rtc_cntl_reg.h"
#include "Base64.h"

#include "esp_camera.h"

#include "camera_pins.h"
//#include "led.h"
#include "bmm8563.h"
#include "battery.h"

// WiFi 設定
const char* ssid     = "SSID";
const char* password = "PASSWORD";

// 撮影設定
int shootingIntervalSec = 60 * 60;   // 撮影間隔 (秒)

// GAS 設定
const char* myDomain = "script.google.com";
String myScript = "/macros/s/XXXXXXXXXXXXXXXXXXXXXX/exec"; //Replace with your own url
int waitingWebTimeSec = 10; // Webサーバーのレスポンスを待つ秒数

String myFilename = "filename=M5Camera.jpg";
String mimeType = "&mimetype=image/jpeg";
String myImage = "&data=";


void setup()
{
  WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0);

  // Init
  bat_init();
  bmm8563_init();
  //led_init(CAMERA_LED_GPIO);

  // Test LED
  //led_brightness(1023);
  //delay(1000);
  pinMode(GPIO_NUM_2, OUTPUT); // LED初期化: GPIO2番を出力モードに設定 (Timer Camera)

  // Halt
  bat_disable_output();

  Serial.begin(115200);
  delay(10);

  WiFi.mode(WIFI_STA);

  Serial.println("");
  Serial.print("Connecting to ");
  Serial.println(ssid);
  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED) {
    Serial.print(".");
    delay(500);
  }

  Serial.println("");
  Serial.println("STAIP address: ");
  Serial.println(WiFi.localIP());

  Serial.println("");

  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM;
  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM;
  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;
  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;
  config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href = HREF_GPIO_NUM;
  config.pin_sscb_sda = SIOD_GPIO_NUM;
  config.pin_sscb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 10000000;
  config.pixel_format = PIXFORMAT_JPEG; //YUV422,GRAYSCALE,RGB565,JPEG
  config.frame_size = FRAMESIZE_QXGA;  // Timer Camera X の最高解像度はQXGA(2048x1536)。https://github.com/espressif/esp32-camera/blob/master/driver/include/sensor.h
  config.jpeg_quality = 5;    //0-63 lower number means higher quality    (試したところ、UXGAの最高画質は2。QXGAの最高画質は3だった。写った画像の複雑さによるかもしれないが。)
  config.fb_count = 2;    //if more than one, i2s runs in continuous mode. Use only with JPEG

  esp_err_t err = esp_camera_init(&config);

  if (err != ESP_OK) {
    Serial.printf("Camera init failed with error 0x%x", err);
    delay(1000);
    ESP.restart();
  }

  sensor_t * s = esp_camera_sensor_get();
  //initial sensors are flipped vertically and colors are a bit saturated
  s->set_vflip(s, 1);//flip it back
  s->set_brightness(s, 1);//up the blightness just a bit
  s->set_saturation(s, -2);//lower the saturation

  //drop down frame size for higher initial frame rate
  //  s->set_framesize(s, FRAMESIZE_SXGA);
}

void loop() {
  digitalWrite(GPIO_NUM_2, HIGH); //LED点灯
  saveCapturedImage();    // 撮影してアップロードする。
  digitalWrite(GPIO_NUM_2, LOW); //LED消灯

  Serial.printf("Waiting for %u sec.\n", shootingIntervalSec);
//  delay(shootingIntervalSec * 1000);    // 次の撮影まで待つ。
  // スリープ
  Serial.printf("esp_sleep_enable_timer_wakeup: %d\n", esp_sleep_enable_timer_wakeup((uint64_t)shootingIntervalSec * 1000ULL * 1000ULL)); //単位はマイクロ秒
  esp_deep_sleep_start();
}

void saveCapturedImage() {
  Serial.println("Connecting to " + String(myDomain));
  WiFiClientSecure client;
  client.setInsecure();
  if (client.connect(myDomain, 443)) {    // Webサーバーに接続する。
    Serial.println("Connection successful");

    camera_fb_t * fb = NULL;
    fb = esp_camera_fb_get();   // 写真を撮影してフレームバッファを得る。属性値:fb->width, fb->height, fb->format, fb->buf, fb->len
    if (!fb) {
      Serial.println("Camera capture failed");    // 取得に失敗した。
      delay(1000);
      ESP.restart();
      return;
    }
    Serial.printf("frame buffer size: %u x %u\n", fb->width, fb->height);    

    Serial.println("Step 1: calicurating data size...");    // Base64とurlencodeされたデータのサイズを数える。
    int index = 0;
    uint8_t *p = fb->buf;
    int rest = fb->len;       // 元のサイズ
    int base64EncodedSize = 0;  // Base64 後のサイズ
    int urlencodedSize = 0;   // Base64 + urlencode 後のサイズ
    while (rest > 0)
    {
      char output[2048 +1];    // 一度に出力するBase64化されたデータを入れるバッファ (base64_encode()が末尾にヌルを入れるので、1バイト追加。)
      int srcLen = rest > 1536 ? 1536 : rest;   // このサイクルでエンコードする元データサイズ(最大はバッファの 3/4 のサイズ)
      int encLen = base64_encode(output, (char *)p + index, srcLen);   // Base64エンコードする。
      base64EncodedSize += encLen;
      if (encLen > 0) {
        String str = urlencode(String(output));   // URLエンコードする。
        urlencodedSize += str.length();
      }
      index += srcLen;
      rest -= srcLen;
    }
    Serial.printf("frame buffer size: %u\n", fb->len);
    Serial.printf("after Base64 encoding: %u\n", base64EncodedSize);
    Serial.printf("frame buffer size: %u\n", urlencodedSize);

    Serial.println("Step 2: Sending a captured image to Google Drive.");
    String Data = myFilename + mimeType + myImage;    // POSTで送られるデータの先頭部分。これの後に画像をBase64化したものが続く。
    client.println("POST " + myScript + " HTTP/1.1");
    client.println("Host: " + String(myDomain));
    client.println("Content-Length: " + String(Data.length() + urlencodedSize));    // ここでデータの長さを書く必要があるので、Step 1 が必要。
    client.println("Content-Type: application/x-www-form-urlencoded");
    client.println();
    client.print(Data);

    index = 0;
    p = fb->buf;
    rest = fb->len;
    Serial.printf("Estimated cycle: %u\n", rest / 1536);
    while (rest > 0 && client.connected())
    {
      char output[2048 +1];    // 一度に出力するBase64化されたデータを入れるバッファ (base64_encode()が末尾にヌルを入れるので、1バイト追加。)
      int srcLen = rest > 1536 ? 1536 : rest;   // このサイクルでエンコードする元データサイズ(最大はバッファの 3/4 のサイズ)
      int encLen = base64_encode(output, (char *)p + index, srcLen);    // Base64エンコードする。
      if (encLen > 0) {
        String str = urlencode(String(output));   // URLエンコードする。
        client.write((uint8_t *)(str.c_str()), str.length());   // データを送信する。
        index += srcLen;
        rest -= srcLen;
        Serial.print(".");
      }
    }
    Serial.println();
    client.flush();
    esp_camera_fb_return(fb);   // システムにフレームバッファを使い終わったことを知らせる。

    Serial.println("Waiting for response.");
    long int StartTime = millis();
    while (!client.available()) {
      Serial.print(".");
      delay(100);
      if ((StartTime + waitingWebTimeSec * 1000) < millis()) {
        Serial.println();
        Serial.println("No response.");
        //If you have no response, maybe need a greater value of waitingTime
        break;
      }
    }
    Serial.println();
    while (client.available()) {
      Serial.print(char(client.read()));
    }
  } else {
    Serial.println("Connection to " + String(myDomain) + " failed.");
  }
  client.stop();
}

//https://github.com/zenmanenergy/ESP8266-Arduino-Examples/
String urlencode(String str)
{
  String encodedString = "";
  char c;
  char code0;
  char code1;
  char code2;
  for (int i = 0; i < str.length(); i++) {
    c = str.charAt(i);
    if (c == ' ') {
      encodedString += '+';
    } else if (isalnum(c)) {
      encodedString += c;
    } else {
      code1 = (c & 0xf) + '0';
      if ((c & 0xf) > 9) {
        code1 = (c & 0xf) - 10 + 'A';
      }
      c = (c >> 4) & 0xf;
      code0 = c + '0';
      if (c > 9) {
        code0 = c - 10 + 'A';
      }
      code2 = '\0';
      encodedString += '%';
      encodedString += code0;
      encodedString += code1;
      //encodedString+=code2;
    }
    yield();
  }
  return encodedString;
}


この記事が気に入ったらサポートをしてみませんか?