こんにちは、テリーです。先日引っ越した新居に最新の防犯カメラがついていました。玄関に人や犬・ネコ、自転車、車が通ると、時刻と拡大画像が保存され、対象物が写っている期間の動画だけを見る機能があります。監視カメラは何かよくないことがあったときに見返すものですが、不審者が写っているであろう数秒を探し出すのに先頭から全部見るのはとても大変です。そのため、時刻が頭出しされているだけでもとても便利です。人の場合は顔だけ拡大して保存されますし、ストレージの容量も節約できそうです。最近は、ペット用カメラ、赤ちゃん用カメラ、ドローンなどでも、撮影側でAI画像解析し、別の端末へ結果を送信するユースケースは多く見られます。今回は、AI画像解析とライブ配信を併用するケースについて、サンプルを作ってみました。
ライブ配信を併用する場合、撮影側と受信側では画質が異なります。撮影側ではカメラの性能通りの画質です。受信側では、映像の圧縮処理に加えて、通信環境の影響を受けるため、撮影したのと同等の画質になることはほぼありません。電気(電池)と重量と発熱に制限のある撮影側で、あえてAI画像解析をするのはそういった事情があります。

動作確認環境

本記事は以下の環境にて動作を確認しています。「Insertable Streams for MediaStreamTrack」という実験的機能を使用しているため、Chrome以外のブラウザでは動作しない可能性があります。

  • macOS 12.6
  • Chrome 107.0.5304.121
  • firebase 9.14.0

ローカルでのAI画像解析

物体検出、人体検出、顔検出などさまざまなAIがありますが、本記事では、AIの初期化コードを短くするため、Chromeブラウザに標準搭載されているバーコード検出機能を使用します。リアルタイムで処理するAIは、どれも本記事の方法が使用できます。
まずは、ローカル環境でAI検出させてみましょう。

  1. <div>
  2.     <button id="startButton">Start</button>
  3.     <button id="stopButton">Stop</button>
  4. </div>
  5. <video id="localVideo" playsinline autoplay muted style='height:320px'></video>
  6. <script type="module">
  7. async function onStart(){
  8.     const devices = await navigator.mediaDevices.enumerateDevices();
  9.     console.log(devices);
  10.     const deviceId = devices.find(e=>e.kind=='videoinput' && e.label.includes('FaceTime'))?.deviceId;
  11.     let video = {deviceId};
  12.     window.stream = await navigator.mediaDevices.getUserMedia({ video });
  13.     const transformer = new TransformStream({
  14.         start(controller) {
  15.             window.barcodeDetector = new BarcodeDetector({ formats: ["qr_code"] });
  16.             window.frameId = 0;
  17.         },
  18.         flush(controller) {
  19.             controller.terminate();
  20.         },
  21.         async transform(videoFrame, controller) {
  22.             const timestamp = videoFrame.timestamp;
  23.             const width = videoFrame.displayWidth;
  24.             const height = videoFrame.displayHeight;
  25.             const bitmap = await createImageBitmap(videoFrame);
  26.             const barcodes = await window.barcodeDetector.detect(bitmap);
  27.             bitmap.close();
  28.             videoFrame.close();
  29.             if(barcodes==null || barcodes.length==0){
  30.                 return;
  31.             }
  32.             const report = {
  33.               frameId: window.frameId++,
  34.               created_at : Date.now(),
  35.               timestamp, width, height,
  36.               barcodes: JSON.parse(JSON.stringify(barcodes)),
  37.             };
  38.             console.log(timestamp, report);
  39.         }
  40.     });
  41.     const videoTrack = window.stream.getVideoTracks()[0];
  42.     const trackProcessor = new MediaStreamTrackProcessor({ track: videoTrack });
  43.     trackProcessor.readable.pipeThrough(transformer).pipeTo(new WritableStream());
  44.     const localVideo = document.getElementById('localVideo');
  45.     localVideo.srcObject = window.stream;
  46. }
  47. async function onStop(){
  48.     window.stream.getTracks().forEach(track => {
  49.       track.stop();
  50.     });
  51. }
  52. document.getElementById('startButton').onclick=onStart;
  53. document.getElementById('stopButton').onclick=onStop;
  54. </script>

主要箇所を説明します。

14.         start(controller) {
15.             window.barcodeDetector = new BarcodeDetector({ formats: [“qr_code”] });
16.             window.frameId = 0;
17.         },
18.         flush(controller) {
19.             controller.terminate();
20.         },

14行目、start関数はコンストラクタに相当します。
15行目、QRコード検出AIを初期化しています。
16行目、フレーム番号をカウントする変数を初期化します。
18行目、flush関数はデストラクタに相当します。

21.         async transform(videoFrame, controller) {
22.             const timestamp = videoFrame.timestamp;
23.             const width = videoFrame.displayWidth;
24.             const height = videoFrame.displayHeight;
25.             const bitmap = await createImageBitmap(videoFrame);
26.             const barcodes = await window.barcodeDetector.detect(bitmap);
27.             bitmap.close();
28.             videoFrame.close();
29.             if(barcodes==null || barcodes.length==0){
30.                 return;
31.             }
32.             const report = {
33.               frameId: window.frameId++,
34.               created_at : Date.now(),
35.               timestamp, width, height,
36.               barcodes: JSON.parse(JSON.stringify(barcodes)),
37.             };
38.             console.log(timestamp, report);
39.         }

21行目のtransform関数は、映像フレーム処理をします。本サンプルの一番重要なところです。第一引数に画像データなどが入っています。
25行目で、映像フレームをビットマップとして取り出します。
26行目でAI画像解析処理としてバーコードを検出し、見つかった場合はその座標などを返します。
27行目でビットマップのメモリを解放。28行目でビデオフレームのメモリを解放。この2件のメモリ解放を忘れると、長期稼働させた際にエラーになります。
36行目は、QRコードの検出結果をディープコピーしています。
38行目で、検出した結果をコンソールに出力します。

41.     const videoTrack = window.stream.getVideoTracks()[0];
42.     const trackProcessor = new MediaStreamTrackProcessor({ track: videoTrack });
43.     trackProcessor.readable.pipeThrough(transformer).pipeTo(new WritableStream());

41-43行目で、ビデオトラックとフレーム処理を関連づけます。
実行すると、このような画面になります。

AI画像解析結果の保存

次に、解析した結果をリモートのDBに保存します。firebase realtime databaseを使用します。通信エラーなどにも対応しているため、解析結果の保存処理を手軽に実装することができます。
赤い文字の行が追記した箇所です。

1. <div>
2.     <button id=“startButton”>Start</button>
3.     <button id=“stopButton”>Stop</button>
4. </div>
5. <video id=“localVideo” playsinline autoplay muted style=‘height:320px’></video>
6. <script type=“module”>
7. import { initializeApp } from “https://www.gstatic.com/firebasejs/9.14.0/firebase-app.js”;
8. import { getDatabase, ref, push } from “https://www.gstatic.com/firebasejs/9.14.0/firebase-database.js”;
9. const app = initializeApp({ databaseURL: ‘https://*HOGE*.firebaseio.com/‘ });
10. const database = getDatabase(app); 
11. async function onStart(){
12.     const devices = await navigator.mediaDevices.enumerateDevices();
13.     console.log(devices);
14.     const deviceId = devices.find(e=>e.kind==‘videoinput’ && e.label.includes(‘FaceTime’))?.deviceId;
15.     let video = {deviceId};
16.     window.stream = await navigator.mediaDevices.getUserMedia({ video });
17.     const transformer = new TransformStream({
18.         start(controller) {
19.             window.barcodeDetector = new BarcodeDetector({ formats: [“qr_code”] });
20.             window.frameId = 0;
21.             const date = new Date();
22.             const dir = date.getFullYear()
23.             + (‘0’ + (date.getMonth() + 1)).slice(-2)
24.             + (‘0’ + date.getDate()).slice(-2)
25.             + (‘0’ + date.getHours()).slice(-2)
26.             window.reportRef = ref(database, ‘report/‘ + dir); 
27.         },
28.         flush(controller) {
29.             controller.terminate();
30.         },
31.         async transform(videoFrame, controller) {
32.             const timestamp = videoFrame.timestamp;
33.             const width = videoFrame.displayWidth;
34.             const height = videoFrame.displayHeight;
35.             const bitmap = await createImageBitmap(videoFrame);
36.             const barcodes = await window.barcodeDetector.detect(bitmap);
37.             bitmap.close();
38.             videoFrame.close();
39.             if(barcodes==null || barcodes.length==0){
40.                 return;
41.             }
42.             const report = {
43.               frameId: window.frameId++,
44.               created_at : Date.now(),
45.               timestamp, width, height,
46.               barcodes: JSON.parse(JSON.stringify(barcodes)),
47.             };
48.             console.log(timestamp, report);
49.             push(window.reportRef, report); 
50.         }
51.     });
52.     const videoTrack = window.stream.getVideoTracks()[0];
53.     const trackProcessor = new MediaStreamTrackProcessor({ track: videoTrack });
54.     trackProcessor.readable.pipeThrough(transformer).pipeTo(new WritableStream());
55.     const localVideo = document.getElementById(‘localVideo’);
56.     localVideo.srcObject = window.stream;
57. }
58. async function onStop(){
59.     window.stream.getTracks().forEach(track => {
60.       track.stop();
61.     });
62. }
63. document.getElementById(‘startButton’).onclick=onStart;
64. document.getElementById(‘stopButton’).onclick=onStop;
65. </script>

主要箇所を説明します。

7. import { initializeApp } from "https://www.gstatic.com/firebasejs/9.14.0/firebase-app.js";
8. import { getDatabase, ref, push } from "https://www.gstatic.com/firebasejs/9.14.0/firebase-database.js";
9. const app = initializeApp({ databaseURL: 'https://*HOGE*.firebaseio.com/' });
10. const database = getDatabase(app);

7行目から10行目はfirebaseの初期化です。
9行目のdatabaseURLを、各自の環境に読み替えてください。このままでは動作しません。

21.             const date = new Date();
22.             const dir = date.getFullYear()
23.             + ('0' + (date.getMonth() + 1)).slice(-2)
24.             + ('0' + date.getDate()).slice(-2)
25.             + ('0' + date.getHours()).slice(-2)
26.             window.reportRef = ref(database, 'report/' + dir);

21-26行目で、年月日時のディレクトリ名文字列を作成し、その下にデータを出力するfirebaseの参照を取得します。

49.             push(window.reportRef, report);

49行目、firebaseに追加保存します。
firebase consoleでデータを確認すると、下記のように保存されていることがわかります。通信速度を測ってみると、約200-250kbpsでした。firebase realtime databaseは、内部でWebSocketを使用していて最適化はされていると思いますが、自前で実装すればもう少し帯域は節約できます。

映像配信機能の追加

次に映像配信機能を追加します。AIで画像解析を実行し、firebaseに保存しつつ、WebRTCで配信をおこないます。
赤い文字の行が追記した箇所です。
10行目と60-61行目を各自の環境に読み替えてください。

1. <div>
2.     <button id="startButton">Start</button>
3.     <button id="stopButton">Stop</button>
4. </div>
5. <video id="localVideo" playsinline autoplay muted style='height:320px'></video>
6. <script src="https://cdn.jsdelivr.net/npm/sora-js-sdk/dist/sora.min.js"></script>
7. <script type="module">
8. import { initializeApp } from "https://www.gstatic.com/firebasejs/9.14.0/firebase-app.js";
9. import { getDatabase, ref, push } from "https://www.gstatic.com/firebasejs/9.14.0/firebase-database.js";
10. const app = initializeApp({ databaseURL: 'https://*HOGE*.firebaseio.com/' });
11. const database = getDatabase(app);
12. async function onStart(){
13.     const devices = await navigator.mediaDevices.enumerateDevices();
14.     console.log(devices);
15.     const deviceId = devices.find(e=>e.kind=='videoinput' && e.label.includes('FaceTime'))?.deviceId;
16.     let video = {deviceId};
17.     window.stream = await navigator.mediaDevices.getUserMedia({ video });
18.     const videoTrack = window.stream.getVideoTracks()[0];
19.     const transformer = new TransformStream({
20.         start(controller) {
21.             window.barcodeDetector = new BarcodeDetector({ formats: ["qr_code"] });
22.             window.frameId = 0;
23.             const date = new Date();
24.             const dir = date.getFullYear()
25.             + ('0' + (date.getMonth() + 1)).slice(-2)
26.             + ('0' + date.getDate()).slice(-2)
27.             + ('0' + date.getHours()).slice(-2)
28.             // + ('0' + date.getMinutes()).slice(-2)
29.             // + ('0' + date.getSeconds()).slice(-2)
30.             window.reportRef = ref(database, 'report/' + dir);
31.         },
32.         flush(controller) {
33.             controller.terminate();
34.         },
35.         async transform(videoFrame, controller) {
36.             const timestamp = videoFrame.timestamp;
37.             const bitmap = await createImageBitmap(videoFrame);
38.             const barcodes = await window.barcodeDetector.detect(bitmap);
39.             const width = videoFrame.displayWidth;
40.             const height = videoFrame.displayHeight;
41.             bitmap.close();
42.             videoFrame.close();
43.             if(barcodes==null || barcodes.length==0){
44.                 return;
45.             }
46.             const report = {
47.               frameId: window.frameId++,
48.               created_at : Date.now(),
49.               timestamp, width, height,
50.               barcodes: JSON.parse(JSON.stringify(barcodes)),
51.             };
52.             console.log(timestamp, report);
53.             push(window.reportRef, report);
54.         }
55.     });
56.     const trackProcessor = new MediaStreamTrackProcessor({ track: videoTrack });
57.     trackProcessor.readable.pipeThrough(transformer).pipeTo(new WritableStream());
58.     const localVideo = document.getElementById('localVideo');
59.     localVideo.srcObject = window.stream;
60.     const sora = Sora.connection("wss://*HOGE*.imageflux.jp/signaling");
61.     const channelId = "*HOGEHOGE*";
62.     window.sendonly = sora.sendonly(channelId, null, { multistream: true, videoBitRate: 100. });
63.     await window.sendonly.connect(window.stream);
64. }
65. async function onStop(){
66.     window.stream.getTracks().forEach(track => {
67.       track.stop();
68.     });
69.     await window.sendonly.disconnect();
70. }
71. document.getElementById('startButton').onclick=onStart;
72. document.getElementById('stopButton').onclick=onStop;
73. </script>

主要箇所を説明します。

60.     const sora = Sora.connection("wss://*HOGE*.imageflux.jp/signaling");
61.     const channelId = "*HOGEHOGE*";
62.     window.sendonly = sora.sendonly(channelId, null, { multistream: true, videoBitRate: 100. });
63.     await window.sendonly.connect(window.stream);

60-63行目はWebRTCの接続です。
60-61行目の値はこのままでは動作しません。ImageFlux LiveStreamingのAPIで発行する接続先(signaling_url)と接続名(channel_id)に置き換えてください。
62行目では、低ビットレートを強調するためにあえて100kbpsを指定しています。

AI解析結果の反映

リモートカメラ側で解析した結果を、WebRTCで受信した映像に強調表示させてみましょう。検出したQRコードを赤い四角で囲み、QRコード内の情報をQRコードの下部に表示します。
10行目と87-88行目を各自の環境に読み替えてください。

1. <div>
2.     <button id="startButton">Start</button>
3.     <button id="stopButton">Stop</button>
4. </div>
5. <video id="localVideo" playsinline autoplay muted style='height:320px'></video>
6. <script src="https://cdn.jsdelivr.net/npm/sora-js-sdk/dist/sora.min.js"></script>
7. <script type="module">
8. import { initializeApp } from "https://www.gstatic.com/firebasejs/9.14.0/firebase-app.js";
9. import { getDatabase, ref, push, onChildAdded } from "https://www.gstatic.com/firebasejs/9.14.0/firebase-database.js";
10. const app = initializeApp({ databaseURL: 'https://*HOGE*.firebaseio.com/' });
11. const database = getDatabase(app);
12. const canvas = new OffscreenCanvas(1, 1);
13. const ctx = canvas.getContext("2d");
14. async function highlightBarcode(videoFrame, originalWidth, originalHeight, barcodes) {
15.     const timestamp = videoFrame.timestamp;
16.     const bitmap = await createImageBitmap(videoFrame);
17.     videoFrame.close();
18.     canvas.width = originalWidth;
19.     canvas.height = originalHeight;
20.     ctx.strokeStyle = "red";
21.     ctx.fillStyle = "red";
22.     ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
23.     bitmap.close();
24.     barcodes.map(barcodes => {
25.       const { x, y, width, height } = barcodes.boundingBox;
26.       ctx.strokeRect(Math.floor(x), Math.floor(y), Math.floor(width), Math.floor(height));
27.       const text = barcodes.rawValue;
28.       const dimensions = ctx.measureText(text);
29.       ctx.fillText(
30.         text,
31.         Math.floor(x + width / 2. - dimensions.width / 2),
32.         Math.floor(y) + height + 20
33.       );
34.     });
35.     const newBitmap = await createImageBitmap(canvas);
36.     return new VideoFrame(newBitmap, { timestamp });
37. }
38.
39. async function process(){
40.     const transformer = new TransformStream({
41.         start(controller) {
42.             const date = new Date();
43.             const dir = date.getFullYear()
44.             + ('0' + (date.getMonth() + 1)).slice(-2)
45.             + ('0' + date.getDate()).slice(-2)
46.             + ('0' + date.getHours()).slice(-2)
47.             window.reportRef = ref(database, 'report/' + dir);
48.             window.onChildAddedUnsubscribe = onChildAdded( window.reportRef, (snapshot) => {
49.                 const m = snapshot.val();
50.                 if(!m) return;
51.                 const report = {
52.                     width: m.width,
53.                     height: m.height,
54.                     barcodes: m.barcodes,
55.                 }
56.                 window.latestReport = report;
57.             });
58.         },
59.         flush(controller) {
60.             window.onChildAddedUnsubscribe?.();
61.             window.onChildAddedUnsubscribe = null;
62.             controller.terminate();
63.         },
64.         async transform(videoFrame, controller) {
65.             if(!window.latestReport){
66.                 controller.enqueue(videoFrame);
67.                 return;
68.             }
69.             const newFrame = await highlightBarcode(
70.                 videoFrame,
71.                 window.latestReport.width,
72.                 window.latestReport.height,
73.                 window.latestReport.barcodes,
74.             );
75.             window.latestReport = null;
76.             controller.enqueue(newFrame);
77.         }
78.     });
79.     const videoTrack = window.stream.getVideoTracks()[0];
80.     const trackProcessor = new MediaStreamTrackProcessor({ track: videoTrack });
81.     const trackGenerator = new MediaStreamTrackGenerator({ kind: 'video' });
82.     trackProcessor.readable.pipeThrough(transformer).pipeTo(trackGenerator.writable);
83.     const localVideo = document.getElementById('localVideo');
84.     localVideo.srcObject = new MediaStream([trackGenerator]);
85. }
86. async function onStart(){
87.     const sora = Sora.connection("wss://*HOGE*.imageflux.jp/signaling");
88.     const channelId = "*HOGEHOGE*";
89.     window.recvonly = sora.recvonly(channelId, null, { multistream: true });
90.     window.recvonly.on("track", (event) => {
91.         window.stream = event.streams[0];
92.         process();
93.     });
94.     await window.recvonly.connect();
95. }
96. async function onStop(){
97.     window.stream.getTracks().forEach(track => {
98.       track.stop();
99.     });
100.     await window.recvonly.disconnect();
101. }
102. document.getElementById('startButton').onclick=onStart;
103. document.getElementById('stopButton').onclick=onStop;
104. </script>

主要箇所を説明します。

48.             window.onChildAddedUnsubscribe = onChildAdded( window.reportRef, (snapshot) => {
49.                 const m = snapshot.val();
50.                 if(!m) return;
51.                 const report = {
52.                     width: m.width,
53.                     height: m.height,
54.                     barcodes: m.barcodes,
55.                 }
56.                 window.latestReport = report;
57.             });

48-57行目、firebaseでデータが追加されると、リアルタイムで最新のデータが届きます。

79.     const videoTrack = window.stream.getVideoTracks()[0];
80.     const trackProcessor = new MediaStreamTrackProcessor({ track: videoTrack });
81.     const trackGenerator = new MediaStreamTrackGenerator({ kind: 'video' });
82.     trackProcessor.readable.pipeThrough(transformer).pipeTo(trackGenerator.writable);
83.     const localVideo = document.getElementById('localVideo');
84.     localVideo.srcObject = new MediaStream([trackGenerator]);

79-84行目、受信した映像の画像フレームを1枚ずつ処理し、映像を書き換えまたは差し替えるための記述方法です。「Insertable Streams for MediaStreamTrack」という、Chromeの新しい機能を使用しています。
実行すると、このような画面になります。QRコードなのでこの画質でも検出できる可能性はありますが、例えば画面内の青いボタンの文字「共有...」と書かれている箇所などは完全に潰れています。

まとめ

ImageFlux Live Streamingでライブ映像配信をしながら、画像解析結果を送受信し、リアルタイムに表示する方法を紹介しました。通信環境が不安定なとき、通信コストを少しでも安くしたいとき、映像を送れないとき、今回ご紹介したコードは参考にできると思います。顔検出、人物特定、人体検出、ペット検出など使い道はたくさんありそうです。他のAIでの実装をぜひ挑戦してみてください。

執筆
テリー (秋月 徹)

テリー (秋月 徹)

2002年よりケータイ向けライブ配信システムの開発・運用・販売を行い、プロ野球中継、音楽ライブコンサート、公営ギャンブル等の大規模ライブ配信システムの構築を担当。趣味のハッカソンでは得意の動画処理、映像処理を使ったサービスを短時間で考案・開発し、多数の優勝経験を持つ。

2024年2月公開