顔認識の技術の発展により、今日ではまばたきの検知まで容易に行えるようです。
例えばPython、OpenCV、dlibを使用したものでは
https://www.pyimagesearch.com/2017/04/24/eye-blink-detection-opencv-python-dlib/
があります。
日本語でないと読む気がしない方はQiitaにも複数の記事があるのでそちらをご参照ください。
さて、本記事では同様の事をJavascriptでやってみようと思います。Webサイトで手軽に作成できたら様々な用途に使用できそうです。
face-api.jsを使用する
JavascriptではPythonのようにdlibを簡単には使用できないようです。かわりに顔認識のライブラリ「face-api.js」を使用します。導入手順はWeb情報にお任せで省略します。
“eye aspect ratio”が得られない
しかし、face-api.jsではdlibのように目を閉じた状態の目の形状を得る事ができないようです。Webカメラで顔を映し、face landmarkの68点を表示させた状態で目を閉じても目を開けている状態と同じ形状が表示されます。どうやら目を閉じた状態の学習データまでは組み込まれていないようです。なので上記ページ(Python,dlib)で用いられているEAR(eye aspectr ratio)でまばたきを検知する事ができません。少々、こまった事になりましたが、Webを検索すると黒目の部分(Iris)の状態変化を追うことでも検知できるようです。サンプルコードまでは見つけられませんでしたがEAR方式と同様に単純な計算で実装が可能そうなのでやってみました。
Irisの色の変化で目の開閉を判別
face landmarkにより目の位置は分かるので、黒目の位置も特定する事ができます。黒目の中心付近のRGB値を毎フレームごとに取ってくれば、目が開いている状態では黒目、閉じている状態ではまぶたの色を得る事ができます。これにより目の開閉を判別できそうです。
実用化には様々な工夫が必要
簡単なサンプルは上記手順で作成できるでしょうが、映像側に色々と制限が必要です。
・顔の正面からの映像が得られている事
・目を正面から動かさない
など。
何かの用途に使用するなら色々と工夫が必要となりそうです。
(黒目の位置の追従など)
コード
ご参考にしてください。
faceApiSample.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<script defer src="face-api.min.js"></script>
<script defer src="script.js"></script>
<style>
body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
canvas {
position: absolute;
}
</style>
</head>
<body>
<video id="video" width="720" height="540" autoplay muted playsinline="true"></video>
</body>
</html>
script.js
const video = document.getElementById('video')
var mBlinkSound = new Audio("/sound/shotgun-firing1.mp3");
Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri('/facemodels'),
faceapi.nets.faceLandmark68Net.loadFromUri('/facemodels'),
faceapi.nets.faceRecognitionNet.loadFromUri('/facemodels'),
faceapi.nets.faceExpressionNet.loadFromUri('/facemodels')
]).then(startVideo)
function startVideo() {
if (navigator.userAgent.match(/iPhone|iPad|Android/)) { ///iPhone|Android.+Mobile/
console.log("Mobile");
video.width = 400; //1080;
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
.then(localMediaStream => {
if ('srcObject' in video) {
video.srcObject = localMediaStream;
} else {
video.src = window.URL.createObjectURL(localMediaStream);
}
video.play();
})
.catch(err => {
console.error(`Not available!!!!`, err);
});
}
else {
console.log("PC");
navigator.getUserMedia(
{ video: {} },
stream => video.srcObject = stream,
err => console.error(err)
)
}
console.log("video:"+[video.width, video.height]);
// let div = document.createElement('div')
// div.innerText = 'video size:'+video.width+', '+video.height
// console.log(div.innerText);
// document.body.appendChild(div)
}
video.addEventListener('play', () => {
var canvas_bg = document.createElement("canvas");
canvas_bg.width = video.width;
canvas_bg.height = video.height;
document.body.append(canvas_bg)
var ctx_bg = canvas_bg.getContext('2d');
// ctx_bg.fillStyle = "rgb(0,0,0)";
// ctx_bg.fillRect(0, 0, video.width, video.height/2);
var canvas_face = document.createElement("canvas");
canvas_face.width = video.width;
canvas_face.height = video.height;
var ctx_face = canvas_face.getContext('2d');
const canvas = faceapi.createCanvasFromMedia(video)
document.body.append(canvas)
const displaySize = { width: video.width, height: video.height }
faceapi.matchDimensions(canvas, displaySize)
var t1 = performance.now();
var irisC = [];
let nowBlinking = false;
let blinkCount = 0;
setInterval(async () => {
//const detections = await faceapi.detectAllFaces(video, new faceapi.TinyFaceDetectorOptions()).withFaceLandmarks().withFaceExpressions()
const detections = await faceapi.detectAllFaces(video, new faceapi.TinyFaceDetectorOptions()).withFaceLandmarks()
const resizedDetections = faceapi.resizeResults(detections, displaySize)
canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height)
//faceapi.draw.drawDetections(canvas, resizedDetections)
faceapi.draw.drawFaceLandmarks(canvas, resizedDetections)
//faceapi.draw.drawFaceExpressions(canvas, resizedDetections)
//console.log(resizedDetections);
const landmarks = resizedDetections[0].landmarks;
//console.log(landmarks);
const landmarkPositions = landmarks.positions;
//--- Iric mark ---//
ctx_bg.clearRect(0, 0, canvas_bg.width, canvas_bg.height)
var x_ = landmarkPositions[38-1].x
var y_ = landmarkPositions[38-1].y
var w_ = landmarkPositions[39-1].x - landmarkPositions[38-1].x
var h_ = landmarkPositions[42-1].y - landmarkPositions[38-1].y
ctx_bg.fillStyle = "rgb(255,0,0)";
ctx_bg.fillRect(x_, y_, w_, h_)
x_ = landmarkPositions[44-1].x
y_ = landmarkPositions[44-1].y
w_ = landmarkPositions[45-1].x - landmarkPositions[44-1].x
h_ = landmarkPositions[48-1].y - landmarkPositions[44-1].y
ctx_bg.fillRect(x_, y_, w_, h_)
//--- Face mask ---//
ctx_bg.fillStyle = 'rgb(0,200,0)';
ctx_bg.beginPath();
ctx_bg.moveTo(landmarkPositions[0].x, landmarkPositions[0].y);
for(var i=1;i<17;i++){
ctx_bg.lineTo(landmarkPositions[i].x, landmarkPositions[i].y);
}
ctx_bg.fill();
ctx_bg.moveTo(landmarkPositions[0].x, landmarkPositions[0].y);
ctx_bg.lineTo(landmarkPositions[17].x, landmarkPositions[17].y);
ctx_bg.lineTo(landmarkPositions[27].x, landmarkPositions[17].y);
ctx_bg.lineTo(landmarkPositions[27].x, landmarkPositions[0].y);
//ctx_bg.lineTo(landmarkPositions[26].x, landmarkPositions[26].y);
ctx_bg.lineTo(landmarkPositions[16].x, landmarkPositions[16].y);
ctx_bg.lineTo(landmarkPositions[16].x, landmarkPositions[16].y-200);
ctx_bg.lineTo(landmarkPositions[0].x, landmarkPositions[0].y-200);
ctx_bg.lineTo(landmarkPositions[0].x, landmarkPositions[0].y);
ctx_bg.fill();
//--- Iris value ---//
ctx_face.clearRect(0, 0, canvas_face.width, canvas_face.height)
ctx_face.drawImage(video, 0, 0, video.width, video.height);
var frame = ctx_face.getImageData(0, 0, video.width, video.height);
var p_ = Math.floor(x_+w_/2) + Math.floor(y_+h_/2) * video.width
//console.log("eye_RGB:"+[frame.data[p_*4+0], frame.data[p_*4+1], frame.data[p_*4+2]]);
var v_ = Math.floor( (frame.data[p_*4+0] + frame.data[p_*4+1] + frame.data[p_*4+2])/3 );
console.log("irisC:"+v_);
irisC.push(v_);
if(irisC.length>100){
irisC.shift();
}//
let meanIrisC = irisC.reduce(function(sum, element){
return sum + element;
}, 0);
meanIrisC = meanIrisC / irisC.length;
let vThreshold = 1.5;
let currentIrisC = irisC[irisC.length-1];
if(irisC.length==100){
if(nowBlinking==false){
if(currentIrisC>=meanIrisC*vThreshold){
nowBlinking = true;
}//
}//
else{
if(currentIrisC<meanIrisC*vThreshold){
nowBlinking = false;
blinkCount += 1;
mBlinkSound.pause();
mBlinkSound.currentTime = 0;
mBlinkSound.play();
}//
}//
}//
//--- Graph ---//
ctx_bg.strokeStyle = 'red';
ctx_bg.lineWidth = 5;
var Ox = 0;
var Oy = canvas_bg.height/2;
var Lx = canvas_bg.width;
var Ly = canvas_bg.height/2;
var vx = 0/irisC.length * Lx;
var vy = irisC[0]/255 * Ly;
ctx_bg.beginPath();
ctx_bg.moveTo(Ox+vx, Oy-vy);
for(var i=1;i<irisC.length;i++){
vx = i/irisC.length * Lx;
vy = irisC[i]/255 * Ly;
ctx_bg.lineTo(Ox+vx, Oy-vy);
}
ctx_bg.stroke();
//--- mean value x threshold(1.X)
ctx_bg.strokeStyle = 'rgb(0,255,0)';
ctx_bg.lineWidth = 2;
ctx_bg.beginPath();
vx = 0 * Lx;
vy = meanIrisC*vThreshold/255 * Ly;
ctx_bg.moveTo(Ox+vx, Oy-vy);
vx = 1 * Lx;
ctx_bg.lineTo(Ox+vx, Oy-vy);
ctx_bg.stroke();
var ctx = canvas.getContext('2d');
var t2 = performance.now();//ms
ctx.font = "48px serif";
ctx.fillText("FPS:"+ Math.floor(1000.0/(t2-t1)), 10, 50);
ctx.fillText("Count:"+blinkCount, 10, 100);
if(nowBlinking){
ctx.fillText("Blinking", 10, 150);
}
//ctx.fillText("FPS:"+ (t2-t1), 10, 50);
t1 = t2;
}, 33)
})
動作確認
こちらから動作確認できます。
Webカメラが必要です。スマホは現在無理そうです。
https://g-llc.co.jp/faceApiSample
動画です。