aptpod Tech Blog

株式会社アプトポッドのテクノロジーブログです

EPS32でリアルタイム映像アップストリーム

aptpod Advent Calendar 2024 12月17日の記事です。

RoboticsグループでROSやロボット関連の開発を担当している影山です。 今日は、REST APIを利用して、リソースの限られた組み込みデバイスから、弊社のintdashへリアルタイムで映像をアップロードしてそれをブラウザで確認する方法について紹介したいと思います。

概要

intdashを利用するために、弊社はPythonやTypeScript、Rustなど色々な言語に対応したSDKを準備してます。 SDKを利用するには、基本的にそれぞれの言語やビルドしたバイナリが実行できる環境が必要になります。 そのため基本的にはPCやJetsonのようなLinuxが動作するデバイス上での利用が前提となります。

弊社では、プログラミングによるSDKの他にもREST APIを利用してintdashサーバとのデータの送受信を行う方法も提供しています。 REST APIであれば、よりリソースの限られたデバイスでも利用できる可能が広がります。今回は、その一例として、ESP32を搭載した小さなカメラを使って、撮影した映像をリアルタイムでintdashに送信して、ブラウザで映像を確認してみたいと思います。

利用するエッジデバイスについて

TimerCamera X

今回は、ESP32を搭載したTimer Camera X を利用して、映像のアップストリームを確認してきたいと思います。

Timer Camera Xは、AmazonSwitch Scienceなどで販売しています。

ESP32を利用する理由

ESP32は、WiFi機能を搭載しており、HTTPS通信を始めとした、各種APIに対応したSDK(ESP-IDF)を提供しています。 intdashとREST API通信するには、HTTPSを用いるので、ESP32のSDKを使えばintdashとの接続が実現できそうなので、選択しました。 今回はESP32とカメラが一体化したTimer Camera Xを利用しましたが、ESP32は、I2CやSPIに対応しており、カメラ以外の色々なセンサやデバイスと組み合わせ使うことも容易です。

Timer Camera X の開発環境

今回はESP-IDFを用いて開発していきます。 Timer Camera Xについての詳細は、公式のドキュメントが参考になります。

ビルドやテスト環境にはDockerを利用しています。開発環境の話については筆者のブログで触れていますので、興味があればご参照ください。

Timer Camera Xのサンプルコードのビルド

まずはサンプルコードで動作を確認します。

検索するといくつかTimer Camera X向けのサンプルが見つかりますが、古いものもあるので、現在は、以下のサンプルを利用するのが良いようです。

github.com

ESP-IDF環境でhttp-streamというサンプルをビルドします。 最新のESP-IDFではビルド時にエラーが起きたので、v4.4.8 を利用しています。 ビルドとフラッシュが上手くいくと、Timer Camera Xが起動したHTTPサーバのアドレスをブラウザで開くことで、リアルタイムでカメラの映像を確認できます。

今回はこのサンプルをベースにREST API over HTTPSで、撮影した画像をリアルタイムでintdashに送信していきます。

REST APIによるアップストリーム

基本的なREST APIの使い方については、別の記事で ATOM Lite 上でMicroPythonを用いた例を紹介しているので、そちらをご参照ください。

tech.aptpod.co.jp

まずはcurlコマンドを使ったシェルスクリプトでJPEGを送信する動作を確認してから、それをESP32に移植していきます。

画像はBASE64エンコードする必要ある点に注意必要です。以下は2枚のJPEG画像を交互に送信する処理を抜粋したサンプルです。

function send_chunk_jpg() {
    local base_time=$1
    local stream_id=$2
    local jpg_file="./1.jpg"

    for i in {1..10}; do
        if (( i % 2 == 1 )); then
            jpg_file="./1.jpg"
        else
            jpg_file="./2.jpg"
        fi
        echo "$jpg_file"
        local encoded_jpg=$(base64 -w 0 "$jpg_file")
        local body_file=$(mktemp)

        local send_time
        send_time=$(date +%s%N)

        cat <<EOF > "$body_file"
{
  "data_point_groups": [
    {
      "data_id": {
        "type": "jpeg",
        "name": "10/image"
      },
      "data_points": [
        {
          "elapsed_time": $((send_time - base_time)),
          "payload": "$encoded_jpg"
        }
      ]
    }
  ]
}
EOF
        curl \
            -H "Content-Type: application/json" \
            -H "Authorization: Bearer $access_token" \
            -X POST \
            --data @"$body_file" "${base_url}/api/iscp/projects/${project_uuid}/upstreams/${stream_id}/chunks" |
            jq .
        sleep 0.5
    done
}

ESP32上での実装

先ほどのcurlを使った手順を参考に、ESP-IDFのマニュアルを参照しつつ、ESP-IDFのAPIを使った実装にしていきます。 今回、使用している主なESP32のHTTPSに関係するAPIは以下の通りです。使い方はマニュアルに記載があります。

  • esp_http_client_init()
  • esp_http_client_set_header()
  • esp_http_client_set_post_field()
  • esp_http_client_open()
  • esp_http_client_write()
  • esp_http_client_fetch_headers()
  • esp_http_client_read_response()
  • esp_http_client_cleanup()

基本的には、HTTPヘッダの設定、HTTPS接続、Bodyの書き込み、レスポンスの解析、終了処理、という順番で処理していきます。 コードの全体は掲載するには長いので、データ送信部分を抜粋したサンプルコードを記事の末尾に記載してます。

実装時の注意点

ESP32での実装時、以下のようなポイントに注意が必要です。

  • カメラの映像はサイズが大きいので、menuconfigで、ESP32のPSRAM機能を有効にすることが必要です。
    • Timer Camera XにはPSRAM 8Mが搭載されており、ESP32の内蔵SRAM 512KBよりも大幅に多くのメモリを利用できますが、アクセス速度が遅い点には留意が必要です
  • 画像の送信時には、タイムスタンプを設定する必要があります。そのため今回はESP-IDFが提供するesp_sntp.hを利用しています。

動かしてたみた様子

実際にリアルタイムで撮影してみた様子です。 パソコンの前でTimer Camera Xを構えて、現在時刻が表示されたData Visualizerの画面を撮影しています。

アップストリーム動作確認

動画内の画面右枠に表示されているのが、Timer Camera Xからアップストリームされた映像です。

Data Visualizer上でのTimer Camera Xで撮影した映像表示

実際に試してみると1枚のJPEGの送信に時間がかかるため、滑らかな表示とまではいきませんでしたが、定期的な映像のアップストリームが確認できました。

まとめ

ESP32を搭載したカメラデバイスを使って、intdashにリアルタイムで画像を送るサンプルを実装しました。

滑らかな動画を送ることは難しかったですが、一定間隔で撮影した画像をパラパラアニメのような感じで送信することには使えそうでした。

高性能なエッジデバイスを準備しなくても、intdashとの連携ができるので、REST APIを利用することで、ユースケースの幅を広げることができると思います。

この記事を見て、エッジデバイスとクラウドの連携技術について、ご興味を持たれた方は窓口からご相談頂ければ幸いです。

www.aptpod.co.jp

参考:JPEG送信部分のC++コード抜粋

void send_chunk(const char *base_url, const char *project_uuid, const char *stream_id, int64_t base_time) {
    char url[256];
    snprintf(url, sizeof(url), "%s/api/iscp/projects/%s/upstreams/%s/chunks", base_url, project_uuid, stream_id);

    camera_fb_t *fb = NULL;
    size_t _jpg_buf_len;
    uint8_t *_jpg_buf;
    int err_count = 0;
    char *base64_buf = NULL;
    size_t base64_len;

    for (int i = 1; i <= 100; i++) { //100枚送信する
        int64_t send_time = esp_timer_get_time() * 1000; // マイクロ秒をナノ秒に変換

        // カメラのフレームバッファ取得
        fb = esp_camera_fb_get();
        if (!fb) {
            ESP_LOGE(TAG, "Camera capture failed");
            err_count++;
            if(err_count>10){
                return;
            }else{
                vTaskDelay(500 / portTICK_PERIOD_MS);
                continue;
            }
        }
        if (fb->format != PIXFORMAT_JPEG) {
            bool jpeg_converted = frame2jpg(fb, 80, &_jpg_buf, &_jpg_buf_len);
            if (!jpeg_converted) {
                ESP_LOGE(TAG, "JPEG compression failed");
                esp_camera_fb_return(fb);
                return;
            }
        } else {
            _jpg_buf_len = fb->len;
            _jpg_buf = fb->buf;
        }
        // Base64エンコード用のバッファを確保
        size_t encoded_len = 4 * ((_jpg_buf_len + 2) / 3) + 1;
        base64_buf = (char *)malloc(encoded_len);
        if (!base64_buf) {
            ESP_LOGE(TAG, "Failed to allocate memory for Base64 buffer");
            if (fb->format != PIXFORMAT_JPEG) {
                free(_jpg_buf);
            }
            esp_camera_fb_return(fb);
            return;
        }

        // Base64エンコード
        int ret = mbedtls_base64_encode((unsigned char *)base64_buf, encoded_len, &base64_len, _jpg_buf, _jpg_buf_len);
        if (ret != 0) {
            ESP_LOGE(TAG, "Base64 encoding failed with error code: %d", ret);
            free(base64_buf);
            if (fb->format != PIXFORMAT_JPEG) {
                free(_jpg_buf);
            }
            esp_camera_fb_return(fb);
            return;
        }

        // 送信
        // JSON全体のサイズを計算してメモリを確保
        size_t json_size = snprintf(NULL, 0, JSON_FORMAT, send_time - base_time, base64_buf) + 1;

        char *body = (char *)malloc(json_size);
        if (!body) {
            ESP_LOGE(TAG, "Failed to allocate memory for JSON body");
            free(base64_buf);
            if (fb->format != PIXFORMAT_JPEG) {
                free(_jpg_buf);
            }
            esp_camera_fb_return(fb);
            return;
        }

        snprintf(body, json_size, JSON_FORMAT, send_time - base_time, base64_buf);

        esp_http_client_config_t config = {
            .url = url,
            .crt_bundle_attach = esp_crt_bundle_attach,
            .buffer_size = 2048,
            .buffer_size_tx = 2048,
            .method = HTTP_METHOD_POST,
        };

        esp_http_client_handle_t client = esp_http_client_init(&config);
        esp_http_client_set_header(client, "Content-Type", "application/json");
        esp_http_client_set_header(client, "Accept-Encoding", "identity");
        esp_http_client_set_header(client, "Authorization", access_token);
        esp_http_client_set_post_field(client, body, strlen(body));

        esp_err_t err = esp_http_client_open(client,strlen(body));
        if( err != ESP_OK)
        {
            ESP_LOGE(TAG, "Failed to open HTTP connection: %s", esp_err_to_name(err));
            esp_http_client_cleanup(client);
            free(base64_buf);
            free(body);
            if (fb->format != PIXFORMAT_JPEG) {
                free(_jpg_buf);
            }
            esp_camera_fb_return(fb);
            continue;
        }

        int wlen = esp_http_client_write(client, body, strlen(body));
        if (wlen >= 0) {
            ESP_LOGI(TAG, "Chunk %d sent successfully, elapsed:%lld", i, send_time - base_time);
        } else {
            ESP_LOGE(TAG, "HTTP request failed: %s", esp_err_to_name(err));
        }

        // メモリ開放
        esp_http_client_cleanup(client);
        free(base64_buf);
        free(body);
        if (fb->format != PIXFORMAT_JPEG) {
            free(_jpg_buf);
        }
        esp_camera_fb_return(fb);
    }
}