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

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

OpenCV.jsで早押しボタンが押されたことを検知する ⭕️

OpenCV.jsでカメラで撮影した映像内の赤色の割合を検出し、早押しボタンが押されたことを検知するプログラムを作ってみました。
部屋の明るさや、日光の入り方などに依存しますが、なかなかの精度が出ています。

ソースコード

index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>OpenCV - Quiz</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 class="canvas">
      <canvas id="src"></canvas>
      <canvas id="dist"></canvas>
    </div>
    <div id="ui">
      <select id="select">
        <option></option>
        <option value="red">red</option>
      </select>
      <dl>
        <dt>
          <p>audio</p>
        </dt>
        <dd>
          <input id="audio-check" type="checkbox" />
        </dd>
      </dl>
      <dl class="threshold">
        <dt>
          <p>threshold</p>
        </dt>
        <dd>
          <label>
            <span>100</span>
            <input id="threshold" type="range" min="0" max="1" step=".1" value="1" />
          </label>
        </dd>
      </dl>
      <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>
      <p id="ratio"></p>
      <audio id="audio" src="ok.mp3"></audio>
      <div>
        <video id="video" autoplay playsinline muted></video>
      </div>
    </div>
  </div>
  <script src="index.js"></script>
  <script src="../opencv.js"></script>
</body>
</html>

index.js

let canPlayAudio = false;

document.getElementById('audio-check').addEventListener('click', (evt) => {
  if (evt.target.checked) {
    document.getElementById('audio').play();
    document.getElementById('audio').pause();
    canPlayAudio = true;
  }
});

document.getElementById('threshold').addEventListener('input', (evt) => {
  document.querySelector('.threshold span').innerText = evt.target.value * 100;
});

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

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

function successCallback(stream) {
  const notBlackMinMat = cv.matFromArray(
    1,
    3,
    cv.CV_8UC1,
    [
      0,
      0,
      64
    ]
  );
  const notBlackMaxMat = cv.matFromArray(
    1,
    3,
    cv.CV_8UC1,
    [
      255,
      255,
      255
    ]
  );
  const redMinMat = cv.matFromArray(
    1,
    3,
    cv.CV_8UC1,
    [
      180,
      160,
      120
    ]
  );
  const redMaxMat = cv.matFromArray(
    1,
    3,
    cv.CV_8UC1,
    [
      255,
      255,
      255
    ]
  );
  const video = document.getElementById('video');
  const select = document.getElementById('select');
  const srcCanvas = document.getElementById('src');
  const srcCtx = srcCanvas.getContext('2d');

  let minMat = notBlackMinMat;
  let maxMat = notBlackMaxMat;

  select.addEventListener('input', (evt) => {
    switch (evt.target.value) {
      case 'red':
        renderInput(redMinMat, redMaxMat);
        break;
      default:
        renderInput(notBlackMinMat, notBlackMaxMat);
    }
  });

  const spans = document.querySelectorAll('#ui .min-max span');

  [].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() {
      const FPS = 8;
      const begin = Date.now();

      srcCanvas.width = width;
      srcCanvas.height = height;
      srcCtx.drawImage(video, 0, 0, width, height);
      srcMat.data.set(srcCtx.getImageData(0, 0, width, height).data);

      cv.cvtColor(srcMat, distMat, cv.COLOR_RGB2HSV_FULL);
      cv.inRange(distMat, minMat, maxMat, distMat);
      cv.medianBlur(distMat, distMat, 3);
      cv.imshow('dist', distMat);

      const ratio = cv.countNonZero(distMat) / (distMat.cols * distMat.rows);

      if (document.getElementById('audio-check').checked) {
        if (canPlayAudio && Number(document.getElementById('threshold').value) <= ratio) {
          canPlayAudio = false;
          document.getElementById('audio').currentTime = 0;
          document.getElementById('audio').play();

          setTimeout(() => canPlayAudio = true, 2000);
        }
      }

      document.getElementById('ratio').innerText = `${ (ratio * 100).toFixed(4) }%`;

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

      setTimeout(processVideo, delay);
    }
  };

  video.srcObject = stream;
};

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

GIFアニメにしてしまったので、全く伝わらないですが、早押しボタンを押すとPCから音が再生されます。
また、画面を左右2分割することで、2台の早押しボタンのどちらが早く押されたかを検出することもできたりします。