ngrokによるBasic認証M5Cameraサーバーの映像をGoogle Colaboratoryに表示させてOpenCVで画像処理させてみた

Google colabにBasic認証有りのngrokを使い、M5Cameraストリーミング表示させ、OpenCVもつかってみた。 M5Stack

Google Colaboratory PythonおよびJavaScriptコード

では、前ページで紹介したように、ngrokのBasic認証下でCORSエラーを解消できたので、それを踏まえてGoogle Colaboratory側のコードを組んでみます。
めちゃめちゃ参考にしたサイトは@a2kiti さん作成の以下のサイトです。

Colab上でwebカメラをリアルタイムに処理

この記事はWebsocketを使った場合も記述してあって素晴らしかったです。
今回は取っ付きやすいコールバック関数を使った方法を参考にしてみました。
ただ、その記事ではBasic認証無しでしたので、自分流に改変してみました。

Google Colaboratory用コード

Jupyter Notebookの3つのセル連続してソースコードを紹介します。
なお、私はPythonもHTMLもJavaScriptも素人で独学なので、何か誤り等ありましたらコメント投稿で連絡いただけると助かります。

Jupyter Notebook セルNo.1

import IPython
from google.colab import output
import cv2
import numpy as np
from PIL import Image
from io import BytesIO
import base64

def run(img_str):
  #Base64文字列をデコード
  decimg = base64.b64decode(img_str.split(',')[1], validate=True)
  decimg = Image.open(BytesIO(decimg)) #PIL形式でメモリ上のファイルを開く
  decimg = np.array(decimg, dtype=np.uint8) #Open CVは基本的にNumPy配列
  decimg = cv2.cvtColor(decimg, cv2.COLOR_BGR2RGB) #BGR→RGB変換
  #Open CVの画像エッジ検出処理
  out_img = cv2.Canny(decimg,100,200)
  #JPEG形式にエンコード。最初のアンダースコアは、返り値のbool値を無視するという意味
  _, encimg = cv2.imencode(".jpg", out_img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
  img_str = encimg.tobytes() #NumPy配列をバイト配列に変換
  #Base64形式文字列に変換
  img_str = "data:image/jpeg;base64," + base64.b64encode(img_str).decode('utf-8')
  return IPython.display.JSON({'img_str': img_str})

output.register_callback('notebook.run', run)

Jupyter Notebook セルNo.2

from IPython.display import display, HTML

def useM5Camera():
  htm = HTML('''
    <style>.box{border: 1px solid};</style>
    <div>
      <img id='id_mjpeg' class='box' crossOrigin='use-credentials'>
      <canvas id='id_canvas1' class='box'></canvas>
      <canvas id='id_canvas2' class='box'></canvas>
    </div>
    <div>
      <button onclick='firstFetch()'>First Fetch</button>
      <button id='btn_start' onclick='startStream()'>Start</button>
      <button id='btn_stop' onclick='stopStream()'>Stop</button>
      <div>Exposure 
        <input type='range' id='aec_val' max='255' onchange='aecVal()'>
      </div>
    </div>
    <script type='text/javascript'>
      const url_ngrok = 'https://xxxxxxxxxx.jp.ngrok.io',//自分のngrokサーバーに書き換える
            url_mjpeg = url_ngrok + '/stream';
      //Basic認証のパスワードはブラウザで手入力する。

      let exit_flg = true;
      const img_width = 160, img_height = 120;
      const mjpeg = document.getElementById('id_mjpeg');
      mjpeg.width = img_width;
      mjpeg.height = img_height;

      const src_canvas = document.getElementById('id_canvas1'),
            src_canvasCtx = src_canvas.getContext('2d');
      src_canvas.width  = img_width;
      src_canvas.height = img_height;
      src_canvasCtx.translate(src_canvas.width, 0);
      src_canvasCtx.scale(-1, 1);

      const dst_canvas = document.getElementById('id_canvas2'),
            dst_canvasCtx = dst_canvas.getContext('2d');
      dst_canvas.width  = img_width;
      dst_canvas.height = img_height;

      const btn_start = document.getElementById('btn_start'),
            btn_stop = document.getElementById('btn_stop'),
            range_aec = document.getElementById('aec_val');
      btn_start.disabled = true;
      btn_stop.disabled = true;

      function firstFetch(){
        btn_start.disabled = false;
        btn_stop.disabled = false;
        exit_flg = false;
        changeCtrlCam('first',0);
      }

      async function startStream(){
        btn_start.disabled = true;
        exit_flg = true;
        let d = new Date();
        mjpeg.src = url_mjpeg + '?' + d.getTime();
        mjpeg.onload = useOpenCV();
      }

      function stopStream(){
        btn_start.disabled = false;
        exit_flg = false;
        changeCtrlCam('stop_stream',0);
      }

      function aecVal(){
        let aec_val = range_aec.value;
        changeCtrlCam('aec_val',aec_val);
      }

      async function changeCtrlCam(var_txt, value_txt){
        let ctrl_url = url_ngrok + '/control?var=';
        ctrl_url += var_txt + '&';
        ctrl_url += 'val=' + value_txt;
        await fetch(ctrl_url, {
          method: 'GET',
          credentials: 'include', //これ重要
          mode: 'cors'
        })
        .then(response => response.text())
        .then(data => console.log(data));
      }

      function useOpenCV() {
        const jpg_quality = 0.8;
        let canSendOpenCV = true,
            anime_req_id = 0;

        _canvasUpdate();
        function _canvasUpdate(){
          src_canvasCtx.drawImage(mjpeg, 0, 0, img_width, img_height, 0, 0, img_width, img_height);
          if(canSendOpenCV){
            const img_url = src_canvas.toDataURL('image/jpeg', jpg_quality);
            const result = google.colab.kernel.invokeFunction('notebook.run', [img_url], {});
            canSendOpenCV = false;

            result.then(function(value) {
              //console.log(value); //valueはJavaScriptオブジェクト
              const cv_img_url = value.data['application/json'].img_str;
              let image = new Image();
              image.src = cv_img_url;
              image.onload = function(){dst_canvasCtx.drawImage(image, 0, 0)};
              canSendOpenCV = true;
            });
          }
          if (exit_flg){
            anime_req_id = requestAnimationFrame(_canvasUpdate);   
          }else{
            cancelAnimationFrame(anime_req_id);
          }
        }
      }
    </script>
  ''')
  display(htm)

Jupyter Notebook セルNo.3

useM5Camera()

個人的解釈で解説

Jupyter Notebook セルNo.1解説

11行目でBase64形式画像文字列をデコードして、元のJPEGバイナリ形式にし、12行目のBytesIOで一旦メモリ内に格納し、それをImage.openでPIL形式(Pillow)に変換します。
たぶん、Image.openでJPEGファイル形式をデコードしてBGRのビットマップデータに変換しているかと思われます。(違っていたらスミマセン)
今まで私はC言語に慣れていた為、Pythonの画像操作はJPEGファイルをどこでデコードしてビットマップに変換しているのか全く見えないのです。そこが個人的に不満ですね。
そして、13行目でNumPy配列に変換しています。
OpenCVというのは、NumPy配列でやり取りしているそうです。
何でこんな面倒な方法になるのかは不明ですが、私が個人的にいろいろ試した結果、これが一番高速でした。imgeioライブラリを使った方法も試してみたのですが、フレームレートがガタ落ちでした。
PIL形式に変換しなければいけない意味がわかりませんが、ネットでいろいろ調べた結果、これが一番良いのだろうと個人的に思いました。

そして、16行目のCanny関数で画像のエッジ検出処理をしています。
第1引数は最小閾値、第2引数は最大閾値らしいです。
それについては、Cannyの公式ドキュメントを参照してください。

そして、18行目で、エッジ検出処理後の画像をJPEG形式にエンコードしてNumPy配列に変換します。
cv2.imencodeについての詳細は、こちらの公式ドキュメントにあります。
最初のアンダースコアは、imencode関数のbool返り値を無視するという意味らしいです。

19行目でNumPy配列からバイト型配列に変換しているらしいです(たぶん…)。

21行目でBase64文字列に変換し、22行目でJSON形式に埋め込んで24行目のregister_callbackへ返し、次のセルのgoogle.colab.kernel.invokeFunction関数の返り値resultへ送られるという流れなのではないかと思われます。
そのJSON形式は単なるJSONで返って来るわけではないのがややこしいのですが、これは後で説明します。

Jupyter Notebook セルNo.2解説

これは、前回記事で紹介したように、IPythonでHTMLおよびJavaScriptを動かすコードです。
ここには、useM5Camera関数が一つだけ定義されていて、それを実行させれば、Jupyter Notebook上にHTMLが表示され、JavaScriptが実行されます。
20行目では、ngrokのBasic認証有りで公開したURLに書き換えておきます。

ポイントは、まず、7行目のimg要素で、前章で説明したように
crossOrigin='use-credentials'
としていることです。
要するに、ブラウザに入力されたBasic認証のユーザー名やパスワード等の認証情報を使って通信せよということです。

そして、12行目のbutton要素で「First Fetch」ボタンを押すと48-53行のfirstFetch関数を実行して、78-82行のFetch APIでM5CameraサーバーへGETリクエストを送ります。
その時、先ほど説明したように、Basic認証の場合は、
credentials: 'include'
を設定しなければなりません。
つまり、ブラウザに入力されたBasic認証等の情報を含んでFetchせよということです。
先ほど述べたように、ここのheadersでユーザー名やパスワードを設定しても無駄です。
そして、81行目では、クロスオリジン(Google Colaboratoryがオリジン、M5Cameraサーバーは異なるオリジン)で通信をするため、
mode: 'cors'
とします。

スタートボタン、ストップボタンおよび自動露出制御(Auto Exposure Control: AEC)については、以前のこちらの記事こちらの記事と同様なので、そちらを参照してください。

もう一つのポイントは、92-114行のCanvasアニメーションループです。
HTMLのCanvasアニメーションは、こちらの記事でも紹介したように、requestAnimationFrameを使ってループさせます。
画面表示の一番右側の<canvas>要素には、中央の<canvas>要素の画像データURLを取得せねばなりませんが、それは96行目のtoDataURL関数で取得します。
実はこのtoDataURL関数がいろいろ面倒で、めちゃめちゃハマったところなのです。

以前、こちらの記事で紹介した時には、<img>要素に
crossOrigin=’anonymous’
という属性を設定していましたが、今回は先ほど述べたように、
crossOrigin='use-credentials'
としたことにより、URL取得できるようになったわけです。

ところで、toDataURLと同じ様な使い方で、
URL.createObjectURL
という関数があります。
URL.createObjectURL については、こちらの記事参照)
両者で何が違うのかよくわからなかったのですが、取得したURLをデベロッパーツールで観察していて、ようやく分かりました。

toDataURLは画像データをBase64エンコードした文字列、つまり画像データそのものの実体です。
文字列にするとかなり多量の文字です。
今回の場合、抜粋した画像1枚は2540文字くらいでした。160×120 pixelです。
たとえば、以下の様な感じです。

data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoK
………..(中略)………………………………………………
………..(中略)………………………………………………
………..(中略)………………………………………………
FSY6B7sFpl9hKNrD3hUoHTecO2TV4fYCyFPH12IJvJjLGr9NZWgFlGlvLkvhG0Fk9DsoDtxmUdALK9pr+gMxRyxCBY1YjZKBLDDVwEojEDN8JZnRqUregzN9Fj60BQIhCCpX4diEHnGQsOhYhAGvD3iEFuwghAdhBCAGcCEAMQhABACEA1cKHXEWVAQgtHCEILf/Z

 

まぁ、160×120 pixelのJPEG画像ならば、2,500byteくらいあるでしょうから、1バイトずつただ単に文字列に置き換えただけのようなものです。
そして、ここがポイントなのですが、これはブラウザのURL欄にそのままテキスト貼り付けしても画像が表示できるので、ファイルの実体といえども、URLであることには間違いないです。
ちょっとややこしいですね。

一方、URL.createObjectURLは、ファイルの実体ではなく、実体のある住所を返すというところが異なります。
今回の場合は、Google Colaboratoryサーバーのメモリ上のURLとなります。
例えば、こんな感じです。

blob:https://123abcdefg-123aaa456bbb789-1-colab.googleusercontent.com/abcd1234-5678-9101-1213-1234abcdefgh

このURLの示す場所にCanvasで表示したバイナリ形式のJPEG画像が保存されているというわけですね。
以前のこちらの記事では、ローカルネットワークだけでやっていたので意味がよく解らなかったのですが、今回はハッキリ違いが分かりましたね。こうなると、クロスオリジンだということが良くわかりますね。
このtoBlobのURL.createObjectURLで今回の実験を行うと、フレームレートがかなり遅くなりました。やはり、毎回Googleサーバーから画像を取得するからでしょうか?
結局、今回の場合はtoDataURLの方がファイルの実体をそのまま受け渡しするために、より速い処理だったということです。

さて、toDataURLで取得したJPEG画像を、97行目のgoogle.colab.kernel.invokeFunction関数でJupyter Notebookの第1セルで定義したrun関数へ送ります。
この関数はコールバック関数なので、処理が返って来るまで待つということをせずに、先へ進んでcanvasアニメーションを描画していきます。
このコールバック関数の処理のことをPromiseというらしい?のですが、JavaScript素人の私には難しくて説明できないので、ネット検索してください。

そして、アニメーションループをしている間にOpenCVによるエッジ検出処理が返ってきたら、100-107行の処理で、Base64文字列に変換されたJPEG画像データを右端のcanvasへ描画します。

ところで、コールバックで返ってきたresultのvalue変数は、console.logで出力してみると、こんな感じでした。

(図02)

JavaScriptオブジェクト

google.colab.kernel.invokeFunctionの返り値のvalue値はJavaScriptオブジェクト

こう見ると、value値は単なるJSONではないようです。
これはどうやら、JavaScriptオブジェクト? というらしいです。(間違えていたらスミマセン)
このapplication/jsonの中のimg_strだけを抽出できれば良いわけです。

先ほどの紹介で参考にさせて頂いたこちらの記事のソースコードでは、JSON.parseやらJSON.stringify関数を用いて3段階くらいかけて抽出していましたが、私がいろいろ試した結果、以下の一行で抽出できました。

const cv_img_url = value.data['application/json'].img_str;

どちらが正しいのか分かりませんが、結果オーライなので個人的にヨシとします。

Jupyter Notebook セルNo.3解説

これはただ単に「Jupyter Notebook セルNo.2」で定義した、useM5Camera関数を実行させているだけのセルです。

以上、Google Colaboratory側のポイントはこんなところです。

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

では、今度はM5Camera側のArduinoスケッチ(プログラムソースコード)です。
今回は、M5Camera側からHTMLを送信する必要が無いので、300行少々で済みました。
以下の感じです。

なお、SSIDやパスワードをソース内に入力しますが、M5Cameraが第三者に渡った場合、ソフトウェアによってSSIDやパスワードが簡単に抜き取られる可能性があることに十分注意してください。

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

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

static String url_origin = "";

#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("control?var=first")){
          respFirstFetch(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 getOriginUrl(WiFiClient &client){
  String req_str = "";
  bool isOriginUrl = false;
  while(client.available()){
    char c = client.read();
    req_str += c;
    if(c == '\n'){
      Serial.print(req_str);
      int from = req_str.indexOf("Origin: ");
      if(from >= 0) {
        int to = req_str.length();
        url_origin = req_str.substring(from + 8, to - 2);
        isOriginUrl = true;
      }
      req_str = "";
    }
    delay(1);
  }
  if(isOriginUrl){
    Serial.printf("-----Origin URL Get!-----\r\n%s\r\n", url_origin.c_str());
  }
}
//****************************************
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 respFirstFetch(WiFiClient &client){
  getOriginUrl(client);
  String resp_head_str = commonRespHeadStr();
         resp_head_str += "Connection: keep-alive\r\n\r\n";
  client.print(resp_head_str);
}
//****************************************
void respHeadMJPEG(WiFiClient &client){
  Serial.println();
  recvHttpReq(client);
  String resp_head_str = commonRespHeadStr();
         resp_head_str += "Content-Type: multipart/x-mixed-replace; boundary=--myboundary\r\n";
         resp_head_str += "Connection: keep-alive\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 += "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);
  String resp_head_str = commonRespHeadStr();
         resp_head_str += "Connection: close\r\n\r\n";
  client.print(resp_head_str);
}
//****************************************
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);
  }
  String resp_head_str = commonRespHeadStr();
         resp_head_str += "Connection: close\r\n\r\n";
  client.print(resp_head_str);
}
//****************************************
String commonRespHeadStr(){
  String resp_head_str = "HTTP/1.1 200 OK\r\n";
        resp_head_str += "Access-Control-Allow-Credentials: true\r\n";
        resp_head_str += "Access-Control-Allow-Origin: " + url_origin + "\r\n";
  return resp_head_str;
}
//****************************************
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;
  }
}

【簡単な解説】

基本的に以下の記事のArduinoスケッチをベースとしています。
M5CameraとngrokでMotion JPEG動画を遠隔ストリーミングする実験
重複しているところは割愛します。

4-5行目のSSIDとパスワードをご自分のWi-Fi環境用に書き換えます。

9-24行のGPIO設定は、M5Cameraのモデルによって変えてください。

71-80行でM5CameraのイメージセンサOV2640の設定をしています。
160×120 pixelのQQVGAで出力しています。
露出設定やホワイトバランス等の設定は以下の記事を参照してください。
esp32-cameraライブラリを読み解く ~モジュール接続、動作チェック編~

ここでの大きなポイントは、前章で説明したように、Google ColaboratoryからBasic認証付きのGETリクエストが送られて来たら、以下の2つのレスポンスヘッダを加えて送信することです。

Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://xxxx-xxxx-x-colab.googleusercontent.com

277-282行にあります。

Access-Control-Allow-OriginのURLは、Google Colaboratoryの「First Fetch」ボタン押下によるGETリクエストを受信した時に、リクエストヘッダの「Origin:」部分を検知して、そのURLを抽出しています。167-188行のところです。
そのURLが、自分のGoogle ColaboratoryのURL、つまりオリジンURLとなるわけです。

以上、今回のArduinoスケッチのポイントはそんなところです。
その他、Motion JPEGストリーミングや、イメージセンサOV2640等については、過去記事を参照してください。

コンパイル書き込み実行、およびランタイム実行

では、Wi-Fiルーターを起動し、M5Cameraが接続できる状態にしておきます。
Arduino IDEでシリアルモニタを115200bpsで起動し、上記のスケッチをコンパイル書き込み実行します。
Arduino IDEのボード設定は以下でOKです。

ボード:  ESP32 Dev Module
Upload Speed:  921600
CPU Frequency:  240MHz (WiFi/BT)
Flash Frequency:  80MHz
Flash Mode:  QIO
Flash Size:  4MB (32Mb)
Partition Scheme:  Default 4MB width spiffs (1.2MB APP/1.5MB SPIFFS)
Core Debug Level:  なし
PSRAM:  Disabled
シリアルポート:  ※ESP32ボードに接続してあるUSBポート

 

すると、下図の様にローカルIPアドレスが表示されます。

(図10)

Arduino IDEシリアルモニタ

Arduinoスケッチコンパイル後のシリアルモニタ結果

それから、ngrokを使ってBasic認証有りの公開URLを取得します。
ngrokの使い方等は以前のこちらの記事を参照してください。

その後、ngrokで発行されたURLで、Google ColaboratoryのJupyter Notebook セルNo.2の20行目のURLを書き換えます。

その後、Google Colaboratoryのランタイムで、「すべてのセルを実行」をクリックします。
そして、「First Fetch」ボタンを押して、「Start」を押します。
すると、下図のように表示されればOKです。

(図11)

Google Colaboratoryの実行表示

Google Colaboratoryのランタイム実行結果

必ず、「First Fetch」ボタンを最初に押します。
すると、Google ColaboratoryのオリジンURLが取得でき、CORSポリシーOKとなります。

Exposureの露出調整は、正確にはイメージセンサOV2640のAuto Exposure Control(AEC)調整です。つまり、マニュアル露出設定というよりは、自動露出制御のパラメータ設定になります。

あとは、最初に紹介した動画のように表示されればOKです。
これで、Google ColaboratoryとESP32およびM5Cameraとの連携ができるようになりました。
やったね!

まとめ

以上、Google Colaboratory上でM5Cameraの映像をストリーミングさせて、OpenCVでリアルタイム画像処理させる方法でした。

今回の個人的自慢は、ngrokのBasic認証有りでもCORSエラーを解消させてGoogle Colaboratory上で動作させることができたことです。
セキュリティが少しでも高い状態で扱うことができるようになって良かったと思います。
おかげで、CORSとかプリフライトリクエストとか、Basic認証に関する知識を少しでも深めることが出来ました。

それにしても、今回の実験でわかったことは、ブラウザというものは先人の知恵の塊なんだなぁ~と、改めて実感した次第です。
これでもまだブラウザの機能のほんの一部分しか触れていませんが、それだけで圧倒されまくりでした。
知れば知るほど「何も分からん」状態に陥りそうなので、深入りはほどほどにしときます。

ということで、今回はここまでです。
ではまた…。

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

スイッチサイエンス ESPr Developer 32 Type-C SSCI-063647
スイッチサイエンス
¥2,420(2024/03/29 13:47時点)
ZEROPLUS ロジックアナライザ LAP-C(16032)
ZEROPLUS
¥22,504(2024/03/29 21:27時点)
Excelでわかるディープラーニング超入門
技術評論社
¥1,700(2024/03/29 10:12時点)

コメント

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