プログラミング

Javascriptでまばたき検知 face-api.js landmarks

投稿日:2022年2月3日 更新日:

顔認識の技術の発展により、今日ではまばたきの検知まで容易に行えるようです。
例えば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


動画です。

-プログラミング

執筆者:

関連記事

Heroku 独自ドメインで公開の手順 / ムームードメイン, ロリポップ使用の場合

HerokuでいくつかNode.jsゲームを公開しています。そのままただ公開すると「https://アプリ名.herokuapp.com」というURLになりますが、収益化などを考えた場合は独自ドメイン …

2重(N重)振り子の数値シミュレーション – Javascriptで計算から描画まで

2重振り子を数値シミュレーションをJavascriptでやってみます。Javascriptでやる利点は計算後の結果表示アニメーションまで容易に行える事だと言えます。2重振り子の解法に関する記事はWeb …

How to use FFmpeg.wasm. What’s the Cross Origin Isoration?

Nowadays, I got the information that FFmpeg can be used with Javascript and I tried it immediately. …

100万DLアプリを生み出せ

アプリ公開で収益を得るようになってから数年、いまだ大当たりはありません。1本でも大当たりアプリを生み出した=アプリで成功と言って良いでしょう。では、そもそも「大当たり」アプリとはどんなものでしょうか? …

no image

ショーモナイノ/ ソースコード(サーバーサイド)

ショーモナイノのコードを公開していないかとのお問い合わせを頂きました。GitHubでの公開を検討しましたが、書き散らかした粗末なコードをGitHubに置くべきではないと判断しました。代わりに自分のブロ …

スポンサーリンク