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

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

OpenCV.jsでスマートフォンのカメラを使った動体検知を実装する 📷

かつて、 OpenCV.jsを使ってフレーム差分のみを表示するDEMO をつくりました。

blog.kimizuka.org

しかし、若干複雑かつ、取り回しにくいコードになってしまっていたため、今回はフレーム差分を検知した際にコールバックイベントを実行するだけのシンプルめなコードを書いてみました。

方針

new MotionDetection({
    video: document.querySelector('video'), // 監視するvideo要素
    onMove: () => console.log('MOVE'), // フレーム差分が発生した際のコールバック関数
    onStop = () => console.log('STOP'), // フレーム差分が発生していない際のコールバック関数
    fps: 8 // FPS
});

と、こんな感じで、対象のvideo要素を監視するインスタンスを生成するClassをつくってみようと思います。
折角インスタンスをつくるので、EventEmitterを継承して、onMove、onStopはコンストラクタの引数ではなく、インスタンスにonする形も検討したのですが、今回はシンプルさを重要視して引数を選択しました。

実装

MotionDetection.js

export function loadOpenCv() {
  const script = document.createElement('script');

  script.src = 'https://docs.opencv.org/3.4.0/opencv.js'; // ビルド済みのopenCV.jsを読み込む

  document.body.appendChild(script);
}

export class MotionDetection {
  constructor({
    video = document.createElement('video'),
    onMove = function() {},
    onStop = function() {},
    fps = 8
  } = {
    video: document.createElement('video'),
    onMove: function() {},
    onStop: function() {},
    fps: 8
  }) {
    const elm = document.createElement('div');
    const imgLength = 3;
    const videoMatList = [];
    const monochromeMatList = [];
    const diffMatList = [];
    const canvasList = [];
    const canvasLength = 8;

    for (let i = 0; i < canvasLength; ++i) {
      canvasList.push(document.createElement('canvas'));
    }

    const contextList = canvasList.map((canvas) => canvas.getContext('2d'));
    const diffCanvas = canvasList[canvasLength - 1];
    const diffCtx = diffCanvas.getContext('2d');
    // パフォーマンスを上げるためにcanvasを小さくしておく
    const width = video.clientWidth / 16;
    const height = video.clientHeight / 16;

    for (let i = 0; i < imgLength; ++i) {
      videoMatList.push(new cv.Mat(height, width, cv.CV_8UC4));
      monochromeMatList.push(new cv.Mat(height, width, cv.CV_8UC1));
    }

    canvasList.forEach((canvas, i) => {
      canvas.id = `c${ performance.now() + i }`; // imshowに渡すために適当なID属性を振る
      canvas.width = width;
      canvas.height = height;

      elm.appendChild(canvas);
    });

    // bodyにcanvasを設置しないとimshowが動かないので見えない位置を設定
    elm.style.cssText = `
      display: none;
      position: fixed;
      top: -9999px;
    `;

    document.body.appendChild(elm);

    processVideo();

    function processVideo() {
      const begin = Date.now();

      contextList[0].drawImage(video, 0, 0, width, height);

      videoMatList[1].copyTo(videoMatList[2]);
      videoMatList[0].copyTo(videoMatList[1]);
      videoMatList[0].data.set(contextList[0].getImageData(0, 0, width, height).data);

      for (let i = 0; i < videoMatList.length; ++i) {
        cv.cvtColor(videoMatList[i], monochromeMatList[i], cv.COLOR_RGB2GRAY);
        cv.imshow(`${ canvasList[i].id }`, videoMatList[i]);
        cv.imshow(`${ canvasList[i + 3].id }`, monochromeMatList[i]);
      }

      for (let i = 0; i < videoMatList.length - 1; ++i) {
        diffMatList.push(new cv.Mat(height, width, cv.CV_8UC1));

        cv.absdiff(monochromeMatList[i], monochromeMatList[i + 1], diffMatList[i]);
        cv.imshow(`${ canvasList[i + 6].id }`, diffMatList[i]);
      }

      const { data } = diffCtx.getImageData(0, 0, diffCanvas.width, diffCanvas.height);
      const length = data.length;
      let sum = 0;

      for (let i = 0; i < length; i += 4) {
        if (256 / 2 < data[i] + data[i + 1] + data[i + 2]) { // 黒くない部分を計測
          sum += 1;
        }
      }

      const rate = .1; // 10%以上差分があるかを検出(のちに引数で指定できるようにしても良さそう)

      if (diffCanvas.width * diffCanvas.height * rate < sum) {
        onMove();
      } else {
        onStop();
      }

      const delay = 1000 / fps - (Date.now() - begin);

      setTimeout(processVideo, delay);
    }
  }
}

今回はTypeScriptを使わずにJavaScriptで実装しました。

使い方の例

index.js

import { loadOpenCv, MotionDetection } from './MotionDetection.js';

window.Module = {
  onRuntimeInitialized() {
    const medias = {
      audio: false,
      video: {
        facingMode: 'user'
      }
    };

    navigator.mediaDevices.getUserMedia(medias).then(successCallback)
                                               .catch(errorCallback);
  }
};

function successCallback(stream) {
  const video = document.querySelector('video');
  const txt = document.getElementById('txt');
  const fps = 8;

  video.oncanplay = () => {
    new MotionDetection({
      video,
      onMove,
      onStop,
      fps
    });
  };

  function onMove() {
    txt.innerText = 'MOVE';
  }
  
  function onStop() {
    txt.innerText = '';
  }

  video.srcObject = stream;
}

function errorCallback(err) {
  alert(err);
};

loadOpenCv();

こんな感じで使用します。

DEMO

kimizuka.github.io

10%以上の差分を検知した際に「MOVE」と表示されます。
iPhone 12miniでしか動作確認していませんが、スマホでも動きました。