みかづきブログ・カスタム

基本的にはちょちょいのほいです。

OpenCV.jsを使って、グリーンバック撮影用のカメラをウェブページ上で実装する 🟩

はじめに

この記事は 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さん材料サンプル を使わせて頂きました。
あまり単色で発注することはないと思いますが、カメラの撮影範囲外にロゴなどを印刷したバンテンやターポリンを発注可能です。

www.fudousan-ouen.com

おわりに

「OpenCVがJavaScriptでも使えて、スマートフォンのブラウザでも動作する」となると、夢が広がりますね。(バッテリーはガンガン減りますが)
OpenCV Advent Calendar 2022 は明日以降も続きます。
明日は irimoさん による 顔のパーツ検出を利用し、顔写真をちいかわにする です。