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台の早押しボタンのどちらが早く押されたかを検出することもできたりします。
OpenCV.jsを使って、リーズナブルな早押しボタンに、1着判定機能を追加してみました。
— 君塚史高 (@ki_230) 2024年6月28日
カメラで撮影した映像を左右に2分割して、どちらに赤色が多いかを判定するという簡易的な仕組みです。 pic.twitter.com/GpbR9OKML6