はじめに
この記事は OpenCV Advent Calendar 2022 の19日目の記事です。
こんにちは。フリーランスのウェブフロントエンドエンジニア、@ki_230 です。
先日、カメラで撮影した映像に対し、リアルタイムでグリーンバックを使ったクロマキー合成を行う機会がありました。
OpenCVを使えば余裕でできるだろうなとは思ったのですが、システムの都合上ウェブページ上で動作すると最高だったので、OpenCV.jsを試すことにしました。
今回は検証用につくった、グリーンバック撮影用のウェブページのモックを紹介しつつ、OpenCV.jsの可能性を探ります。
今回つくったもの
※ 指定した色が白く塗りつぶされます
URL 👉 https://kimizuka.github.io/opencv/greenback/
カメラに映った特定の色を、リアルタイムに除去するウェブサイトをつくりました。
(DEMOなので、該当の色を白で塗りつぶしています)
手元のiPhone 12miniで試しましたが、スマートフォンでも余裕で動作しました。
一応右下のスライダーが「H」「S」「V」の値になっており、リアルタイムに範囲を調整することもできます。
動作原理
❶ navigator.mediaDevices.getUserMediaでカメラ映像のストリームを取得
❷ 取得したストリームをVideo要素のsrcObjectに代入
❸ Video要素をCanvas要素にレンダリング
❹ Canvas要素からMatを作成
❺ MatをHSVに変換し、指定の色領域のみ表示する
❻ ❸と❺を重ね、lightenでブレンド
という流れで動作しています。
ソースコード
index.html
<!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <title>OpenCV - GreenBack</title> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, shrink-to-fit=no" /> <link rel="stylesheet" href="index.css" /> </head> <body> <div id="wrapper"> <div> <video id="video" autoplay playsinline muted></video> </div> <div id="ui"> <select id="select"> <option value="red">red</option> <option value="red2">red2</option> <option value="blue">blue</option> <option value="green" selected>green</option> <option value="yellow">yellow</option> </select> <div class="min-max"> <dl> <dt> <p>MIN</p> </dt> <dd> <label> <span>0</span> <input type="range" min="0" max="255" step="1" value="0" /> </label> <label> <span>0</span> <input type="range" min="0" max="255" step="1" value="0" /> </label> <label> <span>0</span> <input type="range" min="0" max="255" step="1" value="0" /> </label> </dd> </dl> <dl> <dt> <p>MAX</p> </dt> <dd> <label> <span>0</span> <input type="range" min="0" max="255" step="1" value="0" /> </label> <label> <span>0</span> <input type="range" min="0" max="255" step="1" value="0" /> </label> <label> <span>0</span> <input type="range" min="0" max="255" step="1" value="0" /> </label> </dd> </dl> </div> </div> <div class="canvas"> <canvas id="src"></canvas> <canvas id="dist"></canvas> </div> </div> <script src="index.js"></script> <script src="https://docs.opencv.org/4.5.0/opencv.js"></script><!--ビルド済みのものを使用--> </body> </html>
index.js
const Module = { onRuntimeInitialized() { const medias = { audio: false, video: { facingMode: { exact: 'environment' } // リアカメラを立ち上げる https://blog.kimizuka.org/entry/2021/01/07/235209 } }; navigator.mediaDevices.getUserMedia(medias).then(successCallback) .catch(errorCallback); } }; function successCallback(stream) { // ❶ navigator.mediaDevices.getUserMediaのコールバックでstreamを取得 const video = document.getElementById('video'); const select = document.getElementById('select'); const srcCanvas = document.getElementById('src'); const srcCtx = srcCanvas.getContext('2d'); const spans = document.querySelectorAll('#ui .min-max span'); let minMat; let maxMat; select.addEventListener('input', (evt) => { const red1MinMat = cv.matFromArray( 1, 3, cv.CV_8UC1, [ 0, 64, 0 ] ); const red1MaxMat = cv.matFromArray( 1, 3, cv.CV_8UC1, [ 21, 255, 255 ] ); const red2MinMat = cv.matFromArray( 1, 3, cv.CV_8UC1, [ 212, 64, 0 ] ); const red2MaxMat = cv.matFromArray( 1, 3, cv.CV_8UC1, [ 255, 255, 255 ] ); const blueMinMat = cv.matFromArray( 1, 3, cv.CV_8UC1, [ 127, 64, 0 ] ); const blueMaxMat = cv.matFromArray( 1, 3, cv.CV_8UC1, [ 213, 255, 255 ] ); const greenMinMat = cv.matFromArray( 1, 3, cv.CV_8UC1, [ 43, 64, 0 ] ); const greenMaxMat = cv.matFromArray( 1, 3, cv.CV_8UC1, [ 127, 255, 255 ] ); const yellowMinMat = cv.matFromArray( 1, 3, cv.CV_8UC1, [ 30, 51, 0 ] ); const yellowMaxMat = cv.matFromArray( 1, 3, cv.CV_8UC1, [ 43, 255, 255 ] ); switch (evt.target.value) { case 'red': renderInput(red1MinMat, red1MaxMat); break; case 'red2': renderInput(red2MinMat, red2MaxMat); break; case 'blue': renderInput(blueMinMat, blueMaxMat); break; case 'green': renderInput(greenMinMat, greenMaxMat); break; case 'yellow': renderInput(yellowMinMat, yellowMaxMat); break; default: renderInput(notBlackMinMat, notBlackMaxMat); } }); select.dispatchEvent(new Event('input', { target: select })); [].slice.call(document.querySelectorAll('#ui .min-max input')).forEach((input, i) => { input.addEventListener('change', (evt) => { spans[i].innerText = evt.target.value; if (i < 3) { minMat.data[i] = Number(evt.target.value); } else { maxMat.data[i - 3] = Number(evt.target.value); } }) }); function renderInput(min, max) { const inputs = document.querySelectorAll('#ui input'); minMat = min; maxMat = max; min.data.forEach((val, i) => { inputs[i].value = val; spans[i].innerText = val; minMat.data[i] = Number(val); }); max.data.forEach((val, i) => { inputs[i + 3].value = val; spans[i + 3].innerText = val; maxMat.data[i] = Number(val); }); } video.oncanplay = () => { const width = video.clientWidth; const height = video.clientHeight; const srcMat = new cv.Mat(height, width, cv.CV_8UC4); const distMat = new cv.Mat(); processVideo(); function processVideo() { srcCanvas.width = width; srcCanvas.height = height; srcCtx.drawImage(video, 0, 0, width, height); // ❸ Canvas要素にVideo要素をレンダリング srcMat.data.set(srcCtx.getImageData(0, 0, width, height).data); // ❹ MatのdataにCanvas要素のイメージデータを設定 cv.cvtColor(srcMat, distMat, cv.COLOR_RGB2HSV_FULL); // ❺ MatをHSVに変換 cv.inRange(distMat, minMat, maxMat, distMat); // ❺ 指定の色領域のみ表示する cv.medianBlur(distMat, distMat, 3); // ややなめらかにする(ぼかす) cv.imshow('dist', distMat); requestAnimationFrame(processVideo); } }; video.srcObject = stream; // ❷ video要素のsrcObjectにstreamを代入 }; function errorCallback(err) { alert(err); };
index.css
body { margin: 0; padding: 0; font-size: 10px; } select { position: fixed; top: 0; right: 0; } video { /* videoを非表示にする */ position: absolute; top: 0; left: 0; opacity: 0; pointer-events: none; transform-origin: left top; } #wrapper { position: fixed; top: 0; bottom: 0; left: 0; right: 0; overflow: hidden; } #ui { position: fixed; bottom: 0; right: 0; padding: 8px; background: white; z-index: 1; } #ui label { display: block; } #ui span { display: inline-block; width: 32px; } .canvas { position: relative; isolation: isolate; } canvas { /* ❻ カメラの映像とマスク画像を重ねブレンドモードで表示する */ position: absolute; mix-blend-mode: lighten; }
実装時のあれこれ
RGBからHSVへの変換
大まかな部分は変換式を使い、細かい部分はスライダーで調整しながら値を決めました。
光源に大きく依存するため、現場での調整が必須だと思います。
赤の取得
HSVで赤を表現すると0度を跨ぐので、2つの範囲に分かれてしまいます。
今回は緑を対象としたので、「red」「red2」と分けたままですが、赤を対象としたい場合は、「red」「red2」に対しマスク画像を1枚ずつ作って合成してからマスクを掛けるなど、うまいこと処理する必要があります。
マスク画像との差分の取り方
実際にグリーンバックでクロマキー合成を行う場合は、distMatで生成したマスク画像(指定した色のHSVの範囲内が白、それ以外が黒)を使うことになります。
今回は、実際の画像とマスク画像を重ねて、CSSのブレンドモードで表示しました。
video要素の消し方
video要素を非表示にする際、画面外に置いたり、display: noneとするとストリームの取得が止まります。
今回は透明にしつつ、当たり判定をなくすことで非表示としました。
video { position: absolute; top: 0; left: 0; opacity: 0; pointer-events: none; }
今回使用した色見本
不動産応援.comさん の 材料サンプル を使わせて頂きました。
あまり単色で発注することはないと思いますが、カメラの撮影範囲外にロゴなどを印刷したバンテンやターポリンを発注可能です。
おわりに
「OpenCVがJavaScriptでも使えて、スマートフォンのブラウザでも動作する」となると、夢が広がりますね。(バッテリーはガンガン減りますが)
OpenCV Advent Calendar 2022 は明日以降も続きます。
明日は irimoさん による 顔のパーツ検出を利用し、顔写真をちいかわにする です。