OV2640のJPEG出力でM5CameraとM5StackのWiFi動画ストリーミング実験

M5CameraのOV2640からJPEG出力させ、M5StackへWiFi動画ストリーミングする実験。高フレームレートです。 M5Stack

JPEG開始マーカー FFD8 と終了マーカー FFD9 の検出方法

先に述べたように、DMA (Direct Memory Access) でFIFOメモリに書き込まれたデータを読み出してJPEGバッファに格納する際、そのデータがJPEGフォーマットかどうか検出する必要があります。

開始マーカーFFD8検出

開始マーカーについては前回記事で述べたように、FFD8 を検出すれば良いです。
この検出方法は、esp32-cameraライブラリをほぼ丸々参考にしました。

FIFOメモリの dma_desc->buf から初回のデータを読み込んだ時、その先頭データに 0xFF, 0xD8 があれば良いわけです。
以下の感じです。

uint32_t head = *((uint32_t *)jpg_buf);
if(head == 0xE0FFD8FF){
  //FF,E0:marker JFIF形式, FF,E1:EXIF形式
  dma_filtered_count++;
}

これ、最初に見た時に、目から鱗でした。
こんな方法があるのかと思いました。
バイトを1つずつチェックするのではなく、32bitに変換して、4byteごと一括で条件分岐するというやつです。
if文のところで、
0xE0FFD8FF
となっているところがミソで、0xFF, 0xD8の並びが逆になっているように見えます。
ですが、これが正常なんです。
0xFFが0バイト目で、0xD8が1バイト目ですから、32bitにするとこういう並びになるんです。
へぇーー!!!
勉強になりました。

実は、このコードにちょっと手を加えて、Twitterで「このコード、すげー」 みたいにつぶやいたんです。
それは2byteずらして3byte検出したりするやつです。
そしたら、プロのプログラマーの方々や熟練者から止めた方が良いとアドバイスを受けました。

確かに、32bit計算のマイコンで2byteずらしてif文で条件分岐したりすると、アライメントがズレているので、処理系によっては極端に遅くなったり、逆に受け付けない場合があるようです。
アドバイス下さったTwitterの皆さまに感謝ですね。
改めて、Twitterってスゲェと思いました。

このコードを検証し始めた頃、まだロジックアナライザやオシロスコープで割り込み位置を把握していなかったのですが、コードを真似していろいろ実験していると、そもそもOV2640からのパラレルデータは、先頭のI2S割り込みでFFD8を送って来るのか? という疑問に陥りました。
そこで、アライメントを無視して、OV2640からのバイト列を全て検索して、FFD8を検出するようにしたことがありました。

でも、先に紹介したように、実際にオシロスコープやロジックアナライザで解析して、初回のI2S割り込み先頭データにFFD8を検出できたので、VSYNCがHIGHの間はそこの1回だけ32bitでFFD8を検出すれば良いという結論に至ったのです。
というわけで、この部分はとっても勉強になりましたね。

終了マーカーFFD9検出

JPEGデータの終了マーカー FFD9 を検出することはとっても大事です。
これを検出することによって、JPEGデータサイズが決定できます。

これも、esp32-cameraライブラリに習って、自分流に以下のようにしてみました。

int32_t cd = jpg_buf_cnt - 1;
uint8_t *bf = &jpg_buf[jpg_buf_cnt - 1];
uint32_t now_ptr = jpg_buf_cnt - cnt;
while(bf >= (jpg_buf + now_ptr)){
  if(bf[0] == 0xff && bf[1] == 0xd9){
    jpg_buf_len = cd + 2;
    canSendImage = true;
    if(isChangeFramesize == true){
      setFramesize(frame_size_num);
      isChangeFramesize = false;
    }
    return;
  }
  bf--;
  cd--;
}

これは、取得したデータの末尾から検索して、終了マーカーFFD9を検出するやつです。
これはアライメントの問題は無いかと思います。
ここでは、0xFF, 0xD9, の2バイトを検出できたらOKで、その時点のデータまでがJPEGデータサイズです。

setFramesize の切り替えをここに持ってきたのは、M5Stackからフレームサイズ変更指令が来た場合、次のVSYNC割り込み前にフレームサイズ切り替えコマンドをOV2640へ送信する必要があるためです。

Motion JPEG (MJPEG)送信データのchunk化について

イメージセンサでJPEG出力する際は、撮影した画像によって、JPEGの圧縮率が異なります。
色構成が単純なものは圧縮率が高く、複雑な色構成の写真ならば圧縮率が低くなり、JPEGサイズが大きくなります。
800 byte ~ 6000 byte とデータ量の差がかなり大きいのです。

ということは、受信側 M5Stackでは、受信する直前にJPEGデータのバイト数を認識しなければ、read関数で一気に受信することができません。

ビットマップ形式の非圧縮で画角が同じならば、どんな色構成でもファイルサイズは一定ですから、今までは苦労しなかったのです。
しかし、JPEGの場合はそうはいきません。

これを解決するには、HTTPプロトコルのchunk(チャンク)化が便利だということが解りました。
実は、Arduino core ESP32のサンプルスケッチCameraWebServerでは既に使われていました。

chunkについては、以前のこちらの記事で取り上げましたが、その時はあまり有効性を感じませんでした。
しかし、今回のように JPEGデータを扱う場合はとっても有効でした。
基本的にパソコンやスマホなどのブラウザ相手にするものですが、M5StackやESP32などのIoTデバイス相手でも有効だと個人的に思いました。

chunk化とは、ブラウザからGETリクエストがあったら、chunk用のレスポンスヘッダを返して、その後、実際のパケットを送信する前にそのパケットのサイズを16進文字列で送信し、それから実際のパケットを送るという方式です。
画像パケットを分割して送る場合も、個々のパケットの前に16進文字列と空行コードを送れば良いわけです。
ブラウザ側では受信したデータを自動的にマージしてくれます。
chunk化についての詳細は以下のサイトも合わせて参照してください。
Transfer-Encoding

さて、JPEG画像の動画ストリーミングは、以前のこちらの記事で紹介したMotion JPEG (MJPEG)という方式を使いますが、MJPEGをchunk化する場合、一手間増えます。
ループ処理は下図の様な概要です。

バウンダリ文字列や、レスポンスヘッダ等のパケットの前にパケットサイズを16進文字列にして、更に空行コード”¥r¥n”を加えて送信します。

青色文字のところを注目すると、本来、単なるMJPEGでは、画像データの後に空行コード”¥r¥n”を送らねばなりませんが、今回はバウンダリ文字列の先頭に配置換えしても同様の処理能力でした。
その方がプログラミングは楽です。

具体的な流れは以下の様な感じです。

1.ブラウザからM5CameraへGETリクエスト

GET /stream HTTP/1.1\r\n
Host: 192.168.0.22:81\r\n
Connection: keep-alive\r\n
\r\n

2.M5Camera側からchunk用レスポンスヘッダ送信

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

3.ブラウザがMJPEG待機状態になる。

4.M5Cameraからchunk化したMJPEGストリーミング送信
以下をループ送信する。

10\r\n
\r\n--myboundary\r\n\r\n
32\r\n
Content-Type: image/jpeg\r\nContent-Length: 1644\r\n\r\n\r\n
66c\r\n
FF,D8,FF,E0......jpeg data..............FF,D9\r\n

以降繰り返し

 

Motion JPEG (MJPEG) ループ送信に入る前に、
Transfer-Encoding: chunked
のヘッダ情報が必要です。
このヘッダ送信の後のデータがchunk化されていないと、ブラウザは受け付けません。

そして、chunk化ループのところでは、“¥r¥n”が3回も連続するところがありますね。
これを受信側M5Stackでしっかり検知することが大事です。
その直後の16進文字列がJPEGデータのサイズなので、これを漏らさずキッチリ受信して数値に変換します。
例として、「66c」という文字列を10進数値に変換すると、1644 となります。
1644という数値は、レスポンスヘッダの Content-Length にある数値と同じです。

ところで、本来、chunk化した場合はContent-Length 不要のはずですが、ブラウザと通信する場合、なぜかContent-Length が無いと正常に表示されませんでした。

この 0x66c = 1644 という数値は、次のJPEGデータサイズなので、受信側 M5Stackはそのバイト数で read してやれば良いわけです。

その他注意したいのは、通常のMJPEGプログラミングよりも空行コード”¥r¥n”が増えるので、空行コードの送信タイミングや文字列をちょっとでも誤ると正常に動作しなくなります。
そうなるとシリアルモニターやオシロスコープなどで解析しても解決できません。

私の場合は、WireShark等のパケット解析ソフトを使って、正常に送信できるサンプルスケッチCameraWebServerと比較しながらプログラミングすることで解決できました。

ところで、実は後で気付いたのですが、ヘッダにContent-Lengthがあれば、JPEGデータのサイズは分かるので、chunk化しなくても良いのではないかと思えますね。
実際実験したところ、chunk化しなくても問題無く受信できました。
でも、今のところchunk化で問題無く動いているので、ここではあまり触れないでおきます。

後で紹介しますが、今回、私なりに考えて作った受信側 M5Stackのプログラムでは、JPEG開始マーカーFFD8を検知せずに、データサイズ分だけ受信するという方式にしました。
つまり、JPEGデータが来る直前の16進文字列がキッチリ受信できさえすれば、次に来るデータは必ずJPEGデータだからです。

以前のこちらの記事で実験したUDPと違って、 TCP通信の良いところは、データ到達の信頼性が高いということです。
だから、事前にJPEGデータのサイズが分かっていれば、JPEG開始マーカーFFD8 や終了マーカーFFD9を検知する必要が無いのです。

JPEGデータが正常に受信できれば、デコードして、ビットマップファイルに変換して、LCDに表示するだけです。

フレームサイズ(画角)設定

今回、最初に紹介した動画のように、イメージセンサOV2640側のフレームサイズ(画角)設定ができるようにしてみました。
これが実現できた時は我ながら感動してしまいました。

M5Stackの LCD表示で画角を決めるのではないところがミソです。
SCCBインターフェースでイメージセンサOV2640へレジスタコマンドを送って、フレームサイズ(画角)を決めるのです。

以前のこちらの記事で、OV2640のフレームサイズ設定を紹介しましたが、まぁ、ややこしくて小難しいので、ここでは割愛します。

今までRGBビットマップデータを扱ってきたときのフレームサイズは 200 x 148 pixelでしたが、今回のJPEGデータの場合、そのサイズではなぜかうまく行きませんでした。

よって、esp32-cameraライブラリに習って、240 x 176 pixel を試してみたら、問題無くJPEG開始マーカーと終了マーカーを検出できました。

更に、esp32-cameraライブラリも参考にして、以下のフレームサイズを追加しました。

96 x 96 pixel
160 x 120 pixel
192 x 144 pixel

これらもそれぞれ試してみたら、全て問題無く動作しました。

次いでに、M5Stick C のLCDに使われている、160 x 80 pixel を試してみたら、これも問題無く動作しました。

これをWiFi送信して、M5Stackで受信し、JPEGデータをビットマップにデコードする際、フレームサイズをJPEGデータから取り出せるので、そのまま malloc で領域を確保して、LCDに表示させるだけで済みました。
冒頭で紹介した動画のように、実際にM5Stackのボタン操作LCDの動画の画角が変えられると、我ながらメチャメチャ感動しました。

本当は320 x 240 pixel に挑戦したかったのですが、SRAMが足りないので、次回以降にPSRAMで挑戦してみようかなと思っています。

因みに、フレームサイズ設定のsetFramesize関数内で、PCLK周波数の分周設定も行うようになっています。

受信側 M5Stack のプログラミング

受信側のM5Stackプログラミングは、基本的に以前のこちらの記事のプログラムとは大きくは変わりありません。
マルチタスクで3タスクに分けた処理も同じです。
ただ、プラスされたのは前回記事で紹介したJPEGデコーダ―です。

では、今回のM5Stack側のプログラミングのポイントは以下の所です。

bool receiveStream(WiFiClient &client81){
  uint32_t time_out;
  while(StatusStream == ON_STREAM){
jpg_rcv0:
    if(!receiveBoundary(client81)){
      delay(1); continue;
    }
    if(client81.available()){
      time_out = millis();
      while(StatusStream == ON_STREAM){
        if((char)client81.read() == '\n') {
          if((char)client81.read() == '\r'){
            if((char)client81.read() == '\n') {
              if((char)client81.read() == '\r'){
                if((char)client81.read() == '\n') {
                  String res_str = client81.readStringUntil('\n');
                  jpg_len = strtol(res_str.c_str(), NULL, 16);
                  log_v("jpg receive LEN:=%d\r\n", jpg_len);
                  if(jpg_len) {
                    jpg_buf = (uint8_t *)malloc(jpg_len);
                  }else{
                    delay(1);
                    goto jpg_rcv0;
                  }
                  break;
                }
              }
            }
          }
        }
        if(millis() - time_out > 1000){
          receiveBoundary(client81);
          time_out = millis();
          delay(1);
        }
      }
      receiveStreamJPG(client81);
      while(StatusStream == ON_STREAM){
        if(!canDisplayLCD) break;
        delay(1);
      }
    }
    delay(1);
  }
  return false;
}

もっと良い方法があるかも知れませんが、私のプログラミング能力ではこんな程度です。

先ほど述べたように、MJPEGループ中で Content-Length を受信したら、その後は空行コード”¥r¥n”が3連続で送信されてきます。
これをまとめて6byte受信して、”¥r¥n¥r¥n¥r¥n”を検知するという方法も考えたのですが、そのバイトが1つでもズレた場合は検知困難になるので、今回は1バイトずつ受信して検索するようにしました。

そして、readStringUntil関数は、chunk化した16進文字列を検出します。
以前のこちらの記事で述べたように、readStringUntil関数の使いどころを誤ると、ハングアップしてリセットを繰り返してしまうので、こうしました。
16進文字列は、文字数が3つの時もあれば、4つの時もありますので、Arduinoプログラミングになれている私としてはreadStringUntil関数を使う方が便利でした。

そして、この16進文字列は strtol 関数で数値化すれば、JPEGデータサイズが取得できます。
それを元に malloc でメモリ領域を確保して、JPEGデータをread関数で一気に受信します。

JPEGデータを受信し切ったら、前回記事で紹介したTJpgDecライブラリでデコードして、RGB565形式のビットマップデータに変換します。

うまく変換できて、jd_prepareが通れば、JPEGデータの中に画像幅や高さ情報が入っているので、その大きさでビットマップデータ領域を確保します。
以下の所です。

devid.wfbuf = jdec.width;
devid.hfbuf = jdec.height;
devid.fbuf_size = jdec.width * 2 * jdec.height; //RGB565
devid.fbuf = (BYTE *)malloc(devid.fbuf_size);

ビットマップデータへの変換が完了したら、jpg_bufは不要なので、速やかにfreeで領域を開放することを忘れないようにします。

そこまでできれば、あとはM5StackのLCD(液晶ディスプレイ)へビットマップデータを流し込んで表示させるだけです。

以上から、JPEGデータでフレームサイズ(画角)が取得できるので、M5Stack側のボタン操作で動画のフレームサイズ(画角)が切り替えられるわけです。

DMA はFreeRTOS でキュー待ちした方が良い気がする

今回の私のプログラミングでは、bool型を使ってタスクのキュー処理みたいなことをやっていましたが、esp32-cameraライブラリのようにFreeRTOSを使ってキュー処理をした方が、おそらく効率が良いのだろうと思いました。
特に、DMAやFIFOメモリ読み込み、WiFi送信終了などはそうした方が良いでしょうね。

そろそろFreeRTOSも勉強しなきゃいけないのかな、と思いました。

でも、力技の素人プログラミングでも、サンプルスケッチのCameraWebServerとほぼ同等の動画ストリーミングができたので、個人的にはヨシとします。

まとめ

いやぁ~、長かった。
ブログ編集も気が付いたらプログラムソースコード以外で4万文字オーダーでした。
こういう記事の書き方や止めようと言いながら、懲りない自分でした。

でも、やっと、やっとOV2640のJPEG出力を扱えて、M5StackのLCDに動画ストリーミングすることができるようになりました。
しかもTCP通信で実現できました。

M5Camera やイメージセンサ OV2640を扱い始めて1年少々もこればかりに費やしてしまいました。
それだけ謎が多かったのです。

でも、esp32-cameraライブラリに頼りながらではありますが、イメージセンサをここまで自分の意図通りに制御できて、WiFiでストリーミングできるようになったことは感慨深いです。
ようやく納得いくカメラ動画ストリーミングが出来るようになりました。

やはり、JPEG圧縮は画像のデータ通信にとって必須条件だということがよーくわかりました。
データ通信は圧縮しろとよく言われますが、ここまで顕著に違いが現れると、その通りだ!と叫びたくなるほどですね。

それと、今までイマイチ良く解っていなかったDMAやFIFOメモリについてもかなり理解が深まりました。
ロジックアナライザやオシロスコープで割り込み位置を可視化できたことも大きいし、このブログ記事を書いていたおかげで閃いたことも多々ありました。
長文のブログを書くのは苦行ですが、今回はかなり役に立ちました。

そして、esp32-cameraライブラリには多くを勉強させてもらいました。
このライブラリを作って、そしてオープンソース化した Espressif Systemsの開発チームの方々には改めて感謝したいと思います。
ありがとうございました。

あとはPSRAMを使って320 x 240 pixelのストリーミングを実現させて、そろそろ別のプログラミングや工作にとりかかりたいなと思っています。

ではまた・・・。

コメント

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