M5CameraのMotion JPEG動画からJavaScriptでスナップショット静止画を取得してみた

M5CameraのMJPEG動画からJavaScriptでスナップショット画像を撮る実験 M5Stack

6.M5Camera動画から複数個のスナップショット画像を取得するHTMLおよびJavaScriptプログラミング(ディープラーニングの学習用データセットに使える)

では、前章をちょっと発展させて、ディープラーニングの学習用データセットを作成するための、HTMLおよびJavaScriptを組んでみます。

先ほど説明した、toBlobメソッドを使ったスナップショット撮影を、10個に増設しました。

それと、M5CameraのイメージセンサOV2640の露出コントロールができるようにしました。

6-01. M5Cameraサーバー側のArduinoスケッチ(プログラムソースコード)(HTMLとJavaScript無し)

まず、M5Cameraサーバー側のArduinoスケッチ(プログラムソースコード)を紹介します。
ここでは、HTMLとJavaScriptは入力していません。
(HTMLとJavaScriptを同梱するスケッチは後ほど紹介します。)

(※ここに入力したSSIDとパスワードは、あるソフトウェアで簡単に抜き取ることができます。M5Cameraは第三者に渡さないようご注意ください。)

/* This is a program modified by mgo-tec from the esp32-camera library and CameraWebServer sketch.
 * Use Arduino core for the ESP32 stable v1.0.5-1.0.6
 *  
 * Modify app_httpd.cpp(Arduino core for the ESP32 v1.0.6).
 * Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD
 * app_httpd.cpp - Licensed under the Apache License, Version 2.0
 *     http://www.apache.org/licenses/LICENSE-2.0
 *     
 * esp32-camera library ( Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD)
 *  Licensed under the Apache License, Version 2.0 (the "License").
 *  URL:https://github.com/espressif/esp32-camera
 */
#include <WiFi.h>
#include <esp_camera.h>
#include <esp_http_server.h>

const char* ssid = "xxxxxxxxx"; //ご自分のルーターのSSIDに書き換えてください
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

httpd_handle_t stream_httpd = NULL;
httpd_handle_t camera_httpd = NULL;

static bool canStartStream = false;
static bool canSendImage = false;
static bool isWiFiConnected = false;
static bool isCloseConnection = false;

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_QQVGA; //160x120 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_hmirror(sensor, 1); //M5Cameraのモデルによって変更
  sensor->set_vflip(sensor, 1); //M5Cameraのモデルによって変更
  sensor->set_exposure_ctrl(sensor, 1); //sensor露出制御ON
  sensor->set_aec2(sensor, 1); //DSP自動露出制御ON
  sensor->set_ae_level(sensor, 0); //-2~2
  sensor->set_aec_value(sensor, 0); //0~1200(実質255までで充分)
  sensor->set_whitebal(sensor, 1); //ホワイトバランスON
  sensor->set_awb_gain(sensor, 1); //自動ホワイトバランスゲインON

  connectToWiFi();
  while(!isWiFiConnected){
    delay(1);
  }
  startHttpd();
}

void loop() {
}

//****************************************
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;
  }
}
//****************************************
void startHttpd(){
  httpd_config_t config = HTTPD_DEFAULT_CONFIG();

  httpd_uri_t cmd_uri = {
        .uri       = "/control",
        .method    = HTTP_GET,
        .handler   = cmd_handler,
        .user_ctx  = NULL
    };

  httpd_uri_t stream_uri = {
      .uri       = "/stream",
      .method    = HTTP_GET,
      .handler   = stream_handler,
      .user_ctx  = NULL
  };

  if (httpd_start(&camera_httpd, &config) == ESP_OK) {
    httpd_register_uri_handler(camera_httpd, &cmd_uri);
  }

  config.server_port += 1;
  config.ctrl_port += 1;

  if (httpd_start(&stream_httpd, &config) == ESP_OK) {
    httpd_register_uri_handler(stream_httpd, &stream_uri);
  }
}

//****************************************
static esp_err_t cmd_handler(httpd_req_t *req){
  char*  buf;
  size_t buf_len;
  char id_txt[32] = {0};
  char value_txt[32] = {0};

  buf_len = httpd_req_get_url_query_len(req) + 1;
  if (buf_len > 1) {
    buf = (char*)malloc(buf_len);
    if(!buf){
      httpd_resp_send_500(req);
      return ESP_FAIL;
    }
    if (httpd_req_get_url_query_str(req, buf, buf_len) == ESP_OK) {
      //Serial.println("-----Receive Control Command");
      Serial.println(buf);
      if (httpd_query_key_value(buf, "var", id_txt, sizeof(id_txt)) == ESP_OK &&
        httpd_query_key_value(buf, "val", value_txt, sizeof(value_txt)) == ESP_OK) {
      } else {
        free(buf);
        httpd_resp_send_404(req);
        return ESP_FAIL;
      }
    } else {
      Serial.println(buf);
      free(buf);
      httpd_resp_send_404(req);
      return ESP_FAIL;
    }
    free(buf);
  } else {
    httpd_resp_send_404(req);
    return ESP_FAIL;
  }

  int16_t val = atoi(value_txt);
  int res = 0;
  sensor_t *sensor = esp_camera_sensor_get();
  if(!strcmp(id_txt, "start_stream")){
    canSendImage = false;
    canStartStream = true;
    isCloseConnection = false;
  }else if(!strcmp(id_txt, "stop_stream")){
    canStartStream = false;
    isCloseConnection = true;
  }else if(!strcmp(id_txt, "aec_sens")){
    sensor->set_exposure_ctrl(sensor, val);
  }else if(!strcmp(id_txt, "aec_dsp")){
    sensor->set_aec2(sensor, val);
  }else if(!strcmp(id_txt, "ae_level")){
    sensor->set_ae_level(sensor, val);
  }else if(!strcmp(id_txt, "aec_val")){
    sensor->set_aec_value(sensor, val);
  }else{
    res = -1;
  }

  if(res){
    return httpd_resp_send_500(req);
  }

  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  return httpd_resp_send(req, NULL, 0);
}
//****************************************
static esp_err_t stream_handler(httpd_req_t *req){
  const char *stream_content_type = "multipart/x-mixed-replace;boundary=--myboundary";
  const char *stream_boundary = "\r\n--myboundary\r\n";
  const char *stream_part = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";

  camera_fb_t * fb = NULL;
  esp_err_t res = ESP_OK;
  char part_buf[64];
  uint8_t * jpg_buf = NULL;
  size_t jpg_buf_len = 0;

  res = httpd_resp_set_type(req, stream_content_type);
  if(res != ESP_OK){
    return res;
  }
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");

  while(true){
    if(isCloseConnection){
      Serial.println("Loop Out Streaming!");
      break;
    }

    fb = esp_camera_fb_get(); //イメージセンサOV2640から画像取得
    if (!fb) {
        Serial.println("Camera capture failed");
        res = ESP_FAIL;
    } else {
      jpg_buf = fb->buf;
      jpg_buf_len = fb->len;
      if(res == ESP_OK){
        res = httpd_resp_send_chunk(req, stream_boundary, strlen(stream_boundary));
      }else{
        Serial.printf("res3=%d\r\n", res);
      }
      if(res == ESP_OK){
        size_t hlen = snprintf((char *)part_buf, 64, stream_part, jpg_buf_len);
        res = httpd_resp_send_chunk(req, (const char *)part_buf, hlen);
      }else{
        Serial.printf("res1=%d\r\n", res);
        continue;
      }
      if(res == ESP_OK){
        if(jpg_buf_len){
          res = httpd_resp_send_chunk(req, (const char *)jpg_buf, jpg_buf_len);
        }else{
          Serial.println("Failed jpg_buf_len");
        }
      }else{
        Serial.printf("res2=%d\r\n", res);
      }

      if(fb){ //fbを使い回すために必要らしい
        esp_camera_fb_return(fb);
        fb = NULL;
        jpg_buf = NULL;
      } else if(jpg_buf){
        free(jpg_buf);
        jpg_buf = NULL;
      }
      res = ESP_OK;
    }
  }
  return res;
}

6-02. ブラウザ(クライアント)側のHTMLおよびJavaScriptコード

次に、パソコンのブラウザ側のHTMLおよびJavaScriptコードを紹介します。
90行目のbase_urlを、ご自分のM5CameraのURLに書き換えます。
スナップショットの要素の属性には、JavaScriptで動的に属性を付与しています。

<html>
<head>
  <meta name='viewport' content='width=device-width, initial-scale=1' charset='utf-8'>
  <style>
    .img-wh{border:1px solid;}
    .split{position:absolute;}
    .top{top:0;}
    .bottom{overflow-y:scroll; height:60%;}
    .div-in-blk{display:inline-block;}
    .div-bd-btm{padding:0 0 10px 0;border-bottom:groove 10px;}
    .sens-dsp{border:1px solid;padding:5px;}
    button{border-radius:25px;}
  </style>
</head>
<body>
  <div id='split-top' class='split top div-bd-btm'>
    <div class='div-in-blk'>
      <span id='disp_img' style='display:block;'>MJPEG img<br>
        <img id='img_mjpeg' class='img-wh' crossOrigin='anonymous' >
      </span>
    </div>
    <div class='div-in-blk'>
      Canvas(160x120)<br>
      <canvas id='canvas' class='img-wh'></canvas>
    </div>
    <div>
      <button id='btn_disp_img' onclick='displayImg();'>img display OFF</button>
      <p><button id='btn_start' onclick='startStream();'>Start Stream</button>
      <button onclick='stopStream();'>Stop Stream</button>
      <button id='btn_w_stop' onclick='window.stop();'>Window Stop</button></p>
      <div class='sens-dsp'>
        <div>
          <button id='aec_sens' onclick='aecSens();'>AEC Sensor OFF</button>
          <button id='aec_dsp' onclick='aecDsp();'>AEC DSP OFF</button>
        </div>
        <div>
          <label for='ae_level'>AEC level: <span id='ae_level_txt'>0</span></label><br>
          <input type="range" id='ae_level' min='-2' max='2' value='0' onchange='aeLevel();'>
        </div>
      </div>
      <p></p>
      <div class='sens-dsp'>
        <label for='aec_val'>Manual Exposure value: <span id='aec_val_txt'>0</span></label><br>
        <!-- aec_valueは本来0~1200まで可変だが、255までで充分 --> 
        <input type="range" id='aec_val' min='0' max='255' value='0' onchange='aecVal();'>
      </div>
      <p></p>
    </div>
  </div>
  <div class='split bottom' id='split-btm'>
    <div>
      <div id='div_snap0'>
        <button id='btn_snap0'></button>&nbsp;<a id='a0'></a><br><img id='img_snap0'>
      </div>
      <div id='div_snap1'>
        <button id='btn_snap1'></button>&nbsp;<a id='a1'></a><br><img id='img_snap1'>
      </div>
      <div id='div_snap2'>
        <button id='btn_snap2'></button>&nbsp;<a id='a2'></a><br><img id='img_snap2'>
      </div>
      <div id='div_snap3'>
        <button id='btn_snap3'></button>&nbsp;<a id='a3'></a><br><img id='img_snap3'>
      </div>
      <div id='div_snap4'>
        <button id='btn_snap4'></button>&nbsp;<a id='a4'></a><br><img id='img_snap4'>
      </div>
    </div>
    <div>
      <div id='div_snap5'>
        <button id='btn_snap5'></button>&nbsp;<a id='a5'></a><br><img id='img_snap5'>
      </div>
      <div id='div_snap6'>
        <button id='btn_snap6'></button>&nbsp;<a id='a6'></a><br><img id='img_snap6'>
      </div>
      <div id='div_snap7'>
        <button id='btn_snap7'></button>&nbsp;<a id='a7'></a><br><img id='img_snap7'>
      </div>
      <div id='div_snap8'>
        <button id='btn_snap8'></button>&nbsp;<a id='a8'></a><br><img id='img_snap8'>
      </div>
      <div id='div_snap9'>
        <button id='btn_snap9'></button>&nbsp;<a id='a9'></a><br><img id='img_snap9'>
      </div>
    </div>
  </div>
  <script>
    var requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame ||
                                window.webkitRequestAnimationFrame || window.msRequestAnimationFrame,
        cancelAnimationFrame = window.cancelAnimationFrame || window.mozCancelAnimationFrame;
    var base_url = 'http://192.168.0.xxx', //document.location.origin,
        stream_port = 81,
        mjpeg_url = base_url + ':' + stream_port + '/stream',
        img_width = 160, img_height = 120,
        anime_req_id = 0,
        mjpeg = document.getElementById('img_mjpeg'),
        canvas = document.getElementById('canvas'),
        ctx = canvas.getContext('2d');
    var btn_disp_img = document.getElementById('btn_disp_img'),
        btn_start = document.getElementById('btn_start'),
        btn_stop = document.getElementById('btn_stop'),
        split_top = document.getElementById('split-top'),
        split_btm = document.getElementById('split-btm');
    var aec = 1,
        aec_dsp = 1,
        disp_img = document.getElementById('disp_img'),
        aec_elm = document.getElementById('aec_sens'),
        aec_dsp_elm = document.getElementById('aec_dsp'),
        ae_level_elm = document.getElementById('ae_level'),
        ae_level_txt_elm = document.getElementById('ae_level_txt'),
        aec_val_elm = document.getElementById('aec_val'),
        aec_val_txt_elm = document.getElementById('aec_val_txt');
    var blob_url = new Array(10);

    mjpeg.width = img_width;
    mjpeg.height = img_height;
    canvas.width = img_width; //canvasでこれを設定しないとデフォルトで300x150となるので注意。
    canvas.height = img_height;
    split_btm.style.top = split_top.clientHeight + 30;
    setSnapElmAttribute(0, 10); //スナップショット各要素に属性を付加

    function setSnapElmAttribute(start, end){
      for(let step = start; step < end; step++){
        let div_id_str = 'div_snap' + step,
            btn_id_str = 'btn_snap' + step,
            a_id_str = 'a' + step,
            img_id_str = 'img_snap' + step,
            snapshot_str = 'snapShot(' + step + ')',
            download_file_name = 'dataset' + step + '.jpg',
            div_elm = document.getElementById(div_id_str),
            button_elm = document.getElementById(btn_id_str),
            a_elm = document.getElementById(a_id_str),
            img_elm = document.getElementById(img_id_str);

        div_elm.setAttribute('class',  'div-in-blk div-bd-btm');
        button_elm.setAttribute('onclick', snapshot_str);
        button_elm.innerHTML = 'SnapShot' + step;
        a_elm.setAttribute('download', download_file_name);
        a_elm.innerHTML = 'save ' + step;
        img_elm.setAttribute('class', 'img-wh');
        img_elm.width = img_width;
        img_elm.height = img_height;
      }
    }

    function displayImg(){
      let disp = disp_img.style.display;
      if(disp === 'none'){
        disp_img.style.display = 'block';
        btn_disp_img.innerHTML = 'img display OFF';
      }else{
        disp_img.style.display = 'none';
        btn_disp_img.innerHTML = 'img display ON';
      }
      split_btm.style.top = split_top.clientHeight + 30;
    }

    function startStream(){
      btn_start.disabled = true; //スタートボタンが連続で押されて、anime_req_idが更新されるのを防ぐ。
      btn_w_stop.disabled = true; //(stream stop)ボタンを押さないと(window stop)ボタンは使えない。
      changeCtrlCam('start_stream',0); //img要素のsrc変更の前に、ポート80番でGetリクエストを送ることが重要
      //srcが前回接続時と同じだと再スタートできないため、時刻をURLに入れて毎回異なるアドレスにする。
      let d = new Date(); //毎回異なるURLにするために必要
      mjpeg.src = mjpeg_url + '?' + d.getTime();
      loopAnime();
    }

    function loopAnime(timestamp){
      //console.log(timestamp);
      ctx.drawImage(mjpeg, 0, 0);
      anime_req_id = requestAnimationFrame(loopAnime); //max 60fps
    }

    function stopStream(){
      btn_start.disabled = false;
      btn_w_stop.disabled = false;
      cancelAnimationFrame(anime_req_id);
      changeCtrlCam("stop_stream",0);
    }

    function aecSens(){
      if(aec === 1){aec = 0;aec_elm.innerHTML='AEC Sens ON';}
      else{aec = 1;aec_elm.innerHTML='AEC Sens OFF';}
      changeCtrlCam("aec_sens",aec);
    }

    function aecDsp(){
      if(aec_dsp === 1){aec_dsp = 0;aec_dsp_elm.innerHTML='AEC DSP ON';}
      else{aec_dsp = 1;aec_dsp_elm.innerHTML='AEC DSP OFF';}
      changeCtrlCam("aec_dsp",aec_dsp);
    }

    function aeLevel(){
      let ae_level = ae_level_elm.value;
      changeCtrlCam("ae_level",ae_level);
      ae_level_txt_elm.innerHTML = ae_level;
    }

    function aecVal(){
      let aec_val = aec_val_elm.value;
      changeCtrlCam("aec_val",aec_val);
      aec_val_txt_elm.innerHTML = aec_val;
    }

    function changeCtrlCam(id_txt, value_txt){
      let ctrl_url = base_url + '/control?var=';
      ctrl_url += id_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));
    }

    function snapShot(num){
      let img_id_str = 'img_snap' + num,
          a_id_str = 'a' + num,
          img_elm = document.getElementById(img_id_str),
          a_elm = document.getElementById(a_id_str);
      URL.revokeObjectURL(blob_url[num]); //前にメモリに保存されたBlobのurlを削除して解放できるようにする
      canvas.toBlob(function(blob) {
        blob_url[num] = URL.createObjectURL(blob);
        a_elm.href = blob_url[num];
        img_elm.src = blob_url[num];
      }, 'image/jpeg'); //"image/jpeg"と記述しなければデフォルトのpng画像となる
    }
  </script>
</body>
</html>

では、これでパソコンのHTMLファイルをGoogle Chromeで起動し、最初に紹介した動画のように表示されればOKです。
これはブラウザのURL欄には何も入力せず、いきなり「Start Stream」ボタンを押せばストリーミングがスタートします。

6-03. ArduinoスケッチにHTMLおよびJavaScriptコードを埋め込む

では、htmlファイルを別途用意するのが面倒な場合は、HTMLおよびJavaScriptコードをArduinoスケッチに埋め込んでみます。

こうすると、最初に紹介した動画のように、ブラウザのURLにM5CameraのIPアドレスだけ入力するだけで動作します。
htmlファイルを用意する必要はありません。

ただし、デメリットはHTMLとJavaScriptを修正する度にArduino IDEでコンパイルし直さなければいけないところが難点ですね。

Arduino core for the ESP32 では、SPIFFSファイルシステムに組み込んだり、PROGMEMに組み込んだりする方法がありますが、個人的に一番簡単なのは、Arduinoスケッチ内にString文字列として組み込んでしまう方法だと思います。

これはおそらくプログラミング熟練者の方々からは嫌われる方法で、メモリもかなり消費します。
ですが、最初のアクセスが終わったらメモリ解放しますし、趣味程度ならば私個人としてはこの方法が一番手軽なので良いのかなと思っています。
先ほどのHTMLおよびJavaScriptコードをそのままString文字列に埋め込んだので、一つの変数でかなりのバイト数になっていますが、コンパイルエラーも出ないのでヨシとしています。
最近知ったC++11の「生文字リテラル」という、String文字列をR”(…)”で囲う手法は、個人的にとっても重宝しています。
HTMLおよびJavaScriptのエスケープ文字を使わなくて済むので、そのままString文字列に組み込むことができます。

(※ここに入力したSSIDとパスワードは、あるソフトウェアで簡単に抜き取ることができます。M5Cameraは第三者に渡さないようご注意ください。)

/* This is a program modified by mgo-tec from the esp32-camera library and CameraWebServer sketch.
 * 
 * The MIT License (MIT)
 * License URL: https://opensource.org/licenses/mit-license.php
 * Copyright (c) 2021 Mgo-tec. All rights reserved.
 * 
 * Use Arduino core for the ESP32 stable v1.0.5-1.0.6
 *  
 * Modify app_httpd.cpp(Arduino core for the ESP32 v1.0.6).
 * Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD
 * app_httpd.cpp - Licensed under the Apache License, Version 2.0
 *     http://www.apache.org/licenses/LICENSE-2.0
 *     
 * esp32-camera library ( Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD)
 *  Licensed under the Apache License, Version 2.0 (the "License").
 *  URL:https://github.com/espressif/esp32-camera
 */
#include <WiFi.h>
#include <esp_camera.h>
#include <esp_http_server.h>

const char* ssid = "xxxxxxxxx"; //ご自分のルーターのSSIDに書き換えてください
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

httpd_handle_t stream_httpd = NULL;
httpd_handle_t camera_httpd = NULL;

static bool canStartStream = false;
static bool canSendImage = false;
static bool isWiFiConnected = false;
static bool isCloseConnection = false;

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); //160x120pix フレームレートを上げるための設定
  sensor->set_hmirror(sensor, 1); //M5Cameraのモデルによって変更
  sensor->set_vflip(sensor, 1); //M5Cameraのモデルによって変更
  sensor->set_exposure_ctrl(sensor, 1); //sensor露出制御ON
  sensor->set_aec2(sensor, 1); //DSP自動露出制御ON
  sensor->set_ae_level(sensor, 0); //-2~2
  sensor->set_aec_value(sensor, 0); //0~1200(実質255までで充分)
  sensor->set_whitebal(sensor, 1); //ホワイトバランスON
  sensor->set_awb_gain(sensor, 1); //自動ホワイトバランスゲインON

  connectToWiFi();
  while(!isWiFiConnected){
    delay(1);
  }
  startHttpd();
}

void loop() {
}

//****************************************
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;
  }
}
//****************************************
void startHttpd(){
  httpd_config_t config = HTTPD_DEFAULT_CONFIG();

  httpd_uri_t index_uri = {
        .uri       = "/",
        .method    = HTTP_GET,
        .handler   = index_handler,
        .user_ctx  = NULL
    };

  httpd_uri_t cmd_uri = {
        .uri       = "/control",
        .method    = HTTP_GET,
        .handler   = cmd_handler,
        .user_ctx  = NULL
    };

  httpd_uri_t stream_uri = {
      .uri       = "/stream",
      .method    = HTTP_GET,
      .handler   = stream_handler,
      .user_ctx  = NULL
  };

  if (httpd_start(&camera_httpd, &config) == ESP_OK) {
    httpd_register_uri_handler(camera_httpd, &index_uri);
    httpd_register_uri_handler(camera_httpd, &cmd_uri);
  }

  config.server_port += 1;
  config.ctrl_port += 1;

  if (httpd_start(&stream_httpd, &config) == ESP_OK) {
    httpd_register_uri_handler(stream_httpd, &stream_uri);
  }
}
//****************************************
static esp_err_t index_handler(httpd_req_t *req){
  //以下のJavaScriptは、Windows10とAndroid(Chrome89),iOS12.5.2で動作確認済。FireFoxは不可。
  String html_body = R"(
<html>
<head>
  <meta name='viewport' content='width=device-width, initial-scale=1' charset='utf-8'>
  <style>
    .img-wh{border:1px solid;}
    .split{position:absolute;}
    .top{top:0;}
    .bottom{overflow-y:scroll; height:60%;}
    .div-in-blk{display:inline-block;}
    .div-bd-btm{padding:0 0 10px 0;border-bottom:groove 10px;}
    .sens-dsp{border:1px solid;padding:5px;}
    button{border-radius:25px;}
  </style>
</head>
<body>
  <div id='split-top' class='split top div-bd-btm'>
    <div class='div-in-blk'>
      <span id='disp_img' style='display:block;'>MJPEG img<br>
        <img id='img_mjpeg' class='img-wh' crossOrigin='anonymous' >
      </span>
    </div>
    <div class='div-in-blk'>
      Canvas(160x120)<br>
      <canvas id='canvas' class='img-wh'></canvas>
    </div>
    <div>
      <button id='btn_disp_img' onclick='displayImg();'>img display OFF</button>
      <p><button id='btn_start' onclick='startStream();'>Start Stream</button>
      <button onclick='stopStream();'>Stop Stream</button>
      <button id='btn_w_stop' onclick='window.stop();'>Window Stop</button></p>
      <div class='sens-dsp'>
        <div>
          <button id='aec_sens' onclick='aecSens();'>AEC Sensor OFF</button>
          <button id='aec_dsp' onclick='aecDsp();'>AEC DSP OFF</button>
        </div>
        <div>
          <label for='ae_level'>AEC level: <span id='ae_level_txt'>0</span></label><br>
          <input type="range" id='ae_level' min='-2' max='2' value='0' onchange='aeLevel();'>
        </div>
      </div>
      <p></p>
      <div class='sens-dsp'>
        <label for='aec_val'>Manual Exposure value: <span id='aec_val_txt'>0</span></label><br>
        <!-- aec_valueは本来0~1200まで可変だが、255までで充分 --> 
        <input type="range" id='aec_val' min='0' max='255' value='0' onchange='aecVal();'>
      </div>
      <p></p>
    </div>
  </div>
  <div class='split bottom' id='split-btm'>
    <div>
      <div id='div_snap0'>
        <button id='btn_snap0'></button>&nbsp;<a id='a0'></a><br><img id='img_snap0'>
      </div>
      <div id='div_snap1'>
        <button id='btn_snap1'></button>&nbsp;<a id='a1'></a><br><img id='img_snap1'>
      </div>
      <div id='div_snap2'>
        <button id='btn_snap2'></button>&nbsp;<a id='a2'></a><br><img id='img_snap2'>
      </div>
      <div id='div_snap3'>
        <button id='btn_snap3'></button>&nbsp;<a id='a3'></a><br><img id='img_snap3'>
      </div>
      <div id='div_snap4'>
        <button id='btn_snap4'></button>&nbsp;<a id='a4'></a><br><img id='img_snap4'>
      </div>
    </div>
    <div>
      <div id='div_snap5'>
        <button id='btn_snap5'></button>&nbsp;<a id='a5'></a><br><img id='img_snap5'>
      </div>
      <div id='div_snap6'>
        <button id='btn_snap6'></button>&nbsp;<a id='a6'></a><br><img id='img_snap6'>
      </div>
      <div id='div_snap7'>
        <button id='btn_snap7'></button>&nbsp;<a id='a7'></a><br><img id='img_snap7'>
      </div>
      <div id='div_snap8'>
        <button id='btn_snap8'></button>&nbsp;<a id='a8'></a><br><img id='img_snap8'>
      </div>
      <div id='div_snap9'>
        <button id='btn_snap9'></button>&nbsp;<a id='a9'></a><br><img id='img_snap9'>
      </div>
    </div>
  </div>
  <script>
    var requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame ||
                                window.webkitRequestAnimationFrame || window.msRequestAnimationFrame,
        cancelAnimationFrame = window.cancelAnimationFrame || window.mozCancelAnimationFrame;
    var base_url = document.location.origin,
        stream_port = 81,
        mjpeg_url = base_url + ':' + stream_port + '/stream',
        img_width = 160, img_height = 120,
        anime_req_id = 0,
        mjpeg = document.getElementById('img_mjpeg'),
        canvas = document.getElementById('canvas'),
        ctx = canvas.getContext('2d');
    var btn_disp_img = document.getElementById('btn_disp_img'),
        btn_start = document.getElementById('btn_start'),
        btn_stop = document.getElementById('btn_stop'),
        split_top = document.getElementById('split-top'),
        split_btm = document.getElementById('split-btm');
    var aec = 1,
        aec_dsp = 1,
        disp_img = document.getElementById('disp_img'),
        aec_elm = document.getElementById('aec_sens'),
        aec_dsp_elm = document.getElementById('aec_dsp'),
        ae_level_elm = document.getElementById('ae_level'),
        ae_level_txt_elm = document.getElementById('ae_level_txt'),
        aec_val_elm = document.getElementById('aec_val'),
        aec_val_txt_elm = document.getElementById('aec_val_txt');
    var blob_url = new Array(10);

    mjpeg.width = img_width;
    mjpeg.height = img_height;
    canvas.width = img_width;
    canvas.height = img_height;
    split_btm.style.top = split_top.clientHeight + 30;
    setSnapElmAttribute(0, 10);

    function setSnapElmAttribute(start, end){
      for(let step = start; step < end; step++){
        let div_id_str = 'div_snap' + step,
            btn_id_str = 'btn_snap' + step,
            a_id_str = 'a' + step,
            img_id_str = 'img_snap' + step,
            snapshot_str = 'snapShot(' + step + ')',
            download_file_name = 'dataset' + step + '.jpg',
            div_elm = document.getElementById(div_id_str),
            button_elm = document.getElementById(btn_id_str),
            a_elm = document.getElementById(a_id_str),
            img_elm = document.getElementById(img_id_str);

        div_elm.setAttribute('class',  'div-in-blk div-bd-btm');
        button_elm.setAttribute('onclick', snapshot_str);
        button_elm.innerHTML = 'SnapShot' + step;
        a_elm.setAttribute('download', download_file_name);
        a_elm.innerHTML = 'save ' + step;
        img_elm.setAttribute('class', 'img-wh');
        img_elm.width = img_width;
        img_elm.height = img_height;
      }
    }

    function displayImg(){
      let disp = disp_img.style.display;
      if(disp === 'none'){
        disp_img.style.display = 'block';
        btn_disp_img.innerHTML = 'img display OFF';
      }else{
        disp_img.style.display = 'none';
        btn_disp_img.innerHTML = 'img display ON';
      }
      split_btm.style.top = split_top.clientHeight + 30;
    }

    function startStream(){
      btn_start.disabled = true;
      btn_w_stop.disabled = true;
      changeCtrlCam('start_stream',0);
      let d = new Date();
      mjpeg.src = mjpeg_url + '?' + d.getTime();
      loopAnime();
    }

    function loopAnime(timestamp){
      //console.log(timestamp);
      ctx.drawImage(mjpeg, 0, 0, img_width, img_height);
      anime_req_id = requestAnimationFrame(loopAnime);
    }

    function stopStream(){
      btn_start.disabled = false;
      btn_w_stop.disabled = false;
      cancelAnimationFrame(anime_req_id);
      changeCtrlCam("stop_stream",0);
    }

    function aecSens(){
      if(aec === 1){aec = 0;aec_elm.innerHTML='AEC Sens ON';}
      else{aec = 1;aec_elm.innerHTML='AEC Sens OFF';}
      changeCtrlCam("aec_sens",aec);
    }

    function aecDsp(){
      if(aec_dsp === 1){aec_dsp = 0;aec_dsp_elm.innerHTML='AEC DSP ON';}
      else{aec_dsp = 1;aec_dsp_elm.innerHTML='AEC DSP OFF';}
      changeCtrlCam("aec_dsp",aec_dsp);
    }

    function aeLevel(){
      let ae_level = ae_level_elm.value;
      changeCtrlCam("ae_level",ae_level);
      ae_level_txt_elm.innerHTML = ae_level;
    }

    function aecVal(){
      let aec_val = aec_val_elm.value;
      changeCtrlCam("aec_val",aec_val);
      aec_val_txt_elm.innerHTML = aec_val;
    }

    function changeCtrlCam(id_txt, value_txt){
      let ctrl_url = base_url + '/control?var=';
      ctrl_url += id_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));
    }

    function snapShot(num){
      let img_id_str = 'img_snap' + num,
          a_id_str = 'a' + num,
          img_elm = document.getElementById(img_id_str),
          a_elm = document.getElementById(a_id_str);
      URL.revokeObjectURL(blob_url[num]);
      canvas.toBlob(function(blob) {
        blob_url[num] = URL.createObjectURL(blob);
        a_elm.href = blob_url[num];
        img_elm.src = blob_url[num];
      }, 'image/jpeg');
    }
  </script>
</body>
</html>
)";

  httpd_resp_set_type(req, "text/html");
  httpd_resp_set_hdr(req, "Accept-Charset", "UTF-8");
  return httpd_resp_send(req, html_body.c_str(), html_body.length());
}
//****************************************
static esp_err_t cmd_handler(httpd_req_t *req){
  char*  buf;
  size_t buf_len;
  char id_txt[32] = {0};
  char value_txt[32] = {0};

  buf_len = httpd_req_get_url_query_len(req) + 1;
  if (buf_len > 1) {
    buf = (char*)malloc(buf_len);
    if(!buf){
      httpd_resp_send_500(req);
      return ESP_FAIL;
    }
    if (httpd_req_get_url_query_str(req, buf, buf_len) == ESP_OK) {
      //Serial.println("-----Receive Control Command");
      Serial.println(buf);
      if (httpd_query_key_value(buf, "var", id_txt, sizeof(id_txt)) == ESP_OK &&
        httpd_query_key_value(buf, "val", value_txt, sizeof(value_txt)) == ESP_OK) {
      } else {
        free(buf);
        httpd_resp_send_404(req);
        return ESP_FAIL;
      }
    } else {
      Serial.println(buf);
      free(buf);
      httpd_resp_send_404(req);
      return ESP_FAIL;
    }
    free(buf);
  } else {
    httpd_resp_send_404(req);
    return ESP_FAIL;
  }

  int16_t val = atoi(value_txt);
  int res = 0;
  sensor_t *sensor = esp_camera_sensor_get();
  if(!strcmp(id_txt, "start_stream")){
    canSendImage = false;
    canStartStream = true;
    isCloseConnection = false;
  }else if(!strcmp(id_txt, "stop_stream")){
    canStartStream = false;
    isCloseConnection = true;
  }else if(!strcmp(id_txt, "aec_sens")){
    sensor->set_exposure_ctrl(sensor, val);
  }else if(!strcmp(id_txt, "aec_dsp")){
    sensor->set_aec2(sensor, val);
  }else if(!strcmp(id_txt, "ae_level")){
    sensor->set_ae_level(sensor, val);
  }else if(!strcmp(id_txt, "aec_val")){
    sensor->set_aec_value(sensor, val);
  }else{
    res = -1;
  }

  if(res){
    return httpd_resp_send_500(req);
  }

  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  return httpd_resp_send(req, NULL, 0);
}
//****************************************
static esp_err_t stream_handler(httpd_req_t *req){
  const char *stream_content_type = "multipart/x-mixed-replace;boundary=--myboundary";
  const char *stream_boundary = "\r\n--myboundary\r\n";
  const char *stream_part = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";

  camera_fb_t * fb = NULL;
  esp_err_t res = ESP_OK;
  char part_buf[64];
  uint8_t * jpg_buf = NULL;
  size_t jpg_buf_len = 0;

  res = httpd_resp_set_type(req, stream_content_type);
  if(res != ESP_OK){
    return res;
  }
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");

  while(true){
    if(isCloseConnection){
      Serial.println("Loop Out Streaming!");
      break;
    }

    fb = esp_camera_fb_get(); //イメージセンサOV2640から画像取得
    if (!fb) {
        Serial.println("Camera capture failed");
        res = ESP_FAIL;
    } else {
      jpg_buf = fb->buf;
      jpg_buf_len = fb->len;
      if(res == ESP_OK){
        res = httpd_resp_send_chunk(req, stream_boundary, strlen(stream_boundary));
      }else{
        Serial.printf("res3=%d\r\n", res);
      }
      if(res == ESP_OK){
        size_t hlen = snprintf((char *)part_buf, 64, stream_part, jpg_buf_len);
        res = httpd_resp_send_chunk(req, (const char *)part_buf, hlen);
      }else{
        Serial.printf("res1=%d\r\n", res);
        continue;
      }
      if(res == ESP_OK){
        if(jpg_buf_len){
          res = httpd_resp_send_chunk(req, (const char *)jpg_buf, jpg_buf_len);
        }else{
          Serial.println("Failed jpg_buf_len");
        }
      }else{
        Serial.printf("res2=%d\r\n", res);
      }

      if(fb){ //fbを使い回すために必要らしい
        esp_camera_fb_return(fb);
        fb = NULL;
        jpg_buf = NULL;
      } else if(jpg_buf){
        free(jpg_buf);
        jpg_buf = NULL;
      }
      res = ESP_OK;
    }
  }
  return res;
}

265行目のbase_urlは、document.location.origin としましたので、ここにIPアドレスを入力しなくて済みます。

では、これをコンパイル書き込みした後、シリアルモニタに表示されたローカルIPアドレスをブラウザのURL欄に入力してみてください。
同じように操作できればOKです。

6-04. M5Cameraのイメージセンサ(OV2640)の露出調整について

今回はM5Cameraのイメージセンサ(OV2640)のAEC (Auto Exposure Control)、つまり自動露出制御をブラウザでコントロールできるようにしてみました。

実は、過去に何度もこのブログでM5Cameraの露出調整を行ってきたのですが、今回、今更ながらに気付いた事があります。
Arduino core ESP32のサンプルスケッチCameraWebServerを見れば分かるのですが、例えば、露出制御は以下のようなコードが制御対象になります。

sensor_t *sensor = esp_camera_sensor_get();
sensor->set_exposure_ctrl(sensor, 1); //sensor露出制御ON
sensor->set_aec2(sensor, 1); //DSP自動露出制御ON
sensor->set_ae_level(sensor, 0); //-2~2
sensor->set_aec_value(sensor, 0); //0~1200(実質255までで充分)

これはesp32_cameraライブラリの関数で、以下の4つを使います。

set_exposure_ctrl
set_aec2
set_ae_level
set_aec_value

これについては、こちらの記事でも紹介しているので、合わせて参照してみてください。

この4つの関数とブラウザのHTMLのボタンやスライダーとの関係は、下図のようになります。

(図06_04)

このボタンを見ると、AEC (Auto Exposure Control)には、センサ制御とDSP (Digital Signal Processor)制御があるように見えます。
Arduino core ESP32のサンプルスケッチのHTMLボタンでも同様です。

ただ、OV2640データシートのDSP項目を見ても、DSPによる露出制御については記述がありませんでした。
DSP項目のところを日本語直訳してみると、以下の感じです。

DSPブロックでは、RawデータからRGBへの補間と一部の画質制御を行います。
●エッジエンハンスメント(2次元ハイパスフィルター)
●色空間変換(RawデータをRGBまたはYUV/YCbCrに変換可能)
●RGBマトリクスによる色の混ざり合いの解消
●色相・彩度コントロール
●プログラム可能なガンマコントロール
●10ビットデータを8ビットに変換
●ホワイトピクセルキャンセリング
●ノイズ除去

データシートの自動露出制御関連の記述には、画像の輝度によって閾値が変わるようなことも書いてあったので、多分、個人的想像ですが、DSPをONにすることによって、輝度や彩度が自動制御されて、自動露出制御と相乗効果が起きるようになるのかも知れません。

私がいろいろ操作して試してみたところ、暗めの対象物については、「AEC Sensor ON」と「AEC DSP ON」にした方が比較的明るく表示してくれました。そして、AEC level を+2にすると良いでしょう。

比較的明るめの対象物の場合は輝度が元々高いので、AECをONにすると比較的暗めに表示されてしまいます。
そこで、もっと明るくしたい場合は、「AEC Sensor OFF」にして、「Manual Exposure value」を任意に調整すると、ガッツリ明るくなります。

実は、「Manual Exposure value」という表記のボタンは set_aec_value 関数なので、Auto Exposure(自動露出)の値ですが、OV2640のデータシートとexp32_cameraライブラリのソースコードを読み取って解釈すると、set_exposure_ctrlをOFFにする場合はAEC (Auto Exposure Control)はOFFになるようです。
すると、set_aec_value はマニュアル露出調整のように振舞ったのです。
よって、個人的な勝手な判断でそれは「Manual Exposure value」ボタンと表記しました。本当は、おそらく自動露出制御の一貫なのかなと思います。

結局のところ、OV2640のデータシートを読んでもイメージセンサ素人の自分には意味不明なので、実際にいろいろ操作してみて、効果を確かめるしかありませんね。

7.まとめ

今回はディープラーニングの学習用データセットを作成するためにM5Cameraを使ってサクッと作ろうと思ったのですが、ブラウザのセキュリティが高く、かなり苦戦しました。
特に、ソースのクロスオリジン利用という、今まであまり深く考えていなかったことに戸惑いました。

ブラウザに表示させる画像や動画は、クロスオリジンのことをよく考慮して、JavaScriptプログラミングをすることが大事ですね。

そして、そのおかげで、Motion JPEGはコーデックの一つであって、mp4ファイルやAVIファイルでも使用できるということを今更ながら知りました。

最後の10個のスナップショット撮影ができるプログラミングはディープラーニングのデータセット作成に威力を発揮しそうです。

そんなこんなでサクッと書き終わる予定だった今回の記事が、えらい文字数になってしまいました。
この界隈は小難しいことが多すぎますね、ほんとに…。

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

コメント

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