ESP32でビットマップ画像ファイルを生成し、ブラウザに連続送信してMotion JPEGならぬMotion BMP動画ストリーミングする実験

ESP32,でビットマップ画像を生成し、Motion,JPEG,でブラウザに動画ストリーミングさせてみた。 ESP32 ( ESP-WROOM-32 )

Arduino core ESP32 で Motion JPEG ストリーミングを実現するザッとした流れ

では、ビットマップ画像の生成ができたとして、Arduino core for the ESP32 で、ビットマップ画像を連続してブラウザへWiFi送信して、動画ストリーミングする方法の概要をザッと説明します。

HTTPClientライブラリや、httpdライブラリ関数を使えば簡単で楽なのですが、ここでは基本的なWiFiおよびWiFiClientライブラリだけを使って、client.readやclient.readStringUntil、client.writeなどの関数で構成する方法を説明します。
この方が、ブラウザとのコールアンドレスポンスが良く解って、個人的には良いと思っています。

因みに、ブラウザとのコネクション確立には一般的なHTTP通信のポート80番を使いますが、80番だけでは一方向のストリーミングになってしまい、動画をストップさせたりするコントロールができませんでした。
双方向を実現するためにはストリーミング用のポートと、制御コントロール用のポートを分けた方が良いと思われます。
これは、Arduino core ESP32 のサンプルスケッチ、CameraWebServerを解析して得られた結論です。

では、Motion JPEG ならぬ、Motion BMP動画ストリーミングの流れを説明します。
合わせて、先ほど紹介した以下の記事も参照すると分かり易いかと思います。

Androidのカメラ映像をMotionJPEGで配信する

1.ESP32 または M5Stack で、server のポート80番と、81番を起動しておき、クライアントに開放しておきます。

2.ブラウザのURL入力欄に
http://192.168.0.10
などと入力。
ポート番号は未入力なので、デフォルトで80番としてリクエストがESP32に送られる。

3.ESP32がclientからポート80番でGETリクエスト受信。
client.readStringUntil(‘\n’)を使って、改行コードまでの文字列を順次読み込む。
string.indexOf関数を使って、

GET / HTTP/1.1

という文字列を検知する。
検知したら、最後の空行(\r\n)を検知するまで読み込む。
空行の検知は、’\r’が文字が行の先頭を検知したら空行と判断する。

4.client.print関数を使って、ESP32からポート80番でブラウザへHTTPレスポンスヘッダ送信
続けてclient.print関数を使ってESP32からHTML送信
これでポート80番のHTTPコネクション確立完了。

5.ESP32側でclient81= server81.available を使ってポート81番を開放?し、ブラウザからポート81番のリクエスト待機状態にする。

6.ブラウザ画面で「Stream ON」ボタンを押して、JavaScriptによるESP32のポート81番へGETリクエストを送信

7.ESP32で「/stream」という文字列を検知したら、ブラウザへ以下のHTTPレスポンスヘッダを送信

HTTP/1.1 200 OK\r\n
Access-Control-Allow-Origin: *\r\n
Content-type: multipart/x-mixed-replace;boundary=--myboundary\r\n
\r\n

するとブラウザ側ではストリーミング画像受信待機状態になる。

8.ESP32から、

--myboundary\r\n

という文字列を送信。
この文字列は、先のboundary=の後の文字列と同じにする。
ハイフン2つと改行コード以外は任意の文字列にすることができる。
例えば、
–123456abcd\r\n
みたいな感じの適当な文字列でOK
その後続けて以下のHTTPレスポンスヘッダを送信。

Content-type: image/bmp\r\n
Content-Length: bmpファイルサイズ\r\n;
\r\n

9.続けて、ESP32からポート番号81番のclient.write関数を使って、1枚のビットマップファイルを1460byte以下に分割してバイナリ送信。
1460byteという数値は、Arduino core ESP32ライブラリ中に定義されていて、それより大きいサイズは一度に送信できないようになっているためです。
そして、画像ファイル1枚を送信し終えたら忘れがちなのが、必ず最後に空行文字列”\r\n”を送信

10.以降8~9を繰り返して、ブラウザにビットマップ画像を連続して送り続ければ、動画ストリーミングになる。

 

以上で、Motion JPEGならぬ Motion BMP の流れです。

双方向通信の方法(Motion JPEGで動画ストリーミング送信しながら、ブラウザからのリクエストを受信する)

前節ではESP32やM5Stackからブラウザへ一方的な動画ストリーミングだけでしたが、動画ストリーミングしながらブラウザからGETリクエストを受信する方法を説明します。

前節で述べたように、Motion JPEG ( MJPEG )のストリーミングは、ポート81番で送信するようにしました。
ストリーミング中に常にブラウザからポート80番でGETリクエストを受信できるようにESP32側でポート80番を開放しておく必要があります。
なぜなら、最初にポート80番と81番を開放していたはずなのに、81番でストリーミングを開始してしまうと、80番が通信不能になるからです。

その場合、私のやった方法は、前節の手順6番のところで、画像を送信する前に、

client80 = server80.available();

を置きます。
これを置かないとうまく双方向通信できませんでした。
これが正しいのかどうか分かりませんし、Arduino core ESP32がバージョンアップしたら動かなくなるかも知れません。
とりあえず動いたのでヨシとします。
詳しくは後で紹介するスケッチをご覧ください。

ではいよいよ次の節から実際にArduino IDE でプログラミングしてみます。

使ったもの

ESP32開発ボード、又はM5Stack

ESPr Developer 32
スイッチサイエンス(Switch Science)
¥2,200(2021/09/09 23:54時点)

M5Stack Basic
スイッチサイエンス
¥5,203(2021/09/09 23:54時点)

WiFiルーター環境

今回はESP32をSTAモードで動作させます。

STAモードとは、外部のWiFiルーターを経由してスマホやパソコンと接続します。
ですから、WiFiルーター環境が必要です。

事前にWiFiルーターのセキュリティ設定で、MACアドレスフィルタリング等の設定を確認して、ESP32が通信できる状態にしておいてください。
ESP32のMACアドレス情報の確認は以下の記事を参照してください。

ESP32-WROOM-32 チップ・メモリ・MACアドレス情報取得方法

もし、WiFiルーターが無ければ、安価で簡易的なホテルルーターでも良いと思います。

パソコン、スマホ等

できるだけ高速CPU、高速WiFi処理の最新のパソコンまたはスマホを使った方がフレームレートが高いです。

Arduino core for the ESP32 のインストール

Arduino IDE はver 1.8.12 で動作確認しています。
Arduino core for the ESP32 は stable ver 1.0.4 で動作確認しています。
Arduino core for the ESP32 のインストール方法は以下の記事を参照してください。

Arduino core for the ESP32 のインストール方法

スケッチ例(四角形を表示するだけのシンプルなMotion JPEGストリーミング用)

まずは、四角形だけを表示するMotion JPEG用アニメーションのArduinoスケッチを紹介します。
シンプルと言えども、400行近くあります。
ブラウザとのコールアンドレスポンスが結構手順が多いからです。

独自に作った以下のスケッチをご覧ください。
画像の大きさは 200 x 148 pixel です。
これでもファイルサイズが、
200 x 2 x 148 = 59.2KB
にもなってしまいます。
これ以上大きくするとフレームレートが低くなり、動画がカクカクしてしまいます。
これで5~7fps(1秒間に5~7枚)です。

HTTPClientやhttpdライブラリ関数群は一切使っていませんので、当然行数が多くなってしまいます。
でも、ブラウザとのやり取りが明白に分かるので、個人的にはこちらの方が分かり易いと思います。
因みに私はプログラミング素人なので、無駄が多く、コーディングスタイルもメチャメチャかとおもいますが、ご容赦を、、。

【ソースコード】 (※無保証 ※PCの場合、ダブルクリックすればコード全体を選択できます)

/* The MIT License (MIT)
 * License URL: https://opensource.org/licenses/mit-license.php
 * Copyright (c) 2020 Mgo-tec. All rights reserved.
 * 
 * Use Arduino core for the ESP32 stable v1.0.4
 */
#include <WiFi.h>
#include <WiFiClient.h>
#include <utility> //swap関数を使う場合に必要

const char* ssid = "xxxxxxxxx"; //ご自分のルーターのSSIDに書き換えてください
const char* password = "xxxxxxxxx"; //ご自分のルーターのパスワードに書き換えてください
//----------------------------------
const uint16_t disp_width_pix = 200, disp_height_pix = 148;
const uint16_t max_x = disp_width_pix - 1;
const uint16_t max_y = disp_height_pix - 1;
const uint16_t max_w_pix_buf = disp_width_pix * 2;
uint8_t bmp_data_buf[disp_height_pix][max_w_pix_buf] = {};
//----------------------------------
boolean canStartStream = false;
boolean canSendImage = false;
boolean shouldClear = true;
uint32_t frame_last_time = 0; //for display FPS
uint32_t draw_time = 0;
//------Initialize bitmap data------
const uint16_t data_size = disp_width_pix * 2 * disp_height_pix;
const uint8_t data_size_lsb = (uint8_t)(0x00ff & data_size);
const uint8_t data_size_msb = (uint8_t)(data_size >> 8); 
const uint8_t bmp_head_bytes = 66;
const uint16_t file_size = bmp_head_bytes + data_size;
const uint8_t file_size_lsb = (uint8_t)(0x00ff & file_size);
const uint8_t file_size_msb = (uint8_t)(file_size >> 8);
const uint8_t info_header_size = 0x28; //情報ヘッダサイズは常に40byte = 0x28byte
const uint8_t bits_per_pixel = 16; //色ビット数=16bit(0x10)
const uint8_t compression = 3; //色ビット数が16bitの場合、マスクを設定するので3にする。
const uint8_t red_mask[2] =   {0b11111000, 0b00000000};
const uint8_t green_mask[2] = {0b00000111, 0b11100000};
const uint8_t blue_mask[2] =  {0b00000000, 0b00011111}; 
//※Bitmap file headerは全てリトルエンディアン
const uint8_t bmp_header[bmp_head_bytes]=
    {0x42, 0x4D,
     file_size_lsb, file_size_msb, 0, 0,
     0, 0, 0, 0,
     bmp_head_bytes, 0, 0, 0,
     info_header_size, 0, 0, 0,
     disp_width_pix, 0, 0, 0,
     disp_height_pix, 0, 0, 0,
     1, 0, bits_per_pixel, 0,
     compression, 0, 0, 0,
     data_size_lsb, data_size_msb, 0, 0,
     0,0,0,0,
     0,0,0,0,
     0,0,0,0,
     0,0,0,0,
     red_mask[1], red_mask[0], 0, 0,
     green_mask[1], green_mask[0], 0, 0,
     blue_mask[1], blue_mask[0], 0, 0};
//*********************************************
void setup() {
  Serial.begin(115200);
  Serial.println();
  delay(1000);
  TaskHandle_t taskHTTP_handl;
  if (!xTaskCreatePinnedToCore(&taskHTTP, "taskHTTP", 9216, NULL, 24, &taskHTTP_handl, 0)) {
    Serial.println("Failed to create taskHTTP");
  }
  while(!canStartStream){
    delay(1);
  }
}

void loop() {
  if(shouldClear){
    clearAll();
    shouldClear = false;
  }
  if(canStartStream){
    if(!canSendImage){
      uint16_t x0, y0, x1, y1;
      if(changeDrawCount(draw_time, 0, 2000)){
        //0~2秒までは赤色四角形表示
        x0 = 0, y0 = 0, x1 = 50, y1 = 50;
        drawRectangleFill(x0, y0, x1, y1, 0xff, 0x00, 0x00);
      }
      if(changeDrawCount(draw_time, 2000, 4000)){
        //2~4秒までは緑色四角形表示
        x0 = 75, y0 = 49, x1 = 125, y1 = 99;
        drawRectangleFill(x0, y0, x1, y1, 0x00, 0xff, 0x00);
      }
      if(changeDrawCount(draw_time, 4000, 6000)){
        //4~6秒までは青色四角形表示
        x0 = 149, y0 = 97, x1 = max_x, y1 = max_y;
        drawRectangleFill(x0, y0, x1, y1, 0x00, 0x00, 0xff);
      }
      if(changeDrawCount(draw_time, 6000, 6500)){
        //6.0秒から6.5秒までは画面消去
        clearAll();
        draw_time = millis();
      }
      canSendImage = true;
    }
  }
}
//*********************************************
boolean changeDrawCount(uint32_t now_time, uint32_t start_time, uint32_t stop_time){
  if((millis() - now_time > start_time) && (millis() - now_time < stop_time)){
    return true;
  }
  return false;
}
//*********************************************
void clearAll(){
  memset(bmp_data_buf, 0, disp_height_pix * max_w_pix_buf);
}
//*********************************************
void drawRectangleFill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint8_t red, uint8_t green, uint8_t blue){
  uint8_t rgb565_msb = 0, rgb565_lsb = 0;
  convertRGB888toRGB565(red, green, blue, rgb565_msb, rgb565_lsb);
  for(int i = x0; i <= x1; i++){
    drawVerticalLine565(i, y0, y1, rgb565_msb, rgb565_lsb);
  }
}
//*********************************************
void drawVerticalLine565(uint16_t x0, uint16_t y0, uint16_t y1, uint8_t rgb565_msb, uint8_t rgb565_lsb){
  judgeMaxPixel(x0, max_x);
  judgeMaxPixel(y0, max_y);
  judgeMaxPixel(y1, max_y);
  uint16_t x01 = x0 * 2;
  uint16_t x02 = x0 * 2 + 1;
  if(y0 > y1) std::swap(y0, y1);
  for(uint16_t i = y0; i <= y1; i++){
    bmp_data_buf[i][x01] = rgb565_lsb;
    bmp_data_buf[i][x02] = rgb565_msb;
  }
}
//*********************************************
void convertRGB888toRGB565(uint8_t red888, uint8_t green888, uint8_t blue888, uint8_t &rgb565_msb, uint8_t &rgb565_lsb){
  //RGB888をRGB565へ変換するには、下位ビットを削除するだけでOK。
  uint8_t red565 = red888 & 0b11111000;
  uint8_t green565 = green888 & 0b11111100;
  uint8_t blue565 = blue888 & 0b11111000;
  rgb565_msb = red565 | (green565 >> 5);
  rgb565_lsb = (green565 << 3) | (blue565 >> 3);
}
//*********************************************
void judgeMaxPixel(uint16_t &pix, uint16_t max_pix){
  if(pix > max_pix){
    Serial.printf("Over Max pix = %d\r\n", pix);
    pix = max_pix;
  }
}
//*********************************************
void taskHTTP(void *pvParameters){
  connectToWiFi(ssid, password);

  WiFiServer server80(80), server81(81);
  server80.begin();
  server81.begin();
  WiFiClient client80, client81;

  while(true){
    String html_body = "<!DOCTYPE html>\r\n";
          html_body += "<html><head></head><body>\r\n";
          html_body += "<img id='pic_place' width='200' height='148' style='border-style:solid; transform:scale(1, -1);'>\r\n";
          html_body += "<div>\r\n";
          html_body += "<button style='border-radius:25px;' onclick='onStream()'>ON Stream</button><br>\r\n";
          html_body += "<p><button style='border-radius:25px;' onclick='changeControl(\"re_start_stream\",1)'>Re-Start Stream</button>\r\n";
          html_body += "<button style='border-radius:25px;' onclick='changeControl(\"stop_stream\",1)'>Stop Stream</button></p>\r\n";
          html_body += "<button style='border-radius:25px;' onclick='changeControl(\"close_connection\",1)'>Close Connection</button><br>\r\n";
          html_body += "</div>\r\n";
          html_body += "<script>\r\n";
          html_body += "var base_url = document.location.origin;\r\n";
          html_body += "var url_stream = base_url + ':81';\r\n";
          html_body += "function onStream() {\r\n";
          html_body += "var pic = document.getElementById('pic_place');\r\n";
          html_body += "pic.src = url_stream+'/stream';};\r\n";
          html_body += "function changeControl(id_txt, value_txt){\r\n";
          html_body += "var new_url = base_url+'/command?id=';\r\n";
          html_body += "new_url += id_txt + '&';\r\n";
          html_body += "new_url += 'value=' + value_txt;\r\n";
          html_body += "fetch(new_url)\r\n";
          html_body += ".then((response) => {\r\n";
          html_body += "if(response.ok){return response.text();} \r\n";
          html_body += "else {throw new Error();}})\r\n";
          html_body += ".then((text) => console.log(text))\r\n";
          html_body += ".catch((error) => console.log(error));};\r\n";
          html_body += "</script></body></html>\r\n";
    String html_res_head = "HTTP/1.1 200 OK\r\n";
           html_res_head += "Content-Length: " +String(html_body.length());
           html_res_head += "\r\n";
           html_res_head += "Content-Type: text/html\r\n";
           html_res_head += "Accept-Charset: UTF-8\r\n";
           html_res_head += "\r\n";

    client80 = server80.available();
    if (client80) {
      String req_str = "";
      if (client80.connected()){
        while(client80.available()){
          req_str =client80.readStringUntil('\n');
          if(req_str.indexOf("GET / HTTP/1.1") >= 0) {
            if(!receiveToBlankLine(client80, req_str)) goto exit_1;
            client80.print(html_res_head);
            client80.print(html_body);
            html_res_head = "";
            html_body = "";
            delay(10);
            Serial.println("HTML body send ok!!!!!!!!!");
            while(true){
              client81 = server81.available();
              if(!receiveOnStream(client80, client81, server80)){
                stopClient8081(client80, client81);
                break;
              }
              delay(1);
            }
            req_str = "";      
          }else if(req_str.indexOf("GET /favicon") >= 0){
            if(!receiveToBlankLine(client80, req_str)) break;
            sendFaviconResponse(client80);
            stopClient8081(client80, client81);
            break;
          }
          delay(1);
        }
        delay(1);
      }
    }
exit_1:
    delay(1);
  }
}
//*********************************************
boolean receiveOnStream(WiFiClient &client80, WiFiClient &client81, WiFiServer &server80){
  String req_str = "";
  while(client81.available()){
    req_str =client81.readStringUntil('\n');
    if(req_str.indexOf("/stream") >= 0) {
      Serial.println("----------On Stream");
      if(!receiveToBlankLine(client81, req_str)) break;
      String res_http = "HTTP/1.1 200 OK\r\n";
      res_http += "Access-Control-Allow-Origin: *\r\n";
      res_http += "Content-type: multipart/x-mixed-replace;boundary=--myboundary\r\n";
      res_http += "\r\n";

      client81.print(res_http);
      res_http = "";
      req_str = "";
      delay(10);
      canStartStream = true;
      if(!startStreamMJPEG(client80, client81, server80)){
        return false;
      }
    }
    delay(1);
  }
  return true;
}
//*********************************************
boolean startStreamMJPEG(WiFiClient &client80, WiFiClient &client81, WiFiServer &server80){
  String req_str = "";
  String bound = "--myboundary\r\n";
  String head_bound = "Access-Control-Allow-Origin: *\r\n";
  head_bound += "Content-type: image/bmp\r\n";
  head_bound += "Content-Length: " + String(file_size);
  head_bound += "\r\n";
  head_bound += "\r\n";
  draw_time = millis();

  while(true){
    if(!receiveCtrlRequest(client80, server80)){
      return false;
    }
    if(canStartStream){
      if(canSendImage){
        client81.print(bound);
        client81.print(head_bound);
        streamBmp(client81);
        canSendImage = false;
      }
    }
    delay(1);
  }
  return true;
}
//*********************************************
boolean receiveCtrlRequest(WiFiClient &client80, WiFiServer &server80){
  client80 = server80.available();
  String req_str = "";
  while(client80.available()){
    req_str = client80.readStringUntil('\n');
    if(req_str.indexOf("id=stop_stream") >= 0) {
      if(!receiveToBlankLine(client80, req_str)) break;
      sendCtrlResponse(client80);
      canStartStream = false;
      break;
    }else if(req_str.indexOf("id=re_start_stream") >= 0) {
      if(!receiveToBlankLine(client80, req_str)) break;
      sendCtrlResponse(client80);
      canStartStream = true;
      shouldClear = true;
      draw_time = millis();
      break;
    }else if(req_str.indexOf("favicon") >= 0) {
      if(!receiveToBlankLine(client80, req_str)) break;
      sendFaviconResponse(client80);
    }else if(req_str.indexOf("id=close_connection") >= 0) {
      if(!receiveToBlankLine(client80, req_str)) break;
      sendCloseResponse(client80);
      shouldClear = true;
      return false;
    }
    delay(1);
  }
  return true;
}
//*********************************************
void sendCtrlResponse(WiFiClient &client80){
  client80.print("HTTP/1.1 200 OK\r\n");
  client80.print("Access-Control-Allow-Origin: *\r\n\r\n");
}
//*********************************************
void sendCloseResponse(WiFiClient &client80){
  client80.print("HTTP/1.1 200 OK\r\n");
  client80.print("Connection: close\r\n\r\n");
}
//*********************************************
bool receiveToBlankLine(WiFiClient &client, String &req_str){
  Serial.println(req_str);
  req_str = "";
  uint32_t time_out = millis();
  while(true){
    if(client.available()){
      req_str =client.readStringUntil('\n');
      Serial.println(req_str);
      if(req_str.indexOf("\r") == 0){
        return true;
      }
    }
    if(millis() - time_out > 10000) {
      Serial.println("--------error Time OUT receiveToBlankLine");
      break;
    }
    delay(1);
  }
  return false;
}
//*********************************************
void streamBmp(WiFiClient &client81){
  client81.write(bmp_header, bmp_head_bytes);
  for(int i = 0; i < disp_height_pix; i++){
    client81.write(&bmp_data_buf[i][0], max_w_pix_buf);
  }
  client81.print("\r\n");
  float fps = 1000.0 / (millis() - (float)frame_last_time);
  Serial.printf("%.02lf(fps)\r\n", fps);
  frame_last_time = millis();
}
//*********************************************
void sendFaviconResponse(WiFiClient &client80){
  Serial.println(F("----------Favicon GET Request Received"));
  client80.print("HTTP/1.1 404 Not Found\r\n");
  client80.print("Connection: keep-alive\r\n\r\n");
}
//*********************************************
void stopClient8081(WiFiClient &client80, WiFiClient &client81){
  delay(10);
  client81.flush();
  client81.stop();
  delay(10);
  client80.flush();
  client80.stop();
  delay(10);
  Serial.println("------client80, client81 stop");
}
//*********************************************
void connectToWiFi(const char * ssid, const char * pwd){
  Serial.println("Connecting to WiFi network: " + String(ssid));
  WiFi.disconnect(true, true);
  delay(1000);
  WiFi.begin(ssid, password);
  Serial.println("Waiting for WIFI connection...");
  while ( WiFi.status() != WL_CONNECTED ) {
    delay(500);
    Serial.print(".");
  }
  IPAddress myIP = WiFi.localIP();
  Serial.println("WiFi connected!");
  Serial.print("My IP address: ");
  Serial.println(myIP);
  delay(1000);
}

【ザッと解説】

●7-9行:
WiFiのHTTP通信の最も基本的なArduinoライブラリ関数だけインクルードしています。
HTTPClientやWebServerライブラリ、およびhttpdライブラリは一切使いません。
ただ、9行目にあるように、直線を描く用にswap関数を使うために、C++言語の標準ライブラリがあるutilityをインクルードしています。

●11-12行:
今回はSTAモード(外部ルーターを経由して接続するモード)で使用します。
ご自分のWiFiルーターのSSIDとパスワードに書き換えてください。
ただ、ここで書き込んだパスワードは、ESP32個体が他人に渡った場合、簡単に抜き取られて、他人に知られてしまう可能性があるので充分注意してください。
当方では責任は負えません。

●14-18行:
画面の大きさを 200 x 148 pixel としています。
これでもビットマップファイルのサイズが50KBを越えるので、これ以上大きくすると、アニメーションのフレームレートがさらに下がってしまうので注意してください。
18行目で実際の画像データを格納する配列です。

●26-57行:
ここで、ビットマップファイルのヘッダ部分(66byte)を初期化しています。
先に紹介したように、冒頭の”BM”という文字以外は全てリトルエンディアンなので注意してください。

●59-70行:
セットアップ関数です。
このMotion JPEG用スケッチは今後のことを考えてマルチタスクで動かした方が良いので、64-66行で設定しておきます。
メインloop関数とは別のtaskHTTPというタスクを生成し、ESP32のCPU core 0でHTTPコネクションの動作をさせます。
メインloopの CPU core 1では、ビットマップファイルの画像バッファ配列 bmp_data_bufを書き替えるだけにします。

●72-103行:
ESP32のCPU core 1 で動作させているメインloop関数です。
73-76行では、ブラウザからのリクエストで画面を消去させています。
そして、77-102行で、ビットマップファイルの画像バッファ配列 bmp_data_buf に四角形を描画させています。
ブラウザからストリーミング指令が来たら、
canStartStream = true;
となり、四角形描画が始まります。
changeDrawCount関数は、105-110行にあるように、時間経過を計っていて、設定時間になったら四角形描画を切り替えるようにしています。

●112-114行:
ここで、ビットマップファイルの画像バッファ配列 bmp_data_buf をゼロクリアして、黒画面にします。

●116-151行:
ここで、bmp_data_buf に四角形を描画させています。
drawRectangleFill関数に入力したRGB888の色データを、convertRGB888toRGB565関数でRGB565データに変換します。
RGB565データ直接扱えば手っ取り早いのですが、パッと見どういう色か判別しにくいので、一般的になじみ深いRGB888データで入力します。
そして、137-144行を見て分かる通り、RGB888データの下位ビットを消去するだけでRGB565データに変換できるので、それを2byteデータに合成します。
そして、必要な座標位置相当のbmp_data_buf配列に代入していって四角形を描画させています。
らびやんさんによると、もっと高速な代入方法があるのですが、その課題は次回へ見送り、今回は分かり易いようにしています。

●153-232行:
ここで、マルチタスク CPU core 0 で動作させる無限ループです。
と言っても、今回のスケッチ程度ではマルチタスク不要なのですが、イメージセンサと組み合わせる時にはマルチタスクで動作させた方が良いのでそうしています。
まず、154行でWiFiルーター(アクセスポイント)に接続し、156-158行でESP32 をHTTPサーバーにします。
制御コントロール用のポート80番と、ストリーミング用のポート81番を生成します。
161-231行がcore 0 の無限ループですが、core 0 はデフォルトでウォッチドッグタイマ動作ON設定になっています。
ですから、whileループ等の長時間ループにはdelay(1)を最低限適度に置かないと、強制的にWDTリセットがかかりますので注意してください。
個人的に得た知識では、WiFi接続の安定動作させるためには、WiFi関連関数を core 0で動作させて、ウォッチドッグタイマ動作もONにしていた方が良いという結論でした。
そして、この状態でブラウザからのリクエストを待ち受け、200行にあるように、ブラウザからリクエストが来たら、
readStringUntil(‘\n’);
を使って、改行コードまで文字列を順次取り込み、文字列
“GET / HTTP/1.1”
を検知したら、202行のreceiveToBlankLine関数で空行まで読み取ります。
空行の検知は、336行にあるように、’\r’を行頭(位置がゼロ)に検知したことで判別します。
そうしたら、すかさず203-204行のように、HTTPレスポンスヘッダとHTMLソースコードをブラウザにprint関数を使ってテキスト形式で送信します。
(HTMLやJavaScriptについては後で紹介します。)
そうしたら、209-216行の無限ループに入り、211行でブラウザからの”ON Stream”リクエストをポート81番で待ち受けます。
それについては後で述べます。

また、218-223行では、Google Chromeの場合、時々faviconリクエストが来ます。
その場合の対処として、404レスポンスを返してブラウザを安心?させています。

●234-285行:
234-258行では、ブラウザから「ON Stream」ボタンが押されて、ポート番号81番でGETリクエストが送信された場合の処理です。
HTML上のJavaScriptでポート番号81番を指定してリクエストが送信されてきます。
238行のように、GETリクエスト文字列中に”/stream”という文字列を検知したら、240行で空行まで読み取り、その後すかさず241-244行のレスポンスヘッダを246行のようにポート81番を使って送信します。
レスポンスヘッダ中で、

Access-Control-Allow-Origin: *\r\n

とあるのは、ネットで情報がありますが、正直イマイチ良く解りません。
おそらく、Motion JPEGストリーミングしながら双方向通信する場合には必要かと思われます。たぶん、、。
そして、重要なのは、243行目の

Content-type: multipart/x-mixed-replace;boundary=--myboundary\r\n

というところです。
この一行によってMotion JPEGストリーミングが実現できます。
先にも述べたように、

--myboundary

のハイフンを除いた部分は任意の文字列にすることが可能です。
ただ、それは262行と同じ文字列にしてください。
この文字列がESP32から送信されて来たことをブラウザが受けて、その後のビットマップ画像データでブラウザ上に描画されます。

270-283行では、ポート番号80番で、ブラウザから制御コントロールリクエストを待ち受けながら、ポート番号81番でMotion JPEG動画ストリーミングを送信しているループです。
ここでのポイントは、288行目の

client80 = server80.available();

が無いと、ストリーミングしながらポート80番でリクエストを待ち受けられませんでした。
これは謎です。

また、274-281行では、配列bmp_data_buf に図形の描画1フレーム分が完了してからブラウザにビットマップ画像を送信するようにしています。
276行で、

--myboundary\r\n

という文字列を送信し、
277行でレスポンスヘッダを送信しています。
Content-Length でファイルサイズを明記して送信することが重要です。
ビットマップ画像データは、278行で送信しています。

●287-316行:
これは、先ほど述べたように、ストリーミングしながら双方向通信でブラウザからのGETリクエストをポート番号80番で待ち受ける関数です。

●328-347行:
先ほど述べたように、ブラウザからのリクエストがあった場合、その後の空行まで全メッセージを受信し切る関数です。
空行は、336行のように、行の先頭が’\r’という特殊文字を検知することで判別します。

●349-358行:
先ほど述べたように、ここでビットマップ画像データをブラウザにポート番号81番で送信しています。
350行でビットマップファイルのヘッダ部分66byteを送信し、351-353行で画像データを送信し、354行にあるように最後に必ず”\r\n”という空行文字列を送信します。
355-357行では、シリアルモニターにフレームレート値を表示させています。

以上がザッとした?解説でした。

HTMLおよびJavaScript ソースコード

先のシンプルMotion JPEGコードで、ブラウザに出力したHTMLおよびJavaScriptソースコードを見やすくすると、以下の感じです。

<!DOCTYPE html>
<html>
  <head>
  </head>
  <body>
    <img id='pic_place' width='200' height='148' style='border-style:solid; transform:scale(1, -1);'>
    <div>
      <button style='border-radius:25px;' onclick='onStream()'>ON Stream</button>
      <br>
      <p>
        <button style='border-radius:25px;' onclick='changeControl("re_start_stream",1)'>Re-Start Stream</button>
        <button style='border-radius:25px;' onclick='changeControl("stop_stream",1)'>Stop Stream</button>
      </p>
      <button style='border-radius:25px;' onclick='changeControl("close_connection",1)'>Close Connection</button>
      <br>
    </div>
    <script>
      var base_url = document.location.origin;
      var url_stream = base_url + ':81';

      function onStream() {
        var pic = document.getElementById('pic_place');
        pic.src = url_stream+'/stream';
      };

      function changeControl(id_txt, value_txt){
        var new_url = base_url+'/command?id=';
        new_url += id_txt + '&';
        new_url += 'value=' + value_txt;
        fetch(new_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>

【解説】

HTMLやJavaScriptに関してはあまり詳しくないので、ザッと説明します。

肝は、scriptタグはhead内に置くと正しく動作せず、bodyタグ内の最後に置くことです。
要するに、Webページのレンダリングが終了してから、scriptを実行するということです。

また、もう一つ重要なのは、6行目のimgタグのスタイルシート、

transform:scale(1, -1);

です。
先にも述べたように、ビットマップファイルは、左下から描画されます。
つまり、上下逆なのです。
ですからスタイルシートで上下反転させているわけです。

また、スケッチの解説でも述べましたが、「ON Stream」ボタンが押されたら、ESP32サーバーへポート81番を指定して、GETリクエストを送るようにしています。
すると、ESP32からboundary文字列やヘッダと共に画像データが送られてきて、<img>タグにストリーミング動画が表示されるようになっています。

ストリーミング停止や、再スタートボタン操作は、デフォルトのポート80番でGETリクエストを送信するようになっています。
fetch文のGETリクエストによるレスポンスやエラーは、ブラウザの開発者ツールに表示されます。

コンパイル書き込み実行

では、スケッチをコンパイル書き込み実行させてみて下さい。
そうしたら、シリアルモニターを115200bpsで起動します。
WiFiルーターと接続完了すると、以下のように表示されると思います。

ここで、ローカルIPアドレスが表示されなければ、ご自分のWiFiルーターでMACアドレスフィルタリングのせいか、またはDHCPの最大数を超えて、ルーターに機器が接続されていて接続できない状況だと思います。
その場合はWiFiルーターの設定を変えてください。

無事ローカルIPアドレスが表示されれば、ブラウザのURL入力欄にそれを入力してください。
すると、下図の様に表示されると思います。
ただ、スケールは等倍なので、かなり小さいです。
拡大表示して見て下さい。

そしたら、「ON Stream」ボタンを押してください。
すると、シリアルモニターに下図の様に表示され、ポート番号81番でストリーミング通信が始まります。

これはWindwos 10 パソコンでGoogle Chromeの場合です。
見てお分かりの通り、私のWiFi環境では6~7fps程度です。
最初に紹介した動画にあったように、手持ちの旧型Androidスマホでは、5fps程度でした。

ボタンの操作は下図をご覧ください。

「Re-Start Stream」ボタンは、動画を最初からスタートする制御です。
本当は一時停止にしたかったんですが、プログラミングする時間が無くなってしまったので、このままになってしまいました。

「Close Connection」ボタンを押すと、client.stop関数が動作して、ブラウザと切断されます。
再接続したい場合は、ブラウザの更新ボタンを押せばOKです。

では、次の節ではこのスケッチより少し凝ったアニメーションを紹介します。

コメント

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