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)

(追記)
M5Stack Basicは、この記事を書いた当時より格段にバージョンアップしております。
以下のスイッチサイエンスさんの公式サイトをご参照ください。
https://www.switch-science.com/collections/%E5%85%A8%E5%95%86%E5%93%81/products/9010

waves ESP32 DevKitC V4 ESP-WROOM-32 ESP-32 WiFi BLE
waves
¥1,170(2024/04/26 14:27時点)

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です。

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

コメント

  1. 組み込みプログラマ より:

    mgo-tecさん、お久しぶりです。

    ESP32でカメラ画像の送信に興味を持って、調べていたらまたmgo-tecさんのページにたどり着きました。今回もいろいろ読ませて頂きました。ありがとうございます。
    記事を読ませて頂いたお礼に少しだけ気になった事を書かせていただきます。
    (いつもの事ですが参考にならなかったら無視して頂いて結構です。)

    UDPの説明で「これは、UDP規格の下位層で再送設定されているようで、UDPデフォルトの正常な動作のようです。」とありますが、UDPの規格では恐らく再送される事はないと思います。(私が四半世紀以上間違った知識を持っていたのでなければ100%あり得ないのではないかと思います。)
    なぜかと言うと、TCPではデータを送信すると受信側はそのデータを受け取ったという返事(ACK)を送信側に返信します。このACKが一定時間以内に送信側に返らないと、送信側はパケロスが発生したと考えて再送をするという仕組みになっています。
    しかし、UDPではデータを送信したらしっぱなしで、相手に届いたのかどうかは気にしません。なのでUDP層で再送を実施する方法自体がないと思われます。(少なくとも私がLinuxやWindowsでネットワークプログラミングをしていた頃はUDPは再送制御の無いプロトコルという認識でした。)
    又、「UDPはESP32やM5Stackなどの自作IoT機器間のハンドシェイク(コネクション確立)が簡単で実現しやすいのが利点です。ただ、パケットロスは避けられません。」とも書かれていますが、UDPにはそもそもコネクション確立という概念がありません。複数の機器がそれぞれ自分のUDPポートをオープンして、それらのポート間でお互いにデータを投げ込むだけなので、TCPのようなコネクションの確立フェーズはありません。(なので相手のポートがオープンしていなくても送信自体は正常にできてしまいます。)
    パケロスについては、一般的にUDPを使用して高信頼性の通信をする場合は、UDPポートを使用するアプリケーション層で受信時の到達確認の応答や、送信時の一定時間以内の返信が無い場合の再送をシーケンスとして考えて作成します。(送信側はデータ送信後に受信側からの返事を待ち、返事が来たら次のデータを送信する様にし、受信側はデータを受信したら返信をする様にして、TCPがやっている再送制御をアプリケーションが実施する様にします。)
    今回の様なケースでは、上記の様にデータの到達確認制御を入れる様にすれば、受信側の限界に近いパフォーマンスが出せるのではないかと思いました。(今回のケースでは受信側が取りこぼしている確率が高いと思われますので。)

    又、今回のソースを見ていて、ポート80と81のタスクを別々に作成して、コマンド受信側のポート(ポート80でしょうか)のプライオリティを高く設定すれば、ポート81のスレッドで画像の送信をしながらポート80でコマンドを受信した時に即座にコマンドに応答できる様に出来そうな気がしました。(Serverクラスがマルチタスクに対応していればの話になってしまいますが。)

    それでは、失礼します。

    • mgo-tec mgo-tec より:

      組み込みプログラマさん

      たいへんお久しぶりですね。
      2020年に書いたつたない記事をまたまた読んでいただき、ありがとうございます。

      私は現在、諸事情でほとんどESP32を触っておらず、このブログもしばらく放置状態です。

      さてさて、確かにUDPってコネクション確立とか、再送処理とか無いはずですね。
      ただ、うろ覚えですが、この記事を書いた当初は、UDPなのになぜか再送処理っぽい動きをしていたので、そういう想像で記事を書いた記憶があります。
      今思えば、組み込みプログラマさんがおっしゃる通り、Arduino core ESP32がアプリケーション層で何やら処理していそうな気がしますね。

      当時はUDPでの動画送信はどうやってもうまくいかず、Twitterでお世話になった「らびやん」さんから教えてもらい、結局はTCPで送信した方が確実で早くで発熱もしなかったのでした。
      もしかしたら、UDPでもうまくプログラミングすれば、TCPよりも良いかも知れませんね。
      ただ、今は残念ながら再検証する時間が無いのですが…。
      そんなこんなで、いつもいろいろ教えて頂き、ありがとうございます。
      記事も時間がある時に修正を入れたいと思います。
      m(_ _)m

      • 組み込みプログラマ より:

        mgo-tecさん、お返事を頂きましてありがとうございます。
        現在はEPS32を触っていないとの事ですが、せっかくここまで色々と調べたり作ったりしてきたのですから、また面白いプログラムを作ってプログに公開できる様になると良いですね。
        以前にも書きましたが、mgo-tecさんの書いたこのブログは色々な方に大変役立っていると思います。私もESP32を使って何かしようと調べるとよくこのブログの記事がヒットして、拝見させて頂いています。
        ある意味、mgo-tecさんの財産といっても過言ではないくらい素晴らしい内容だと思いますので、これからも運用して頂けると参考にする私たちにとっても大変有難いと思いました。
        私なども、色々と忙しかったりなんだか面倒くさくなってしまったりする事も何度もありましたが、そんな時は少し手を休めて距離を置いたりして、またやりたくなった時に再開する様にしていました。
        mgo-tecさんも、またESP32やその他ガジェトなどを使った面白いプログラムを作れる様な環境になる事を祈っています。
        それでは、失礼します。

        • mgo-tec mgo-tec より:

          組み込みプログラマさん

          とてもありがたいお言葉、感謝感謝です。
          多くの読者に役に立っているのなら嬉しい限りです。
          でも、いろいろと未熟者で、今は反省ばかりですが…。

          ブログ休眠中でも、コメント投稿で問合せがあり、ブログ運営の大変さを今さら感じています。
          今は本業と生活が厳しく、暇が全く無い状態なのです。
          ブログ運営だけで生活できるだけの報酬が出れば、是非ともブログを再開したいんですけどね~…。
          それは無理としても、いつか生活に余裕ができたら再開したいと思っています。

          そんなわけで、また何かお気づきの点がありましたら、コメント頂けると幸いです。
          うれしいコメントありがとうございました~。
          m(_ _)m

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