webカメラとOpenCV.jsとブラウザでリアルタイム顔向き推定を行う

動画から目や鼻の位置を取得して顔がどの方向を向いているかを調べる顔向き推定を、ブラウザ上でリアルタイムに行ってみます。

顔向き推定までの流れは大きく 4 つのステップに分けられます。

  1. web カメラから映像を取得する
  2. 映像から顔のランドマークの座標を取得する
  3. ランドマーク座標からカメラの回転・移動ベクトルを計算する
  4. 回転・移動ベクトルから顔の向いている角度を計算する

注意:以下のサンプルコードはエラー処理などを省略しています。また OpenCV.js の変数は手動で削除する必要があります。

const imagePoints = cv.Mat.zeros(rows, 2, cv.CV_64FC1);
...
imagePoints.delete();

webカメラからvideo取得

MediaDevices.getUserMedia() を使うとブラウザ上で web カメラの映像を扱うことができます。getUserMedia では動画だけではなく音声も取得できますが、今回は動画だけを取得します。

index.html
<div style="position: relative;">
  <video id="video" width="640" height="480" style="transform: scaleX(-1);"></video>
  <canvas id="canvas1" width="640" height="480" style="position: absolute; top: 0px; left: 0px; transform: scaleX(-1);"></canvas>
  <canvas id="canvas2" width="640" height="480" style="position: absolute; top: 0px; left: 0px; transform: scaleX(-1);"></canvas>
</div>
index.ts
const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: false,
});

const video = document.getElementById('video') as HTMLVideoElement;
video.srcObject = stream;
await video.play();

ここまでのコードがうまく動作していれば web カメラの映像がブラウザ上に表示されます。

つぎのステップでは映像から目や鼻などの座標を取得します。

ランドマークの座標を取得

顔のランドマークを検出するために今回は face-api.js を使います。face-api.js は tensorflow.js がベースとなっていて、ブラウザ上で手軽に顔をトラッキングしたりランドマークの座標を取得したり表情を検出したりすることができます。

face-api.js を実行する前に、顔認識とランドマーク取得用の学習モデルを読み込みます。

index.ts
import * as faceapi from 'face-api.js';

...

await Promise.all([
  faceapi.nets.tinyFaceDetector.loadFromUri('models/weights'),
  faceapi.nets.faceLandmark68TinyNet.loadFromUri('models/weights'),
]);

つぎに、ステップ 1 で取得した video の mediaStream を face-api.js の detectSingleFace 関数に渡して顔認識を行います。同時にランドマークの取得を行う withFaceLandmarks の設定も行います。

あとは resizeResults 関数から認識結果を受け取り、顔が検出されていれば目や鼻などのランドマーク座標を取得します。また requestAnimationFrame で再帰的に実行してリアルタイムで顔認識を行います。

index.ts
detect();
async function detect() {
  requestAnimationFrame(detect);

  //  webカメラの映像から顔認識を行う
  const useTinyModel = true;
  const detection = await faceapi
    .detectSingleFace(
      video,
      new faceapi.TinyFaceDetectorOptions({
      inputSize: 160,
      })
    )
    .withFaceLandmarks(useTinyModel);

  if (!detection) {
    return;
  }

  // 認識データをリサイズ
  const resizedDetection = faceapi.resizeResults(detection, {
    width: video.width,
    height: video.height,
  });

  // ランドマークをキャンバスに描画
  const canvas = document.getElementById('canvas1') as HTMLCanvasElement;
  canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
  faceapi.draw.drawFaceLandmarks(canvas, resizedDetection);

  // 以後使用するランドマーク座標
  const landmarks = resizedDetection.landmarks;
  const nose = landmarks.getNose()[3];
  const leftEye = landmarks.getLeftEye()[0];
  const rightEye = landmarks.getRightEye()[3];
  const jaw = landmarks.getJawOutline()[8];
  const leftMouth = landmarks.getMouth()[0];
  const rightMouth = landmarks.getMouth()[6];
  const leftOutline = landmarks.getJawOutline()[0];
  const rightOutline = landmarks.getJawOutline()[16];
}

今回の顔向き推定で使用するランドマークは、鼻、左目、右目、あご、口の左側、口の右側、顔左側面、顔右側面の合計 8 点です。ランドマークは多ければ多いほど推定精度がよくなりますが、推定計算に時間がかかります。

そのため顔向き推定には 6 点をもちいる場合が多いようですが、今回の実行環境だと 6 点ではうまく推定できなかったため 8 点での顔向き推定を行っています。

ここまでのステップでカメラの映像からランドマーク座標の取得ができました。つぎは、ランドマーク座標からカメラの回転・移動ベクトルを計算します。

カメラの回転・移動を計算する

3 次元の不動の物体(web カメラで撮影された自分の姿)が、2 次元のキャンバスに描写された(カメラが撮影した画面)とき、物体を撮影しているカメラの場所が 3 次元のどの位置にいるかを計算することで顔が向いている角度を知ることができます。

このステップ 3 では、もともと顔の正面に設置したカメラがランドマーク座標を取得した時間ではどの位置に移動しているかを計算します。

OpenCV.jsのビルド

カメラ移動の計算には OpenCV の solvePnP 関数を使います。通常の OpenCV はブラウザ上で動かすことができませんが、OpenCV を JavaScript に変換した OpenCV.js を使うことでブラウザ上でも OpenCV の関数が使えるようになります。

ただし 2020 年 4 月 20 日の OpenCV.JS の初期ビルド設定では solvePnP 関数が使えないので、ビルド設定を変更します。

git clone https://github.com/opencv/opencv.git
# git rev-parse HEAD
# f19d0ae41d112de1e8aba11717462dd1b118342f
opencv/platforms/js/opencv_js.config.py
# Classes and methods whitelist
calib3d = {'': ['Rodrigues', 'solvePnP', 'projectPoints', 'decomposeProjectionMatrix']}
white_list = makeWhiteList([calib3d])
opencv/modules/js/src/embindgen.py
def add_func(self, decl):
    namespace, classes, barename = self.split_decl_name(decl[0])
    cpp_name = "::".join(namespace + classes + [barename])
    # "Cannot register public name 'projectPoints' twice'" エラーを防ぐために以下2行を追加する
    if (len(namespace) > 1) and namespace[1] == u'fisheye':
        return
    name = barename
    class_name = ''

ビルドには Docker を使います。

docker run --rm --workdir /code -v $PWD:/code trzeci/emscripten:latest python ./platforms/js/build_js.py build

ビルドが完了すれば opencv/build/bin/opencv.js が生成されています。この js ファイルをブラウザで読み込めば OpenCV.js が使えるようになります。

index.html
<head>
  <script src="/opencv.js" type="text/javascript"></script>
</head>

solvePnPでPnP問題を解く

OpenCV.js が使えるようになったところでカメラの回転・移動ベクトルを計算します。

index.ts
// capture model points
const detectPoints = [
  // nose
  ...[0.0, 0.0, 0.0],
  // jaw
  ...[0, -330, -65],
  // left eye
  ...[-240, 170, -135],
  // right eye
  ...[240, 170, -135],
  // left mouth
  ...[-150, -150, -125],
  // right mouth
  ...[150, -150, -125],
  // left outline
  ...[-480, 170, -340],
  // right outline
  ...[480, 170, -340],
];

function solve({
  nose,
  leftEye,
  rightEye,
  jaw,
  leftMouth,
  rightMouth,
  leftOutline,
  rightOutline,
}) {
  const rows = detectPoints.length / 3;
  const modelPoints = cv.matFromArray(rows, 3, cv.CV_64FC1, detectPoints);

  // camera matrix
  const size = {
    width: 640,
    height: 480,
  };
  const center = [size.width / 2, size.height / 2];
  const cameraMatrix = cv.matFromArray(3, 3, cv.CV_64FC1, [
    ...[size.width, 0, center[0]],
    ...[0, size.width, center[1]],
    ...[0, 0, 1],
  ]);

  // image matrix
  const imagePoints = cv.Mat.zeros(rows, 2, cv.CV_64FC1);
  const distCoeffs = cv.Mat.zeros(4, 1, cv.CV_64FC1);
  const rvec = new cv.Mat({ width: 1, height: 3 }, cv.CV_64FC1);
  const tvec = new cv.Mat({ width: 1, height: 3 }, cv.CV_64FC1);

  [
    ...nose,
    ...jaw,
    ...leftEye,
    ...rightEye,
    ...leftMouth,
    ...rightMouth,
    ...leftOutline,
    ...rightOutline,
  ].map((v, i) => {
    imagePoints.data64F[i] = v;
  });

  // 移動ベクトルと回転ベクトルの初期値を与えることで推測速度の向上をはかる
  tvec.data64F[0] = -100;
  tvec.data64F[1] = 100;
  tvec.data64F[2] = 1000;
  const distToLeftEyeX = Math.abs(leftEye[0] - nose[0]);
  const distToRightEyeX = Math.abs(rightEye[0] - nose[0]);
  if (distToLeftEyeX < distToRightEyeX) {
    // 左向き
    rvec.data64F[0] = -1.0;
    rvec.data64F[1] = -0.75;
    rvec.data64F[2] = -3.0;
  } else {
    // 右向き
    rvec.data64F[0] = 1.0;
    rvec.data64F[1] = -0.75;
    rvec.data64F[2] = -3.0;
  }

  const success = cv.solvePnP(
    modelPoints,
    imagePoints,
    cameraMatrix,
    distCoeffs,
    rvec,
    tvec,
    true
  );

  return {
    success,
    imagePoints,
    cameraMatrix,
    distCoeffs,
    rvec, // 回転ベクトル
    tvec, // 移動ベクトル
  };
}

cv.solvePnP を実行したあとの rvec がカメラの回転ベクトル、tvec がカメラの移動ベクトルになります。

これにてランドマークの座標を取得した時間の映像を、カメラはどの位置から撮影しているか計算できるようになりました。最後となる次のステップでは rvectvec から顔の向きを計算します。


回転・移動ベクトルから顔の向いている角度を計算する

solve 関数から得られる値を使って顔向きを計算します。

cv.projectPoints は中心となる鼻の位置から x 軸、y 軸、z 軸を描写するための座標計算です。noseEndPoint2DX には x 軸を canvas で描画するための座標が代入されます。

cv.Rodrigues では回転ベクトルを回転行列に変換します。

cv.decomposeProjectionMatrix では、これまで計算した値を使うことで 2 次元に描写された顔の向き情報を計算します。第 8 引数 eulerAngles に角度が保存されます。

index.ts
function headpose({ rvec, tvec, cameraMatrix, distCoeffs, imagePoints }) {
  const noseEndPoint2DZ = new cv.Mat();
  const noseEndPoint2DY = new cv.Mat();
  const noseEndPoint2DX = new cv.Mat();

  const pointZ = cv.matFromArray(1, 3, cv.CV_64FC1, [0.0, 0.0, 500.0]);
  const pointY = cv.matFromArray(1, 3, cv.CV_64FC1, [0.0, 500.0, 0.0]);
  const pointX = cv.matFromArray(1, 3, cv.CV_64FC1, [500.0, 0.0, 0.0]);
  const jaco = new cv.Mat();

  cv.projectPoints(
    pointZ,
    rvec,
    tvec,
    cameraMatrix,
    distCoeffs,
    noseEndPoint2DZ,
    jaco
  );
  cv.projectPoints(
    pointY,
    rvec,
    tvec,
    cameraMatrix,
    distCoeffs,
    noseEndPoint2DY,
    jaco
  );
  cv.projectPoints(
    pointX,
    rvec,
    tvec,
    cameraMatrix,
    distCoeffs,
    noseEndPoint2DX,
    jaco
  );

  const canvas2 = document.getElementById('canvas2') as HTMLCanvasElement;
  const context = canvas2.getContext('2d');

  const position = {
    nose: {
      x: imagePoints.data64F[0],
      y: imagePoints.data64F[1],
    },
    x: {
      x: noseEndPoint2DX.data64F[0],
      y: noseEndPoint2DX.data64F[1],
    },
    y: {
      x: noseEndPoint2DY.data64F[0],
      y: noseEndPoint2DY.data64F[1],
    },
    z: {
      x: noseEndPoint2DZ.data64F[0],
      y: noseEndPoint2DZ.data64F[1],
    },
  };

  context.clearRect(0, 0, canvas2.width, canvas2.height);

  context.beginPath();
  context.lineWidth = 2;
  context.strokeStyle = 'rgb(255, 0, 0)';
  context.moveTo(position.nose.x, position.nose.y);
  context.lineTo(position.z.x, position.z.y);
  context.stroke();
  context.closePath();

  context.beginPath();
  context.lineWidth = 2;
  context.strokeStyle = 'rgb(0, 0, 255)';
  context.moveTo(position.nose.x, position.nose.y);
  context.lineTo(position.x.x, position.x.y);
  context.stroke();
  context.closePath();

  context.beginPath();
  context.lineWidth = 2;
  context.strokeStyle = 'rgb(0, 255, 0)';
  context.moveTo(position.nose.x, position.nose.y);
  context.lineTo(position.y.x, position.y.y);
  context.stroke();
  context.closePath();

  const rmat = new cv.Mat();
  cv.Rodrigues(rvec, rmat);

  const projectMat = cv.Mat.zeros(3, 4, cv.CV_64FC1);
  projectMat.data64F[0] = rmat.data64F[0];
  projectMat.data64F[1] = rmat.data64F[1];
  projectMat.data64F[2] = rmat.data64F[2];
  projectMat.data64F[4] = rmat.data64F[3];
  projectMat.data64F[5] = rmat.data64F[4];
  projectMat.data64F[6] = rmat.data64F[5];
  projectMat.data64F[8] = rmat.data64F[6];
  projectMat.data64F[9] = rmat.data64F[7];
  projectMat.data64F[10] = rmat.data64F[8];

  const cmat = new cv.Mat();
  const rotmat = new cv.Mat();
  const travec = new cv.Mat();
  const rotmatX = new cv.Mat();
  const rotmatY = new cv.Mat();
  const rotmatZ = new cv.Mat();
  const eulerAngles = new cv.Mat();

  cv.decomposeProjectionMatrix(
    projectMat,
    cmat,
    rotmat,
    travec,
    rotmatX,
    rotmatY,
    rotmatZ,
    eulerAngles // 顔の角度情報
  );

  return {
    yaw: eulerAngles.data64F[1],
    pitch: eulerAngles.data64F[0],
    roll: eulerAngles.data64F[2],
  };
}

これにて yawpitchroll の形で顔の向き情報が得られました。

完成版デモ

実際の動作デモです。うまく推定できています。

browser-headpose-demo

おしまい。

参考