M5CameraとngrokでMotion JPEG動画を遠隔ストリーミングする実験

ngrokとM5Cameraで動画ストリーミング M5Stack

ngrokとの連携では、httpdをあきらめ、WiFiClientを使う

前ページでは、ブラウザの431エラーの原因を解明しました。
Arduino core ESP32のhttpd関数が問題だったので、ネットでいろいろ解決策を探っていました。
そうしたら、以下のサイトで、WiFiClientだけを使っている例を見つけました。

https://www.iotsharing.com/2020/03/using-http-for-camera-live-stream.html

同じように試してみたら、何と、431エラーが出ませんでした!

おー!
できるじゃないか!

SSLのhttpsでも、Basic認証付きでも問題無くできました!
すばらしい!!!
このサイトのおかけで、かなりの部分が解決しました。
感謝! 感謝!

Arduinoスケッチ(プログラムソースコード)

では、上記のサイトを参考にし、そしてArduino core ESP32のサンプルスケッチSimpleWiFiServerも参考にしながら、従来のWiFiClient関数で自分なりに組んでみました。
因みに私は素人ですから、間違えていたらコメント投稿していただけると助かります。
(※M5Cameraが第三者に渡ると、ツール等によってSSIDやパスワードが抜き取られる可能性がありますので注意してください)

#include <WiFi.h>
#include <esp_camera.h>

static const char* ssid = "xxxxxxxxx"; //ご自分のルーターのSSIDに書き換えてください
static const char* password = "xxxxxxxxx"; //ご自分のルーターのパスワードに書き換えてください

#define PWDN_GPIO_NUM     -1
#define RESET_GPIO_NUM    15
#define XCLK_GPIO_NUM     27
#define SIOD_GPIO_NUM     22 //M5Camera model A #25
#define SIOC_GPIO_NUM     23
#define Y9_GPIO_NUM       19
#define Y8_GPIO_NUM       36
#define Y7_GPIO_NUM       18
#define Y6_GPIO_NUM       39
#define Y5_GPIO_NUM        5
#define Y4_GPIO_NUM       34
#define Y3_GPIO_NUM       35
#define Y2_GPIO_NUM       32
#define VSYNC_GPIO_NUM    25 //M5Camera model A #22
#define HREF_GPIO_NUM     26
#define PCLK_GPIO_NUM     21

static bool isWiFiConnected = false;

static WiFiServer server(80);
static WiFiClient client_mjpeg;

static uint8_t fps_cnt = 0;
static uint32_t fps_time = 0;

void setup() {
  Serial.begin(115200);
  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.jpeg_quality = 20; //※画質(10~60)
  config.frame_size = FRAMESIZE_VGA; //640x480 pixel
  config.fb_count = 1;

  //esp_cameraライブラリ初期化
  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("Camera init failed with error 0x%x", err);
    return;
  }

  //イメージセンサOV2640の初期設定
  sensor_t *sensor = esp_camera_sensor_get();
  sensor->set_framesize(sensor, FRAMESIZE_QQVGA); //160x120 pixel
  sensor->set_hmirror(sensor, 1); //M5Cameraのモデルによって変更
  sensor->set_vflip(sensor, 1); //M5Cameraのモデルによって変更
  sensor->set_exposure_ctrl(sensor, 0); //sensor露出制御OFF
  sensor->set_aec2(sensor, 0); //DSP自動露出制御OFF
  sensor->set_ae_level(sensor, 0); //-2~2
  sensor->set_aec_value(sensor, 50); //0~1200(実質255までで充分)
  sensor->set_whitebal(sensor, 1); //ホワイトバランスON
  sensor->set_awb_gain(sensor, 1); //自動ホワイトバランスゲインON

  connectToWiFi();
  while(!isWiFiConnected){
    delay(1);
  }
  server.begin();
}

void loop() {
  receiveGetRequest();
  sendMJPEG();
  delay(1);
}
//****************************************
void receiveGetRequest(){
  WiFiClient client = server.available();
  if(client){
    Serial.println("New Client.");
    String currentLine = "";
    while(client.connected()){
      while(client.available()){
        char c = client.read();
        Serial.write(c);
        if(c == '\n'){
          if(currentLine.length() == 0){
            resp404(client);
            stopClient(client);
            return;
          }else{
            currentLine = "";
          }
        }else if(c != '\r'){
          currentLine += c;
        }

        if(currentLine.endsWith("GET / HTTP/1.1")){
          respIndex(client);
          stopClient(client);
          return;
        }

        if (currentLine.endsWith("GET /stream")){
          respHeadMJPEG(client);
          client_mjpeg = client;
          fps_cnt = 0;
          fps_time = millis();
          return;
        }

        if (currentLine.endsWith("control?var=stop_stream")){
          respStopStream(client);
          stopClient(client);
          return;
        }

        if (currentLine.endsWith("control?var=aec_val")){
          recvAecVal(client);
          stopClient(client);
          return;
        }
        delay(1);
      }
      delay(1);
    }
  }
}
//****************************************
void recvHttpReq(WiFiClient &client){
  String req_str = "";
  while(client.available()){
    char c = client.read();
    req_str += c;
    if(c == '\n'){
      //Serial.print(req_str);
      req_str = "";
    }
    delay(1);
  }
}
//****************************************
void stopClient(WiFiClient &client){
  delay(1);
  client.stop();
  Serial.println("\r\nClient Stop.");
}
//****************************************
void respIndex(WiFiClient &client){
  recvHttpReq(client);

  String resp_head_str = "HTTP/1.1 200 OK\r\n";
                   resp_head_str += "Content-type: text/html\r\n";
                   resp_head_str += "Connection: keep-alive\r\n\r\n";
  String html_str = R"(
<!doctype html>
<html>
<head>
  <meta name='viewport' content='width=device-width, initial-scale=1' charset='utf-8'>
  <style>
    .img-wh{border:1px solid;}
    button{border-radius:25px;}
  </style>
</head>
<body>
  <img id='img_mjpeg' class='img-wh' width='160' height='120' /><br/>
  <div>
    <button id='btn_start' onclick='startStream();'>Start Stream</button>
    <button onclick='stopStream();'>Stop Stream</button><br>
    <label for='aec_val'>Manual Exposure value: <span id='aec_val_txt'>50</span></label><br>
    <input type="range" id='aec_val' min='0' max='255' value='50' onchange='aecVal();'>
  </div>
  <script>
    const base_url = document.location.origin,
          mjpeg_url = base_url + '/stream',
          mjpeg = document.getElementById('img_mjpeg'),
          aec_val_elm = document.getElementById('aec_val');
          aec_val_txt_elm = document.getElementById('aec_val_txt');
    function startStream(){
      btn_start.disabled = true;
      let d = new Date();
      mjpeg.src = mjpeg_url + '?' + d.getTime();
    }
    function stopStream(){
      btn_start.disabled = false;
      changeCtrlCam("stop_stream",0);
    }
    function aecVal(){
      let aec_val = aec_val_elm.value;
      changeCtrlCam("aec_val",aec_val);
      aec_val_txt_elm.innerHTML = aec_val;
    }
    function changeCtrlCam(var_txt, value_txt){
      let ctrl_url = base_url + '/control?var=';
      ctrl_url += var_txt + '&';
      ctrl_url += 'val=' + value_txt;
      fetch(ctrl_url).then((response) => {
        if(response.ok){
          return response.text();
        } else {
          throw new Error();
        }
      })
      .then((text) => console.log(text))
      .catch((error) => console.log(error));
    }
  </script>
</body>
</html>
)";

  client.print(resp_head_str);
  client.println(html_str);
  client.println();
}
//****************************************
void resp404(WiFiClient &client){
  Serial.println("send request: 404 Not Found");
  recvHttpReq(client);
  client.print("HTTP/1.1 404 Not Found\r\n");
  client.print("Connection: keep-alive\r\n\r\n");
}
//****************************************
void respHeadMJPEG(WiFiClient &client){
  Serial.println();
  recvHttpReq(client);
  String resp_head_str = "HTTP/1.1 200 OK\r\n";
         resp_head_str += "Access-Control-Allow-Origin: *\r\n";
         resp_head_str += "Content-Type: multipart/x-mixed-replace; boundary=--myboundary\r\n\r\n";
  client.print(resp_head_str);
}
//****************************************
void sendMJPEG(){
  if(client_mjpeg){
    camera_fb_t * fb = esp_camera_fb_get();
    if (!fb) {
        Serial.println("Camera capture failed");
        return;
    }
    String resp_head_str = "--myboundary\r\n";
           resp_head_str += "Content-Type: image/jpeg\r\n";
           resp_head_str += "Access-Control-Allow-Origin: *\r\n";
           resp_head_str += "Content-Length: " + String(fb->len);
           resp_head_str += "\r\n\r\n";

    client_mjpeg.print(resp_head_str);
    client_mjpeg.write(fb->buf, fb->len);
    client_mjpeg.print("\r\n");

    fps_cnt++;
    if(millis() - fps_time > 1000){
      Serial.printf("%d (fps)\r\n", fps_cnt);
      //Serial.printf("before receive jpg heap=%d\r\n", esp_get_free_heap_size());
      fps_cnt = 0;
      fps_time = millis();
    }
    esp_camera_fb_return(fb);
  }
}
//****************************************
void respStopStream(WiFiClient &client){
  client_mjpeg.stop();
  Serial.println("\r\nclient_mjpeg Stop.");
  recvHttpReq(client);
  client.print("HTTP/1.1 200 OK\r\n");
  client.print("Connection: close\r\n\r\n");
}
//****************************************
void recvAecVal(WiFiClient &client){
  String req_str = "";
  while(client.available()){
    char c = client.read();
    req_str += c;
    if(c == '\n'){
      Serial.print(req_str);
      int from = req_str.indexOf("=");
      if(from >= 0) {
        uint8_t to = req_str.indexOf("H");
        String val_str = req_str.substring(from + 1, to - 1);
        int16_t val = atoi(val_str.c_str());
        Serial.printf("AEC val = %d\r\n", val);
        sensor_t *sensor = esp_camera_sensor_get();
        sensor->set_aec_value(sensor, val);
      }
      while(client.available()){
        client.read();
        delay(1);
      }
    }
    delay(1);
  }
  client.print("HTTP/1.1 200 OK\r\n");
  client.print("Connection: keep-alive\r\n\r\n");
}
//****************************************
void connectToWiFi(){
  Serial.println("Connecting to WiFi network: " + String(ssid));
  WiFi.disconnect(true, true);
  delay(1000);
  WiFi.onEvent(WiFiEvent);
  WiFi.begin(ssid, password);
  Serial.println("Waiting for WIFI connection...");
}

void WiFiEvent(WiFiEvent_t event){
  switch(event) {
    case SYSTEM_EVENT_STA_GOT_IP:
      Serial.println("WiFi connected!");
      Serial.print("My IP address: ");
      Serial.println(WiFi.localIP());
      delay(1000);
      isWiFiConnected = true;
      break;
    case SYSTEM_EVENT_STA_DISCONNECTED:
      Serial.println("WiFi lost connection");
      isWiFiConnected = false;
      break;
    default:
      break;
  }
}

【簡単に解説】

いろいろ試行錯誤していて気付いたのは、ngrokを通すと、ブラウザから送られてくるGETリクエストが遅れる場合があり、whileループでタイムアウトしてしまい、なかなかアクセスできないことがありました。
それで、おおよそ問題無く動いた最終系がこのコードです。

receoveGetReqiest関数で、ブラウザからのGETリクエストを待ち受けます。
リクエストを受信したら、clientが有効になりますが、/streamという文字列を受信したら、clientclient_mjpegにコピーして、sendMJPEG関数に渡して、Motion JPEGフレーム1枚をブラウザへ送信します。
(Motion JPEGの送信手順についてはこちらの記事を参照してください。)
そして、すぐにreceiveGetRequest関数に戻り、新たにclientを生成して、GETリクエストを待ち受けます。
この時、WiFiClientクラスの実体は2つ存在することになるというところがミソです。
こうすることによって、Motion JPEGストリーミングを1枚送信する毎にブラウザからの新たなGETリクエストを待ち受けられるので、あたかもマルチタスクしているかのように、ストリーミング中でもボタン操作やスライダー操作が可能というわけです。
client_mjpegの値はグローバル領域で保持されているので、「Stop Stream」ボタンが押されると、client_mjpeg.stop()を実行して解放します。

このようにシングルタスクで組んでも、160 x 120 pixelでフレームレートは25fpsくらい出るので、全く問題ありません。
因みに、フレームサイズを640×480 pixelで実験しましたが、13fpsくらいでした。

ただ、最初に紹介した動画のように、動画ストリーミング中にGETリクエストを受信すると、そのレスポンス中はストリーミングが停止してしまうのは仕方ありません。
それでも遠隔操作するならば、個人的に問題無いレベルだと思っています。

このように、一つのポート80番で、ストリーミング中にボタン操作ができるのは個人的に画期的だなぁと思いました。
これによって、無料版のngrokでM5Cameraの動画ストリーミングとボタン操作が可能になるわけです。

コンパイル書き込み実行、そして動作確認

では、Wi-Fiルータを起動し、Arduino IDEでシリアルモニタを115200bpsで起動させます。
その後、スケッチをコンパイル書き込みすると、Wi-Fiアクセスポイントに接続し、以下のように表示されると思います。

Waiting for WIFI connection…
WiFi connected!
My IP address: 192.168.xxx.xxx

表示されたIPアドレスをメモっておき、こちらの記事を参考にngrokで新規にBasic認証付きサーバーアドレスを発行しておきます。

そしてブラウザを起動し、URL入力欄にSSLのhttpsアドレスを入力し、Basic認証のユーザー名とパスワードを入力します。
そして、以下の画面が表示されればOKです。

(図02)

ブラウザの表示画面

ブラウザの表示画面

Manual Exposure は本来はAuto Exposure Control (AEC)です。
自動露出制御なのですが、マニュアル調整っぽいので、独断でその表示名にしました。
周囲の明るさによっては、OV2640のセンサ感度がうまく合わず、点滅する場合があります。点滅しているのは、おそらくAuto Exposure(自動露出)センサの影響だと思われます。
その他、イメージセンサ0V2640の設定方法については、以下の記事を参照してください。

esp32-cameraライブラリを読み解く ~モジュール接続、動作チェック編~

まとめ

以上、ngrokというトンネリングツールによる、ローカルのM5Camera動画ストリーミングを世界に公開する方法でした。

今回の検証で、ブラウザとのコールアンドレスポンスの具合がかなり分かってきたように思います。
Arduino core ESP32のhttpd関数でうまくいかない場合は、いろいろ融通の利くWiFiClient関数で組めば良いことが分かりました。
これで、Google Colaboratory上でM5Cameraのストリーミングも可能になります。
というか、もう既に実証済みです。

次回は、Google Colaboratory上でM5Camera映像のリアルタイム画像処理を紹介したいと思います。

ではまた…。

Amazon.co.jp 当ブログのおすすめ

スイッチサイエンス ESPr Developer 32 Type-C SSCI-063647
スイッチサイエンス
¥2,420(2024/04/24 02:58時点)
ZEROPLUS ロジックアナライザ LAP-C(16032)
ZEROPLUS
¥22,503(2024/04/23 12:37時点)
Excelでわかるディープラーニング超入門
技術評論社
¥2,068(2024/04/23 21:27時点)

コメント

タイトルとURLをコピーしました