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

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

今回は、ディープラーニング用のデータセットを作る目的もあって、M5Stack社のM5Cameraでブラウザに動画をWiFiでストリーミングさせながら、HTMLとJavaScriptでスナップショット画像(静止画像)を取得し、それをパソコンやスマホに保存させてみる実験をしてみました。
M5Camera側のプログラミングを特別に改変ぜずともスナップショット画像を取得できたので、今後いろいろと応用できそうです。

スポンサーリンク

まぁ、何はともあれ、以下の動画をご覧ください。
これは、Windows10パソコンのブラウザGoogle Chromeを使っています。
ディスプレイに手書き数字を描いた紙を貼り付けて、それをM5Cameraで撮影したものです。

どうでしょうか。
自前のプログラミングでここまでできれば、今後いろいろと応用が期待できそうですよね。
この手法を使えば、ディープラーニングの学習用データセットを作ることが容易になりますね。

M5Camera側のプログラミングは、サンプルスケッチのCameraWebServerを少し改変したくらいで、特別なことはしていません。

ただ、ブラウザ側のHTMLおよびJavaScriptはなかなか苦戦しました。
toBlobメソッドを使うわけですが、調べれば調べるほど奥が深いことが分かりました。
それに、Chrome以外のブラウザ(FireFoxやSafariなど)の動作チェックをすると、更に沼にハマりました。

本当は今回の記事はサクッと終わらせる予定だったんですが、逆にとんでもなく長い記事になってしまいました。
特にクロスオリジン(Cross-Origin)関連の情報については、実際にサーバーとクライアントでデータをやり取りしてみないと、難しさを体感できなかったですね。

ということで、今回の苦戦した実験を順に説明していきます。

なお、毎回述べておりますが、私はHTMLやJavaScriptプログラミングに関しては趣味程度の独学、素人です。
もし、間違った情報等、お気づきの点がありましたら、コメント投稿等でご連絡いただけると助かります。

    【目次】

    【事前準備】
    ・使ったもの
    ・Arduino core for the ESP32 のインストール
    ・Arduino core for the ESP32 のボード設定

  1. 対応OSおよびブラウザについて
  2. Motion JPEG (MJPEG) 動画ストリーミングのおさらい
  3. videoやimg要素の画像をcanvas要素に表示させる
  4. canvas要素の動画(アニメーション)からスナップショット(静止画)をimg要素に書き出す
  5. 画像データのダウンロードについて
  6. M5Camera動画から複数個のスナップショット画像を取得するHTMLおよびJavaScriptプログラミング(ディープラーニングの学習用データセットに使える)
  7. まとめ

事前準備

使ったもの

M5Camera

ESP32-WROVER(PSRAM付き)搭載、OmniVision製フルカラーイメージセンサOV2640搭載、技適取得済み、M5Stack社のWiFiマイコンモジュールです。
スイッチサイエンスさんで購入できます。

https://www.switch-science.com/catalog/5207/

M5Cameraについては、過去にレビューした記事があるので、参照してみてください。

M5Camera をレビューしてみた。分解したり、Arduino IDE でスマホに映したりする実験

WiFiルーター(アクセスポイント)環境

今回はM5CameraのESP32をSTAモード(外部ルーターを介すモード)で動作させますので、別途WiFiルーターが必要です。
予めWiFiルーターのMACアドレスフィルタリングやファイアウォール等の設定は済ませておきます。

パソコンおよびUSBケーブル

ここではWindows10パソコンを使いました。

M5Cameraとパソコンとは、USB Type-A と Type-C のケーブルを使って接続します。

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

事前にArduino IDEに、Arduino core for the ESP32をインストールしておきます。
インストール方法は以下の記事を参照してください。

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

動作確認済みバージョンは以下の通りです。

Arduino IDE 1.8.13
Arduino core for the ESP32 stable v1.0.5~v1.0.6

Arduino core for the ESP32 のボード設定

Arduino IDE のメニューの「ツール」のボード設定は以下で行いました。

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

1.対応OSおよびブラウザについて

ここでは主にWindows10を使用しましたので、それで説明しています。

ブラウザはGoogle Chrome(ver 89以降)とEdgeで動作確認しました。
FireFoxはうまく動作しませんでした。原因は不明です。

Android 10は問題無く動作しました。
ただ、後で詳しく述べますが、画像のダウンロード方法は少々特殊です。

iOSについては12.5.2で検証しましたが、HTMLの<a>要素のdownload属性でファイルのダウンロードができませんでした。
ただ、スナップショット画像を長押しすれば、写真(カメラロール)に保存できました。(ファイル名は指定できません)
この件については、後で詳細を述べます。
もしかしたら、iOS 13以降ならばすんなり保存できるかもしれません(未確認です)。

実は、ブラウザからスナップショットボタンを押したら、Google Driveの複数の指定フォルダへ画像を保存できるようにしたかったんです。
でも、ブラウザのセキュリティ上、複数の指定したフォルダへ保存ができないことがわかりました。
(Google Chrome 89.0.4389.90の場合)

結局、パソコンのデフォルトのダウンロードフォルダに保存するか、ブラウザの設定で保存先を変えて、ダウンロード先フォルダを一カ所のみ指定して保存するしかありませんでした。
複数の任意のフォルダへの保存したい場合は、結局は手動でコピーするしか無いようです。
それでも、Windows10やAndroid10の場合、スナップショット毎にファイル名を変えられて保存できるし、ファイル名が重複した場合はsample(1).jpg、sample(2).jpg、….というように自動で括弧付きナンバリングされるので、その後のフォルダ仕分けはさほど面倒な手間がかからないです。
よって、今回はこれでヨシとしました。
翌々は、ブラウザのボタンを押したら何かしらの方法で複数の指定したフォルダへ自動保存できるようにしたいですね。

この様にOSによって挙動が異なるし、そのバージョンによっても異なるので、クロスブラウザ対応というのは頭の痛い問題ですね。

2.Motion JPEG (MJPEG) 動画ストリーミングのおさらい

M5Cameraからの動画ストリーミングは、Motion JPEG (MJPEG)という方式を使います。

以前、こちらの6つの記事で、M5CameraからJPEG画像を連続してブラウザに送信して、パラパラ漫画のように表示させるMotion JPEG (MJPEG)動画ストリーミングを扱ってきましたが、今回は改めて事前知識としておさらいしておいた方が良いです。

ブラウザでストリーミングする詳しい流れはこちらの記事を参照してください。

3.videoやimg要素の画像をcanvas要素に表示させる

今までの過去の記事で扱ってきて分かっていたのですが、<img>要素に表示させただけでは、スナップショット静止画を取得して、その画像を保存できませんでした。
そこで、今回はJavaScriptを使ってもっと深掘りして実験してみることにしました。

いろいろ調べると、<img>要素に表示させた画像を<canvas>要素に表示させることができれば、そこからスナップショット画像を取得でき、パソコンやスマホに保存することができるようになることを突き止めました。

では、いきなりM5CameraやWebカメラで実験を始める前に、まずはパソコンのローカルファイルで動作確認してみます。

ですが、ローカルファイルからは<img>要素にMJPEG動画を表示させることが難しいので、<video>要素から<canvas>要素へ表示させる実験をします。
いろいろと問題点がありましたが、、、。

3-01. ブラウザではAVIやMOV、MP4ファイルのMotion JPEGコーデック動画は表示できなかった

(※これから述べることは、私自身、最近勉強したばかりの知識で、正直詳しくありません。誤ったことを言っているかも知れません。もし、お気づきの点がありましたらコメント投稿等でご連絡いただけると助かります。)

私は動画ファイルについて、今まで勘違いしていました。

AVIファイルや、MOVファイル、MP4ファイルなどがありますが、私はそれらのファイルはAVIならAVIコーデック、MP4ファイルならMP4コーデックでできていると思い込んでいました。

実はそれは全くの誤りで、MP4形式ファイルで言えば、MPEG-4コーデックの他、Motion JPEGコーデックにも対応していて、その他複数のコーデックに対応していたんです。
もちろん、AVIもMOVファイルもMotion JPEGコーデックに対応しています。

ならば、AVI形式ファイルや、MOV形式ファイルとはなんぞや? ということになりますよね。

実は、そのファイル形式はコンテナと呼ばれる、いわゆる「入れ物」だそうです。
コンテナには多くが複数のコーデックに対応しているようにできていて、音声データも入れられて、字幕データも同梱できるコンテナもあります。

へぇ~、、、!

動画ファイルって、あまりにも沢山種類があり過ぎて、調べるのも面倒でスルーしていました。
今まで私はパソコンのちょっとした動画編集ではそれらのファイルを扱ってはいたものの、数十年もの間、全く理解していませんでした。
Motion JPEGに限っては、M5Cameraでブラウザにストリーミングすることくらいしか扱ったことが無かったのです。

ならば、試しに動画編集ソフトでMotion JPEGコーデックのAVIファイルを作って、HTMLの<img>要素で表示させてみようと実験してみました。

が、しかし、全く表示できませんでした。

ま、これは、MDNの<img> 画像埋め込み要素ドキュメント記事を読めば明白で、<img>要素はAVI動画ファイルをサポートしていないと書いてありました。
それは表示されませんよね~、、、。

ならば、HTMLの<video>要素ならばできるのではないかと思ってやってみましたが、それでもMotion JPEGコーデックのAVIやMOVファイルは再生できませんでした。
ただし、MPEG-4コーデックならば表示できました。
この理由について公式なドキュメントは見つけられませんでしたが、一般のネット情報ではMotion JPEGコーデックは対応していないように見受けられました。

結局のところ、Motion JPEGコーデックのコンテナファイルはブラウザでは表示できない様です。
表示させるなら、MPEG-4などのコーデックに変換したものを使えというこのようです。

おいおい!

と、突っ込みたくなりますが、いろいろ事情があるようで、それ以上探りを入れないでおきます。

たぶん、ブラウザ側からの言い分としては、一つのファイルをロードする場合、Motion JPEGよりも遙かに圧縮率の高いファイルを使えと言っているのだと個人的に想像しました。

そう考えると、今の時代、Motion JPEGという方式は、とうの昔に廃れ果ててしまってもおかしくないですよね。

実は、Twitterで鎌ダークさんから情報頂いたのですが、MPEG関連のコーデックはライセンス問題があり、製品への組み込みに使いにくいそうです。
Mothion JPEGならばライセンス不要で利用し易いそうです。

なるほどな~、、、。

Motion JPEGが未だに多く使われている理由が少し分かった気がしました。

と、いうことで、Motion JPEG方式は、基本的にM5CameraなどのWebカメラで使う物と思っておけば良いのかなと思いました。

3-02. まずはvideo要素のMPEG-4動画をcanvas要素で表示させる

では、まずはパソコンとブラウザのみで試します。

前節で述べたように、<img>要素へのMotion JPEGコーデック動画ファイルは表示出来なかったので、まずはMPEG-4コーデックの動画ファイルを<video>要素に表示させて、それを<canvas>要素に表示させてみます。

私が独自に作った、MPEG-4コーデックのMPEG動画を以下に置いておきますので、ご自由にダウンロードして使って下さい。

https://github.com/mgo-tec/sample_videos/blob/master/MPEG4_codec/mp4_test.mp4

私はJavaScriptは素人ですが、とりあえず以下のコードを組んでみました。
このコードをテキストエディタで入力し、拡張子は.htmlで保存して置き、先のMP4動画ファイルと同じフォルダに保存して置きます。

<!DOCTYPE html>
<html>
<head>
  <meta name='viewport' content='width=device-width, initial-scale=1' charset='utf-8'>
</head>
<body>
  <div>
    video(160x120)<br>
    <video id='img_mjpeg' width='160' height='120' loop controls>
      <source src='./mp4_test.mp4' type='video/mp4'/>
      Sorry, your browser doesn't support embedded videos.
    </video>
  </div>
  <div>
    canvas(160x120)<br>
    <canvas id='canvas' width='160' height='120'></canvas>
  </div>
  <div>
    <button id='btn_start' onclick='startStream();'>Start Canvas</button>
    <button id='btn_stop' onclick='stopStream();'>Stop Canvas</button>
  </div>

  <script>
    var requestAnimationFrame = window.requestAnimationFrame ||
                                window.mozRequestAnimationFrame ||
                                window.webkitRequestAnimationFrame ||
                                window.msRequestAnimationFrame,
        cancelAnimationFrame =  window.cancelAnimationFrame ||
                                window.mozCancelAnimationFrame;
    var mjpeg = document.getElementById('img_mjpeg'),
        canvas = document.getElementById('canvas'),
        btn_start = document.getElementById('btn_start'),
        ctx = canvas.getContext('2d'),
        anime_req_id;

    function startStream(){
      //btn_start.disabled = true; //スタートボタンが連続で押されると、stopStream関数で停止できなくなるための対処
      loopAnime();
    }

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

    function stopStream(){
      //btn_start.disabled = false;
      cancelAnimationFrame(anime_req_id);
    }
  </script>
</body>
</html>

これをGoogle Chromeブラウザで開くと、下図のようになります。

(図03_02)

こんな感じで、video要素の動画をそっくりcanvas要素に動画表示することができれば占めたものです。
そこからスナップショット画像(静止画像)を得ることができます。

requestAnimationFrameがポイントで、loopAnime関数内の drawImage で video要素の画像を canvas要素へ再描画させて、requestAnimationFrame で loopAnime関数をループさせてcanvas要素をその都度更新してアニメーションを実現させています。

24-27行目では、requestAnimationFrameを、Google Chrome以外のFireFoxやSafariなどの他のブラウザで使うための設定(クロスブラウザ設定)です。

requestAnimationFrameを使う利点は、ブラウザで複数のタブを開いていて、この画面が隠れている時はパソコンの電力を減らすためにリフレッシュレートを低くするように動作するようです。
スマホで使う時には嬉しい機能ですね。
最大60fps(1秒間に60回)で動作するようです。

注意点は、「Start Canvas」ボタンを2回以上連続で押してしまうと、「Stop Canvas」ボタンを2回以上押さないと停止できないことです。
恐らく、私の想像では requestAnimationFrame関数が裏で別途実行されてしまうのではないかと思われます。
返り値の anime_req_id値を console.logで見てみたのですが、返り値だけでは requestAnimationFrame関数の実行状況は分かりませんでした。

結局のところ、「Start Canvas」ボタンを複数回押されないようにするためには、ボタンを押したときに btn_start.disabled を使って、 button要素を無効にすると個人的には良いと思いました。
このコードの37行目と49行目のコメントを解除(//を消去)して上書き保存し、ブラウザを更新すればそれが可能です。

因みに、MPEG-4コーデックの動画以外にも、Motion JPEGコーデックのAVIファイルやmovファイルも実験してみると良いと思います。
独自に作ったファイルをGitHubの以下のリンクに置いておきます。
https://github.com/mgo-tec/sample_videos/tree/master/Motion_jpeg_codec

<video>要素ではMotion JPEGコーデック動画は表示できないことが分かると思います。

4.canvas要素の動画(アニメーション)からスナップショット(静止画)をimg要素に書き出す

では、次は、<canvas>要素に表示された動画から、ボタンを押したらスナップショット(静止画)画像が表示されるようにしてみます。
それには、JavaScriptの toBlob() というメソッドを使います。
(toBlobのドキュメントはこちらを参照)

4-01. Blobとは?

Blob の意味は、ネットで調べればいくらでも出てきますので、ここでは簡単に触れておきます。

Blobとは、Binary Large Object の略だそうです。
私の個人的な解釈で言うと、<canvas>要素で表示された画像を0と1だけのバイナリデータに変換して、ブラウザが確保しているメモリに保存しておいたデータだと思えば良いのかなと思いました。

メモリに保存してあるということは、その場所のアドレスも存在すると考えれば理解しやすいのかも。

4-02. toBlob()メソッドについて

MDNのtoBlobメソッドドキュメントに書いてある使用例を参照すると、以下のように書いてあります。

var canvas = document.getElementById("canvas");

canvas.toBlob(function(blob) {
  var newImg = document.createElement("img"),
      url = URL.createObjectURL(blob);

  newImg.onload = function() {
    URL.revokeObjectURL(url);
  };

  newImg.src = url;
  document.body.appendChild(newImg);
});

これでは私の様なJavaScriptビギナーには分かり難いので、以下のように書き直すと、少しは理解し易くなるかも知れません。
つまり、toBlobメソッドのcallback関数を外に出しただけです。

var canvas = document.getElementById("canvas");

canvas.toBlob(myFunc);

function myFunc(blob) {
  var newImg = document.createElement("img"),
      url = URL.createObjectURL(blob);

  newImg.onload = function() {
    URL.revokeObjectURL(url);
  };

  newImg.src = url;
  document.body.appendChild(newImg);
}

callback関数というものが小難しいのですが、要するに、toBlob関数を実行すると、その中の引数に指定された関数(ここではmyFunc)が呼び出されるということです。

そして、もう一つ分かりにくいのが、blobという引数です。
この名称は自分の好きな名前に変えてもOKです。
要は、toBlob()の引数で呼び出されるmyFunc関数に適当な名前の引数を一つだけ使うと、その引数に自動的にBlobというオブジェクト、ここでは画像のバイナリデータが渡されるのです。
toBlob(myFunc) というところで、myFuncという関数名だけしか指定していませんが、その関数に自動的にBlobが渡されているというところがミソです。
JavaScript熟練者には当たり前のことかも知れませんが、私はこれを理解するまでにしばらく時間がかかりました。

さて、これで<canvas>要素のBlob化した画像データを取得できることが分かったので、そのデータのアドレスを取得したい場合は、URL.createObjectURL関数を使えば良いわけです。
それで取得したアドレスは、URLとして扱うことができます。
こんな感じのURLです。

blob:null/13ea4a75-c673-4641-9284-8b0c79a0db2d

これをブラウザのURL欄に貼り付ければ、その画像が表示されるわけです。

しかし、先ほどのコードでは問題点があります。
revokeObjectURL()メソッドを使ってしまうと、そのURLは消去されてしまい、表示できません。

ですから、もし、ブラウザのURL欄に貼り付けて画像を確認したければ、revokeObjectURL()メソッドは使わずにコメントアウトすれば良いです。
同様にスナップショット画像をダウンロードしたい場合には、画像のダウンロードが終わるまではrevokeObjectURL()メソッドを使わないようにプログラミング順序を考慮することが必要です。

revokeObjectURL()を無効にした場合は、スナップショットボタンを押す度にメモリを消費してしまう可能性がありますので、無効にしていたとしても、どこかで必ずrevokeObjectURL()を実行するようにプログラミングすれば良いと思います。

ところで、toBlob()メソッドは、デフォルトではpng画像として保存されます。
JPEG画像にしたければ、以下のようにすれば良いです。

canvas.toBlob(myFunc, 'image/jpeg');

4-03. 動画ソースのクロスオリジン(crossorigin)問題

では、toBlobメソッドを使って、canvas動画からスナップショット(静止画)を取得するコードを書いてみます。
以下の感じです。
ただし、ここではまず、誤ったコードを書いてみます。

<!DOCTYPE html>
<html>
<head>
  <meta name='viewport' content='width=device-width, initial-scale=1' charset='utf-8'>
</head>
<body>
  <div>
    video(160x120)<br>
    <video id='img_mjpeg' width='160' height='120' loop controls>
      <source src='./mp4_test.mp4' type='video/mp4'/>
      Sorry, your browser doesn't support embedded videos.
    </video>
  </div>
  <div>
    canvas(160x120)<br>
    <canvas id='canvas' width='160' height='120'></canvas>
  </div>
  <div>
    <button id='btn_start' onclick='startStream();'>Start Canvas</button>
    <button id='btn_stop' onclick='stopStream();'>Stop Canvas</button>
  </div>

  <script>
    var requestAnimationFrame = window.requestAnimationFrame ||
                                window.mozRequestAnimationFrame ||
                                window.webkitRequestAnimationFrame ||
                                window.msRequestAnimationFrame,
        cancelAnimationFrame =  window.cancelAnimationFrame ||
                                window.mozCancelAnimationFrame;
    var mjpeg = document.getElementById('img_mjpeg'),
        canvas = document.getElementById('canvas'),
        btn_start = document.getElementById('btn_start'),
        ctx = canvas.getContext('2d'),
        anime_req_id;

    function startStream(){
      //btn_start.disabled = true; //スタートボタンが連続で押されると、stopStream関数で停止できなくなるための対処
      loopAnime();
    }

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

    function stopStream(){
      //btn_start.disabled = false;
      cancelAnimationFrame(anime_req_id);
    }
  </script>
</body>
</html>

先ほどと同じように、このコードの拡張子を.htmlとして、同じフォルダにMPEG-4コーデックの動画ファイル(ここではmp4_test.mp4)を置いておきます。

これをブラウザで実行させてみると、下図のように、<canvas>要素に動画が表示されますが、スナップショットボタンを押してもエラーになり、静止画が表示されません。

(図04_03_01)

Windows10のGoogle Chromeのデベロッパーツールを開いて、エラーを確認してみると、以下のように表示されました。

(図04_03_02)

Uncaught DOMException: Failed to execute ‘toBlob’ on ‘HTMLCanvasElement’: Tainted canvases may not be exported.
(日本語直訳)
未解決の DOMException です。HTMLCanvasElement’に対する’toBlob’の実行に失敗しました。汚染されたキャンバスはエクスポートできません。

 

キャンバスが汚染されていると言っていますね。

このエラーが出る原因は、<video>要素で表示しているMPEG-4動画のURLと、<canvas>要素から取得したBlobデータの存在するURLが異なることによるものだそうです。このことをクロスオリジン(Cross-Origin)と言うそうです。
そうなると、キャンバスが汚染されることになるらしいです。

なぜクロスオリジンがダメなのかというと、ユーザーが信頼あるサイトのサーバーにアクセスしたのに、意図せずに勝手に別のサーバーからの情報が表示されるということは、セキュリティ上好ましくないからだそうです。
そりゃ、そうですよね。
それを悪用したものが「なりすまし」というものでしょう。

そこで、<video>要素の<source>タグに
crossOrigin='anonymous'
という属性を追加してみました。
これは、元の画像のURLはどこでもOKという、クライアント側からの指定です。

しかし、これだけではエラーは解消されません。

これを解消する方法は、MDNのこちらのドキュメントに書いてあります。

要は、<video>要素のsource属性を読み取ると、ブラウザ側からサーバーにcrossOrigin=’anonymous’属性でGETリクエストを送り、サーバー側からのレスポンスヘッダに、
Access-Control-Allow-Origin: *
というものを返さねばならないとのことです。

この様な方法を使えば、異なるオリジン間でリソースを共有してブラウザで表示させることができます。
このオリジン間リソース共有の手法のことをCORS( Cross-Origin Resource Sharing) というそうです。
CrossとCORS は間違えやすいスペルですね。
なかなか奥が深いです。

ただし、ここでの場合、ローカルのハードディスクとブラウザのメモリ間のオリジンソースなので、サーバーを使っていません。
ですから、レスポンスヘッダをブラウザに返すことが出来ませんので、このエラーは解消できないことになります。
もし、ローカル環境でレスポンスヘッダを返したいのならば、Apacheなどのローカル仮想サーバーを構築すれば良いと思います。

ここでは、ローカルサーバーを構築するのは割愛しますが、次ではWiFiマイコンモジュールのM5Cameraで簡易サーバーを作って、レスポンスヘッダを返してCORSを実現してみる実験をしてみます。

4-04. M5Cameraサーバーでimg要素のMotion JPEG動画のCrossOrigin属性を使って、スナップショット画像を取得してみる

前節では、ローカルの動画ファイル再生では、CrossOrigin問題によってBlobのスナップショット画像が得られませんでした。

ならば、M5Cameraをサーバーにして、レスポンスヘッダで
Access-Control-Allow-Origin: *
を返して、CrossOrigin問題を解消してみます。

4-04-01. M5Cameraサーバー側のスケッチ(プログラムソースコード)

では、Arduino IDE でM5Camera側のスケッチ(プログラムソースコード)を入力してみます。
今回はHTMLとJavaScriptコードは別途パソコン側のローカルファイルに保存して、Arduino IDEスケッチとは分離する方式にしました。

ポイントは、stream_handler関数の中で、レスポンスヘッダに
Access-Control-Allow-Origin: *
をセットしているところです。
クライアント側のリクエストをどこからでも受け付けるという意味です。
といっても、これはサンプルスケッチのCameraWebServerにあったものをほぼそのまま使っています。

(※ここに入力した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;
  if(!strcmp(id_txt, "start_stream")){
    canSendImage = false;
    canStartStream = true;
    isCloseConnection = false;
  }else if(!strcmp(id_txt, "stop_stream")){
    canStartStream = false;
    isCloseConnection = true;
  }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;
}

これは、サンプルスケッチCameraWebServerを基に作ったものです。
単純にストリーミング開始と終了命令だけで動作するので、300行以内のプログラムで済みました。

では、WiFiルーターを立ち上げ、M5CameraとパソコンをUSBケーブルで接続し、シリアルモニタを115200bpsで起動し、これをArduino IDEでM5Cameraにコンパイル書き込みします。
すると、下図のように表示されます。

(図04_04_03)

ローカルIPアドレスが表示されるので、それをメモっておき、次に紹介するHTMLおよびJavaScriptプログラミングに入力します。

4-04-02. ブラウザ側のHTML、JavaScript

では、ブラウザ側(クライアント側)のHTMLおよびJavaScriptコードは以下のようになります。
39行目のbase_urlを、ご自分のM5CameraのURLに書き換えます。

ポイントは、<img>要素に
crossOrigin='anonymous'
という属性をセットすることです。

<!DOCTYPE html>
<html>
<head>
  <meta name='viewport' content='width=device-width, initial-scale=1' charset='utf-8'>
</head>
<body>
  <div>
    MJPEG img(160x120)<br>
    <img crossOrigin='anonymous' id='img_mjpeg' width='160' height='120'>
  </div>
  <button id='btn_img_start' onclick='startStream();'>Start Stream</button>
  <button id='btn_img_stop' onclick='stopStream();'>Stop Stream</button>
  <div>
    canvas(160x120)<br>
    <canvas id='canvas' width='160' height='120'></canvas>
  </div>
  <div>
    <button id='btn_canv_start' onclick='startCanvas();'>Start Canvas</button>
    <button id='btn_canv_stop' onclick='stopCanvas();'>Stop Canvas</button>
  </div>
  <div id='div0'>
    <button id='btn0' onclick='snapShot();'>snap shot</button>&nbsp;<a id='a0' download='sample.jpg'>Download</a><br>
    <img id='snap_img'>
  </div>

  <script>
    var requestAnimationFrame = window.requestAnimationFrame ||
                                window.mozRequestAnimationFrame ||
                                window.webkitRequestAnimationFrame ||
                                window.msRequestAnimationFrame,
        cancelAnimationFrame =  window.cancelAnimationFrame ||
                                window.mozCancelAnimationFrame;
    var mjpeg = document.getElementById('img_mjpeg'),
        snap_img = document.getElementById('snap_img'),
        canvas = document.getElementById('canvas'),
        btn_img_start = document.getElementById('btn_img_start'),
        btn_canv_start = document.getElementById('btn_canv_start'),
        ctx = canvas.getContext('2d'),
        base_url = 'http://192.168.0.xxx',
        stream_port = 81,
        mjpeg_url = base_url + ':' + stream_port + '/stream',
        anime_req_id = 0;

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

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

    function startCanvas(){
      btn_canv_start.disabled = true; //スタートボタンが連続で押されると、stopStream関数で停止できなくなるための対処
      loopAnime();
    }

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

    function stopCanvas(){
      btn_canv_start.disabled = false;
      cancelAnimationFrame(anime_req_id);
    }

    function snapShot(){
      canvas.toBlob(function(blob){
        let snap_img = document.getElementById('snap_img'),
            a_elm = document.getElementById('a0'),
            url = URL.createObjectURL(blob);
        a_elm.href = url;
        snap_img.src = url;
        console.log(url);
      }, 'image/jpeg'); //"image/jpeg"と記述しなければデフォルトのpng画像となる
    }

    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));
    }
  </script>
</body>
</html>

39行目のbase_urlには、先ほどのシリアルモニタに表示されたIPアドレスを入力します。

Motion JPEG動画ストリーミング開始の流れは以下です。
(基本的なMotion JPEGストリーミングについては、過去の以下の記事も参照してみて下さい。)
Arduino core ESP32 で Motion JPEG ストリーミングを実現するザッとした流れ

1.「Start Stream」ボタンを押す。

2.ブラウザからM5Cameraサーバーへポート80番でストリーミングスタートコマンドのGETリクエストが送られる。URLに特に何も指定しなければ、デフォルトでポート80番を使うことになります。

3.<img>要素のsrc属性のURLが書き換えられて、M5Cameraサーバーへポート81番を使ってMotion JPEGストリーミングのGETリクエストが送られる。

4.M5Cameraサーバーから Access-Control-Allow-Origin を含むレスポンスヘッダをブラウザに返信し、Motion JPEG動画ストリーミングコネクションが確立。そしてストリーミングがスタートする。

ここでのポイントは<img>要素のURLに、getTime()メソッドを使った時刻(UNIX 元期からの経過ミリ秒数)を加えていることです。

どうしてこんなことをしているのかというと、ストリームを停止する時に「Stop Stream」ボタンを押したあと、改めて「Start Stream」ボタンを押すと、<img>要素のsrc属性のURLに変化が無い場合はストリーミングが再スタートしない不具合を起こさないようにするためです。

その理由は、<img>要素から一度GETリクエストしたURLからのデータは、ブラウザがメモリに確保しているため、新たに全く同じURLでリクエストしてしまうと、ブラウザが不要と判断する為だと想像できます。

ならば、「Start Stream」ボタンを押す度に、srcのURLを毎回異なるアドレスにすれば良いわけです。
それには乱数を用いるという手も考えられますが、私の場合はgetTime()メソッドを使って、毎回必ず異なる数値になるようにしました。
getTime()メソッドは、現在時刻を1970 年 1 月 1 日 00:00:00 UTCからの経過時間をミリ秒で返してくれます。つまり、直前に押したボタンの時刻と一致することはあり得ないのです。
この時間数値をURLに含めれば、毎回必ず異なるURLを作ることができます。

M5Cameraサーバー側は、送信されてきたURL文字列のうち、/stream までしか検索せず、後の時刻は無視しているので、特に新たなコードを組む必要はありません。

因みに、ここでの<img>要素タグにはsrc属性を付けていませんが、49行目のmjpeg.srcのところで、src属性を動的に付加しています。

「snap shot」ボタンを押すと、先ほど紹介したtoBlobメソッドを使って、<canvas>要素の画像をバイナリオブジェクト(Blob)化してメモリに保存し、そのURLアドレスを返してきます。
そのアドレスを<img>要素のsrc属性に加えて、スナップショット画像を表示させています。
そして、そのアドレスを<a>要素のハイパーリンクに設定に流用して、download属性にすることで、メモリに保存されたBlobデータがダウンロードできるようになるというわけです。

(図04_04_04)

5.画像データのダウンロードについて

先ほど説明した、toBlobメソッドで取得した画像データのダウンロードは、Windows10の場合、<a>要素のテキストをクリックすると、デフォルトで設定されたダウンロード用フォルダに保存できます。
前章のプログラムでは、sample.jpg というファイル名で保存しています。

5-01. Windows10のChromeで名前を付けて保存させる方法

もし、「Download」をクリックした時点で、「名前を付けて保存」ウィンドウを表示させて保存させたい場合、Google Chromeの場合は以下の設定をする必要があります。

まず、Chromeブラウザの「設定」を開きます。
すると、下図の様な画面になるので、一番下の方の「詳細設定」をクリックします。

(図05_01_01)

すると、下図の様な画面になるので、デフォルトの保存先フォルダを任意で変更し、その下の「ダウンロード前に各ファイルの保存場所を確認する」という所をONにします。

(図05_01_02)

すると、<a>要素テキストの「Download」をクリックすると、下図の様な「名前を付けて保存」ウィンドウが開くようになります。

(図05_01_03)

5-02. Android10 スマホの場合のBlobデータダウンロード

スマホのAndroid 10 の場合、テキストエディタで作ったhtmlファイルをGoogle Drive等に保存し、AndroidスマホからGoogle Driveを開いて、htmlファイルをダウンロードしておきます。

おそらくデフォルトでダウンロードフォルダにダウンロードされていると思いますので、ファイルアプリでそのhtmlファイルをGoogle Chromeで開けば良いわけです。

さて、画像をダウンロードする時に、Google Chromeのデフォルトでは自動でスマホ端末のダウンロードフォルダに保存される設定になっていますが、名前を付けて保存させたい場合は、下図のようにGoogle Chromeの「設定」画面を開きます。
そして、下の方にスクロールさせると、「ダウンロード」という項目があるので、そこをタップします。

(図05_02_01)

すると、下図の様な画面になるので、「ファイルの保存場所を確認する」をONにすれば良いです。

(図05_02_02)

そうしたら、「設定」画面を閉じて、先ほどのhtmlファイルを再読み込みして、M5Cameraのストリーミングを開始して、スナップショット画像を取得し、「Download」をタップします。
すると、下図の様な画面になります。

(図05_02_03)

ファイル名は任意に変更可能ですが、ダウンロード先はスマホ本体か、SDカードしか選べませんでした。
スマホならば個人的にそれで充分かと思います。

5-03. iPhoneやiPadなどのiOS(12.5)の場合のBlobデータダウンロード

iPhoneやiPadなどのiOSの場合でもこれができるか検証してみました。
ただ、私の場合はiOS(12.5.2)しか持っていないので、他のバージョンの動作状況は不明です。

htmlファイルの開く方法は、Windows10パソコンで作ったhtml形式ファイルを、iCloud Driveのルートにコピーしておきます。

ただ、それだけではhtmlファイルを開けませんので、私の場合は、Documentsアプリでhtmlファイルを開くことが出来ました。
その他、ちょっと使いにくいですが、Dropboxでもhtmlファイルを開くことができます。
iOSの場合、サクッとSafariで開けないので、ちょっと面倒です。

さて、iOSの場合、古いバージョンではtoBlobメソッドが非対応だったのですが、最近のバージョンは使えるようになったようです。

ただし、<a>要素のdownload属性でファイル名を指定してローカルフォルダに自動保存することはできないようです。

その代わり、Blobデータを書き出した<img>要素の画像を長押しすると、下図のように保存できるようになります。

(図05_03)

「イメージを保存」をタップすると、写真(カメラロール)に保存されます。
保存されたファイル名は好きな名前にできませんでした。
でも、保存できさえすれば、何かと使えると思いました。
ブラウザに関してはやはりWindowsやAndroidの方が使い易いですね。

では、次のページでは、M5Cameraの動画から10個のスナップショット画像を取得するプログラミングをしてみます。

コメント

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