tAROの試行錯誤

技術的なことを色々試した過程を記録

Timer Camera Xで定点観測カメラを作る(その1)

バッテリー内蔵で、通信できて、自由にプログラミングができて、安い(5000円以下)カメラが欲しいとずっと思っていました。 年明けにスイッチサイエンスさんからTimer Camera Xというまさにそれを叶えるものが発売されたので、お年玉気分で即購入しちゃいました。

www.switch-science.com

特に、以下のスペックがお気に入りです。

スタンバイ時の電流はわずか2 μAで、1時間に1枚のタイマー撮影をオンにしても、バッテリーは1ヶ月以上連続で動作します。

買ってからしばらく経ってしまいましたが、何か作ってみたいと思います。

1時間に1枚のタイマー撮影で1ヶ月バッテリーが持つとのことですが、そのサンプルコードが見つからなかったため、勉強がてら作ってみたいと思います。

まずは最終的に作りたいものの構成図を書いてみました。

from urllib.request import urlretrieve
from diagrams import Cluster, Diagram, Edge
from diagrams.custom import Custom
from diagrams.aws.network import APIGateway
from diagrams.aws.compute import Lambda
from diagrams.aws.storage import S3

urlretrieve("https://cdn.shopify.com/s/files/1/0056/7689/2250/products/6_8b9883c8-d556-4794-b43f-6a060b03dca5_1200x1200.jpg?v=1601857650", "timercamx.jpg")

with Diagram("システム構成図", filename="system", show=True):
  cam = Custom("TimerCameraX\n1時間に1枚撮影", "timercamx.jpg")
  with Cluster("AWS"):
    apigateway = APIGateway("APIGateway")
    awslambda = Lambda("AWSLambda")
    s3 = S3("S3")
  cam >> Edge(label='画像データ送信') >> apigateway >> awslambda
  awslambda >> Edge(label="jpg") >>s3

f:id:tARO:20210124223326p:plain

いきなりAWSに送る構成になっていますが、TimerCameraXはSDカード等の外部ストレージを持たないので、撮影した画像はクラウドに保存します。 Google Drive等に直接保存してもいいのですが、保存先は後からでも変更できる方がいいと思い、まずはAWSのAPIGatewayに送信し、そこから先はクラウド側で処理するようにします。 今回はAPIGatewayにAWSLambdaを接続し、AWSLambdaからS3に保存するようにします。

それでは、早速作っていきたいと思います。 ただ、いきなり最終構成で作っていくのはハードルが高いため、以下の6ステップで作っていきます。

  1. Node-REDでhttpで画像を送受信し、表示する環境を作る
  2. TimerCameraXで撮影した画像をPC上で確認する
  3. TimerCameraXからhttpでNode-REDに画像を送信する
  4. TimerCameraXをディープスリープさせて定期的に撮影するようにする
  5. AWS部分を作り、node-redから画像を送信する
  6. TimerCameraXとAWSをつなぐ

1. Node-REDでhttpで画像を送受信し、表示する環境を作る

ここでいきなりNode-REDが出てきます。

nodered.jp

Node-REDはブラウザ上でAPIが作成できたり、簡単なGUIが作成できたりと、IoTのプロトタイプを作るのに最適な環境で、以前から時々使っていて多少土地勘があるため使います。 MacBook Pro上のDockerで動作させているのですが、環境構築はこのあたりを参考にできている前提で進めます。

まずは、表示部分を作ります。 node-red 画像表示で検索して参考になりそうなところを探します。 上位何件かをみてみると、画像はどうやらbase64エンコードして、htmlに埋め込むのが常套手段のようです。 base64にさえしてしまえばdashboardのtemplateノードで埋め込みができそうです。

gist.github.com

上記のコードを見るとimgタグにbase64で指定すれば良さそうなので、html img base64でググって以下のページを見つけました。

edge.sincar.jp

最終的にはdashboardのtemplateノードに以下を記述すればOKでした。 <img src="data:image/jpg;base64,{{msg.payload}}"/>

動作確認するためにbase64のデータを用意します。 mac base64 encode jpgで検索して一番上に出てきたターミナルで画像をBase64化する方法(速度改善)を参考に、適当なjpgファイルをbase64エンコードして、| pbcopyクリップボードにコピーします。

base64 sample.jpg | pbcopy

コピーした文字列をinjectノードに貼り付けます。 そのinjectノードを先程のtemplateノードに接続します。 これで画像表示部分は無事完成です。 デプロイしてインジェクトして表示されることを確認します。

f:id:tARO:20210124223238p:plain

表示部分ができたので、次にhttpで送受信してみます。 上記のinjectノードをhttp inノードに置き換えます。 http inノードは以下のようにメソッドはPOST、URLは/imageと指定します。 http inノードにはhttp responseノードをステータスコード200で接続します。 これで受信部分は完成です。

f:id:tARO:20210124223302p:plain

次は、送信部分を作ります。 送信は先程のbase64をinjectするノードの後ろにhttp requestノードを接続し、以下のように設定します。 メソッドはPOST、URLはhttp://localhost:1880/imageとします。

f:id:tARO:20210124223309p:plain

これで送受信のサンプルができました。 デプロイしてinjectしたら表示されることを確認します。

送受信両方を作ったことで、今後、カメラから送信するときの受信確認や、AWSに送信するときの送信サンプルとして使えます。

2. TimerCameraXで撮影した画像をPC上で確認する

jpg画像をhttpで送受信する方法がわかったので、次はいよいよTimerCameraXを使って撮影をしてみます。 まずはサンプルを動かしてみます。 公式のQuickStartの通りにサンプルコード(web_cam)を動かして問題なく動くことを確認します。 web_camのサンプルコードでは画像を撮影するところ、配信するところが全てesp_cameraライブラリのstartCameraServer()関数に隠れてしまっていて、どうすれば撮影できるのか、どういうデータ構造を保持しているのかがよくわかりません。

そこで、esp_camera.hを使って、画像のバッファを取得しているサンプルを探します。 ちょうどいい記事が見つかりました。

qiita.com

loopの中の画像を取得して送信しているっぽいところを抜き出してコメントを書いてみます。

  // 撮影
  camera_fb_t *fb = esp_camera_fb_get();
  if ( fb ) {  // 撮影成功
    // fb->buf: データ(jpgならjpg、RAWならRAW)
    // fb->len: データの長さ(byte数)
    Serial.printf("width: %d, height: %d, buf: 0x%x, len: %d\n", fb->width, fb->height, fb->buf, fb->len);
    // MQTTで送信する
    g_pub_sub_client.publish("test", fb->buf, fb->len);
    // fbを使い終わって再利用できるようにする
    esp_camera_fb_return(fb);
  }

ということなので、MQTTで送信している部分を、http postに変更すればやりたいことができそうです。 その前に、このデータがほんとにjpgデータのなのかどうかを確認するために、base64エンコードしてシリアルモニタで確認してみようと思います。

web_camのサンプルを変更した、その実装が以下になります。今回はWifi関連の部分は使わないため削除しています。

#include "esp_camera.h"
#include <base64.hpp>
#include "camera_pins.h"

void setup() {
  Serial.begin(115200);
  Serial.setDebugOutput(true);
  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 = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;
  config.frame_size = FRAMESIZE_UXGA;
  config.jpeg_quality = 10;
  config.fb_count = 2;
 
  // camera init
  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("Camera init failed with error 0x%x", err);
    return;
  }

  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_QVGA);
}

void loop() {
  camera_fb_t *fb = esp_camera_fb_get();
  if ( fb ) {
    Serial.printf("width: %d, height: %d, buf: 0x%x, len: %d\n", fb->width, fb->height, fb->buf, fb->len);
    // base64にエンコードした場合必要なバッファサイズを計算(base64では8/7倍になる)
    unsigned int base64_length = encode_base64_length(fb->len);
    // バッファを確保、シリアルモニタにプリントするために終端文字用の1バイト追加
    unsigned char *base64buff = new unsigned char[base64_length+1];
    // 末尾に終端文字を代入
    base64buff[base64_length] = '\0';
    // base64にエンコードする
    encode_base64(fb->buf, fb->len, base64buff);
    // シリアルモニタに出力する
    Serial.printf("%s", base64buff);
    // メモリを解放する
    delete [] base64buff;
    esp_camera_fb_return(fb);
  }
  // put your main code here, to run repeatedly:
  delay(10000);
}

loopの中の以下の部分がbase64エンコードしてシリアルモニタに送信している部分です。 ここbase64ライブラリを追加してインクルードしています。

 if ( fb ) {
    Serial.printf("width: %d, height: %d, buf: 0x%x, len: %d\n", fb->width, fb->height, fb->buf, fb->len);
    // base64にエンコードした場合必要なバッファサイズを計算(base64では8/7倍になる)
    unsigned int base64_length = encode_base64_length(fb->len);
    // バッファを確保、シリアルモニタにプリントするために終端文字用の1バイト追加
    unsigned char *base64buff = new unsigned char[base64_length+1];
    // 末尾に終端文字を代入
    base64buff[base64_length] = '\0';
    // base64にエンコードする
    encode_base64(fb->buf, fb->len, base64buff);
    // シリアルモニタに出力する
    Serial.printf("%s", base64buff);
    // メモリを解放する
    delete [] base64buff;
    esp_camera_fb_return(fb);
  }

ここで引っかかったのは、base64のバッファサイズです。 バッファサイズをencode_base64_lengthで取得した必要サイズのピッタリにすると、シリアルモニタに出力する際にエラーで落ちました。 単純な話で、%sにプリントする際に、終端文字が見つかるまでメモリを参照してしまい、範囲外参照が起きているためと思われます。 そこで、サイズを1バイト余分に確保し、最後に終端文字を代入することで解決しました。

これで無事シリアルモニタに定期的にbase64の謎の文字列が送られてくるようになりました。 これがjpgかどうか、Node-REDの送信側のinjectノードにコピペしてinjectしてみます。 無事画像が出ました! 思った通り、jpgのバイナリがfb->bufに格納されていました。 これで撮影して、base64に変換するところまでできました。

ここで力尽きたので、今日はここまでにします。