Life is Tech ! Advent Calendar 2日目 jsで顔認識して遊んで見た!

  • 62
    いいね
  • 2
    コメント

こんにちはー、毎度おなじみ大学生プログラマうーぴょん(さすらいうさぎ)です!
今回は、Life is Tech! のメンターのアドベントカレンダー2日目の記事ということで参戦しています。
普段Webサービスコースというサーバサイドの開発について教えていることが多いのですが、個人的にはJavascriptが好きなのでjsでゴリゴリと進めていきたいと思います!
今回のテーマは、12月といえば冬、雪・・・SNOWということで、Web版SN◯Wに挑戦していきます!
SN◯Wがなんぞというのは、ここでは説明しないのでググってください。

アジェンダ!

  • 顔認識
  • 画像処理(拡大・縮小)

Let' try!

Chapter 0. 下準備

とりあえず次のファイルを準備します

  • index.html
  • js/application.js
  • css/application.css

index.htmlを以下のように書き換えます。

index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>camera</title>
  <script src="./js/application.js"></script>
  <link rel="stylesheet" href="./css/application.css">
</head>
<body>
</body>
</html>

ローカル環境(自分のPC)にサーバを立てる必要があるので、ターミナルをもう1つ開いてカレントディレクトリを移したら、次のコマンドを実行します。

ターミナル
$ python -m SimpleHTTPServer 8080

これで、ブラウザからlocalhost:8080にアクセスすると作ったページが見られるようになります。Bracketsが入っているなら、そっちでも構いません。

ちなみに検証環境は、OS X El Capitan上でChromeを使っています。

Chapter 1. カメラからの入力ができるようにしよう!

まずは、Webブラウザからカメラを使えるようにします。

js/application.js
window.addEventListener("load", () => {
  let deviceNavigator = navigator.mediaDevices.getUserMedia({ audio: false, video: true });
  deviceNavigator.then((s) => {
    let target = document.getElementById("target");
    target.src = window.URL.createObjectURL(s);
  });
});
index.html(抜粋)
<body>
  <video id="target" width="800" height="600" autoplay></video> <!-- 追加 -->
</body>

js/application.js

navigator.mediaDevices.getUserMediaがカメラを持った一種のサーバのような役割を果たしています。そのためwindow.URL.createObjectURL(s)とすることで、そのサーバのURLを取得しています。

index.html

追記したvideoタグは見たまんま、動画のために使うタグです。autoplay属性を付けて自動再生させないと動画にならないので注意してください。あとheightwidthも指定しておかないと、あとで使うライブラリが動かないので注意します。

ここまで、進めるとブラウザ上にカメラからとれた動画が映るはずです!

Chapter 2. 顔認識ができるようにしよう!

STEP 1. ライブラリのダウンロード

カメラから撮った画像を自力で解析して顔認識をするのは、非常に大変なのでライブラリを導入します。
今回は、clmtrackrを使います。
以下の2ファイルをダウンロードしましょう!

ダウンロードが終わったらjsディレクトリに入れておきます。

STEP 2. ライブラリの読み込み

index.htmlから、ダウンロードした2ファイルが読み込めるようにします。

index.html(抜粋)
  <script src="./js/clmtrackr.min.js"></script> <!-- 追加 -->
  <script src="./js/model_pca_20_svm.js"></script> <!-- 追加 -->
  <script src="./js/application.js"></script>

STEP 3. いざ顔認識!

これで準備が整いました! あとはライブラリを使うだけです!

js/application.js
window.addEventListener("load", () => {
  let deviceNavigator = navigator.mediaDevices.getUserMedia({ audio: false, video: true });
  deviceNavigator.then((s) => {
    let target = document.getElementById("target");
    let ctracker = new clm.tracker(); //追加
    target.src = window.URL.createObjectURL(s);
    ctracker.init(pModel); //追加
    ctracker.start(target); //追加
  });
});

それといって説明することがないので省略します。

STEP 4. テスト

このままでは動いているのかいまいちわからないので、テストコードを作成します。

index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>camera</title>
  <script src="./js/clmtrackr.min.js"></script>
  <script src="./js/model_pca_20_svm.js"></script>
  <script src="./js/application.js"></script>
  <link rel="stylesheet" href="./css/application.css">
</head>
<body>
  <div id="container"><!-- 追加 -->
    <video id="target" width="800" height="600" autoplay></video>
    <canvas id="result" width="800" height="600"></canvas><!-- 追加 -->
  </div><!-- 追加 -->
</body>
</html>
js/application.js
window.addEventListener("load", () => {
  let deviceNavigator = navigator.mediaDevices.getUserMedia({ audio: false, video: true });
  deviceNavigator.then((s) => {
    let target = document.getElementById("target");
    let ctracker = new clm.tracker();
    let result = document.getElementById("result"); //追加
    let context = result.getContext("2d"); //追加
    let update = () => { //追加
      requestAnimationFrame(update); 
      context.clearRect(0, 0, result.width, result.height);
      ctracker.draw(result);
    };
    target.src = window.URL.createObjectURL(s);
    ctracker.init(pModel);
    ctracker.start(target);
    update(); //追加
  });
});
css/application.css
#container {
  position: relative;
}

#container > * {
  position: absolute;
  top: 0;
  left 0;
  transform: RotateY(180deg);
}

index.html

<canvas id="result" width="800" height="480"></canvas>によって輪郭を表示させるための場所を準備しました。

js/application.js

ctracker.draw(result);を使うことで、輪郭を出力しています。

css/application.css

transform: RotateY(180deg);カメラが鏡のように表示されるようにしています。

ここまでできるとこのようになっているはずです!
予想図
(引用: https://github.com/auduno/clmtrackr)

Chapter 3. Canvasを使って動画に加工をしよう!

ここからが本番です。

STEP 1. 動画をCanvasに出力しよう

今はvideoタグによってカメラから動画を出していますが、このままでは加工ができないのでキャンバスから出力するように変更します。

js/application.js
window.addEventListener("load", () => {
  let deviceNavigator = navigator.mediaDevices.getUserMedia({ audio: false, video: true });
  deviceNavigator.then((s) => {
    let target = document.getElementById("target");
    let ctracker = new clm.tracker();
    let result = document.getElementById("result");
    let context = result.getContext("2d");
    let update = () => {
      requestAnimationFrame(update);
      context.clearRect(0, 0, result.width, result.height);
      context.drawImage(target, 0, 0, result.width, result.height); //追加
      ctracker.draw(result);
    };
    target.src = window.URL.createObjectURL(s);
    ctracker.init(pModel);
    ctracker.start(target);
    update();
  });
});  

context.drawImageは文字通り、画像を書き出すメソッドです。videoタグの中から適宜フレームを画像として持ってきてcanvasタグに貼り付けています。

STEP 2. ピクセルデータの取得

画像データを加工するにはピクセルの情報を使うのですが、そのままだと扱いづらいのでクラスを作ります。

js/Color.js
class Color {
  constructor(r, g, b, a) {
    this.r = r;
    this.g = g;
    this.b = b;
    this.a = a;
  }
}
js/Image.js
class Image {
  constructor(canvas, context, width, height, px, py) {
    this.width = Math.floor(width);
    this.height = Math.floor(height);
    if(px === undefined || py === undefined) {
      this.data = context.createImageData(this.width, this.height);
    } else if(px !== undefined && py != undefined) {
      this.data = context.getImageData(Math.floor(px), Math.floor(py), this.width, this.height);
    }
  }

  getHeadAddress(x, y) {
    if(x < 0 || y < 0 || x >= this.width || y >= this.height) {
      return null;
    }
    return 4 * (Math.floor(x) + this.width * Math.floor(y));
  }

  getRGBA(x, y) {
    let p = this.getHeadAddress(x, y);
    if (p === null) {
      return new Color(0, 0, 0, 0);
    }
    return new Color(this.data.data[p], this.data.data[p + 1], this.data.data[p + 2], this.data.data[p + 3]);
  }

  setRGBA(x, y, c) {
    let p = this.getHeadAddress(x, y);
    this.data.data[p] = c.r;
    this.data.data[p + 1] = c.g;
    this.data.data[p + 2] = c.b;
    this.data.data[p + 3] = c.a;
  }
}

context.createImageDatacontext.getImageDataを使う時は、座標の値が整数になるように注意してください。バグります。

読み込みも忘れずに・・・

index.html(抜粋)
  <script src="./js/Color.js"></script>
  <script src="./js/Image.js"></script>
  <script src="./js/application.js"></script>

STEP 3. 認識した情報の取得

js/application.js
window.addEventListener("load", () => {
  let deviceNavigator = navigator.mediaDevices.getUserMedia({ audio: false, video: true });
  deviceNavigator.then((s) => {
    let target = document.getElementById("target");
    let ctracker = new clm.tracker();
    let result = document.getElementById("result");
    let context = result.getContext("2d");
    let update = () => {
      requestAnimationFrame(update);
      let positions = ctracker.getCurrentPosition(); //追加
      context.clearRect(0, 0, result.width, result.height);
      context.drawImage(target, 0, 0, result.width, result.height);
      ctracker.draw(result);
    };
    target.src = window.URL.createObjectURL(s);
    ctracker.init(pModel);
    ctracker.start(target);
    update();
  });
});

ctracker.getCurrentPosition();を呼び出すことで顔の識別点の座標が入った配列が取得できます。
どこの情報かは、下の写真を参考にしましょう。

位置情報
(引用: https://github.com/auduno/clmtrackr)

ただcssで出力が左右反転しているので、そこだけ注意しましょう!

STEP 3. 画像処理その1 -拡大・縮小-

画像の拡大縮小に挑戦します。いいライブラリがあると思いきや全く見当たらないので、自力で実装しましょう。
今回はバイリニア補間法を利用します。一番簡単に思いつく画像の拡大・縮小アルゴリズムは、処理後の画素の情報が、処理前の画像のどこから来ているかを予測し一番近い点をまるっと持ってくる最近傍法と呼ばれるアルゴリズムがあります。しかし、これだとキレイに処理できないので一番近い点と、その周辺の情報を元にいい感じの色を持ってきてくれるバイリニア補完法が便利です。

次のコードを一番上に追加しましょう!

js/application.js
let bilinear = (src, dst, sx, sy) => {
  for (let y = 0; y < dst.height; y++) {
    for (let x = 0; x < dst.width; x++) {
      let x0 = Math.floor(x / sx);
      let y0 = Math.floor(y / sy);
      if (x0 < src.width - 1 && y0 < src.height - 1) {
        let a = x / sx - x0;
        let b = y / sy - y0;
        let p1 = src.getRGBA(x0, y0);
        let p2 = src.getRGBA(x0 + 1, y0);
        let p3 = src.getRGBA(x0, y0 + 1);
        let p4 = src.getRGBA(x0 + 1, y0 + 1);
        let weighting = (c1, c2, c3, c4) => Math.round((1 - a) * (1 - b) * c1 + a * (1 - b) * c2 + (1 - a) * b * c3 + a * b * c4);

        dst.setRGBA(x, y, new Color(weighting(p1.r, p2.r, p3.r, p4.r), weighting(p1.g, p2.g, p3.g, p4.g), weighting(p1.b, p2.b, p3.b, p4.b), weighting(p1.a, p2.a, p3.a, p4.a)));
      }
    }
  }
}

srcが変換前の画像データ、dstが変換後の画像データを格納する場所です。dstを基準にしてループを回しているので、そこだけ注意してください。

STEP 4. 目の周辺を拡大してみよう!

タイトルのまんまです。座標だけうまく取れれば後はbilinearに回すだけです。

js/application.js
let bilinear = (src, dst, sx, sy) => {
  for (let y = 0; y < dst.height; y++) {
    for (let x = 0; x < dst.width; x++) {
      let x0 = Math.floor(x / sx);
      let y0 = Math.floor(y / sy);
      if (x0 < src.width - 1 && y0 < src.height - 1) {
        let a = x / sx - x0;
        let b = y / sy - y0;
        let p1 = src.getRGBA(x0, y0);
        let p2 = src.getRGBA(x0 + 1, y0);
        let p3 = src.getRGBA(x0, y0 + 1);
        let p4 = src.getRGBA(x0 + 1, y0 + 1);
        let weighting = (c1, c2, c3, c4) => Math.round((1 - a) * (1 - b) * c1 + a * (1 - b) * c2 + (1 - a) * b * c3 + a * b * c4);

        dst.setRGBA(x, y, new Color(weighting(p1.r, p2.r, p3.r, p4.r), weighting(p1.g, p2.g, p3.g, p4.g), weighting(p1.b, p2.b, p3.b, p4.b), weighting(p1.a, p2.a, p3.a, p4.a)));
      }
    }
  }
}

//追加
let expand_eye = (canvas, context, center, positions) => {
  let minX = Math.min.apply(null, positions.map(x => x[0]));
  let maxX = Math.max.apply(null, positions.map(x => x[0]));
  let minY = Math.min.apply(null, positions.map(x => x[1]));
  let maxY = Math.max.apply(null, positions.map(x => x[1]));
  let width = maxX - minX;
  let height = maxY - minY;
  let l = Math.max(width, height) * 1.2;
  let r = 1.25;
  let src = new Image(canvas, context, l, l, center[0] - l / 2, center[1] - l / 2);
  let dst = new Image(canvas, context, l * r, l * r);
  bilinear(src, dst, r, r);

  context.putImageData(dst.data, center[0] - l * r / 2, center[1] - l * r / 2);
};

window.addEventListener("load", () => {
  let deviceNavigator = navigator.mediaDevices.getUserMedia({ audio: false, video: true });
  deviceNavigator.then((s) => {
    let target = document.getElementById("target");
    let ctracker = new clm.tracker();
    let result = document.getElementById("result");
    let context = result.getContext("2d");
    flag = true;
    let update = () => {
      requestAnimationFrame(update);
      let positions = ctracker.getCurrentPosition();
      context.clearRect(0, 0, result.width, result.height);
      context.drawImage(target, 0, 0, result.width, result.height);
      let left_eye_positions = [positions[28], positions[29], positions[30], positions[31], positions[67], positions[68], positions[69], positions[70]]; //追加
      let right_eye_positions = [positions[23], positions[24], positions[25], positions[26], positions[63], positions[64], positions[65], positions[66]]; //追加
      expand_eye(result, context, positions[32], left_eye_positions); //追加
      expand_eye(result, context, positions[27], right_eye_positions); //追加

      ctracker.draw(result);
    };
    target.src = window.URL.createObjectURL(s);
    ctracker.init(pModel);
    ctracker.start(target);
    update();
  });
});

これで目の周辺が拡大されて出力されます。多少切り貼り感が出てしまう実装方法になってしまってますが気にしないでください・・・。

STEP 5. 画像処理その2 -重ねあわせ-

画像を撮った動画の中に貼り付けます。すでに察しているかもしれませんがcontext.drawImageを使うだけです。背景が透過のPNGを使うと綺麗です!

js/application.js
let bilinear = (src, dst, sx, sy) => {
  for (let y = 0; y < dst.height; y++) {
    for (let x = 0; x < dst.width; x++) {
      let x0 = Math.floor(x / sx);
      let y0 = Math.floor(y / sy);
      if (x0 < src.width - 1 && y0 < src.height - 1) {
        let a = x / sx - x0;
        let b = y / sy - y0;
        let p1 = src.getRGBA(x0, y0);
        let p2 = src.getRGBA(x0 + 1, y0);
        let p3 = src.getRGBA(x0, y0 + 1);
        let p4 = src.getRGBA(x0 + 1, y0 + 1);
        let weighting = (c1, c2, c3, c4) => Math.round((1 - a) * (1 - b) * c1 + a * (1 - b) * c2 + (1 - a) * b * c3 + a * b * c4);

        dst.setRGBA(x, y, new Color(weighting(p1.r, p2.r, p3.r, p4.r), weighting(p1.g, p2.g, p3.g, p4.g), weighting(p1.b, p2.b, p3.b, p4.b), weighting(p1.a, p2.a, p3.a, p4.a)));
      }
    }
  }
}

let expand_eye = (canvas, context, center, positions) => {
  let minX = Math.min.apply(null, positions.map(x => x[0]));
  let maxX = Math.max.apply(null, positions.map(x => x[0]));
  let minY = Math.min.apply(null, positions.map(x => x[1]));
  let maxY = Math.max.apply(null, positions.map(x => x[1]));
  let width = maxX - minX;
  let height = maxY - minY;
  let l = Math.max(width, height) * 1.2;
  let r = 1.25;
  let src = new Image(canvas, context, l, l, center[0] - l / 2, center[1] - l / 2);
  let dst = new Image(canvas, context, l * r, l * r);
  bilinear(src, dst, r, r);

  context.putImageData(dst.data, center[0] - l * r / 2, center[1] - l * r / 2);
};

window.addEventListener("load", () => {
  let deviceNavigator = navigator.mediaDevices.getUserMedia({ audio: false, video: true });
  let stamp1 = document.createElement("img"), stamp2 = document.createElement("img"), stamp3 = document.createElement("img"); //適宜追加

  stamp1.src = "./img/1.png";
  stamp2.src = "./img/2.png";
  stamp3.src = "./img/3.png";
  deviceNavigator.then((s) => {
    let target = document.getElementById("target");
    let ctracker = new clm.tracker();
    let result = document.getElementById("result");
    let context = result.getContext("2d");
    flag = true;
    let update = () => {
      requestAnimationFrame(update);
      let positions = ctracker.getCurrentPosition();
      context.clearRect(0, 0, result.width, result.height);
      context.drawImage(target, 0, 0, result.width, result.height);
      let left_eye_positions = [positions[28], positions[29], positions[30], positions[31], positions[67], positions[68], positions[69], positions[70]];
      let right_eye_positions = [positions[23], positions[24], positions[25], positions[26], positions[63], positions[64], positions[65], positions[66]];
      expand_eye(result, context, positions[32], left_eye_positions);
      expand_eye(result, context, positions[27], right_eye_positions);
      context.drawImage(stamp1, positions[20][0] - 40, positions[20][1] - 350, 75, 250); //適宜追加
      context.drawImage(stamp1, positions[16][0] - 40, positions[16][1] - 350, 75, 250); //適宜追加
      context.drawImage(stamp3, positions[3][0] - 20, positions[3][1] - 80, 100, 100); //適宜追加
      context.drawImage(stamp3, positions[11][0] - 90, positions[11][1] - 80, 100, 100); //適宜追加
      ctracker.draw(result);
    };
    target.src = window.URL.createObjectURL(s);
    ctracker.init(pModel);
    ctracker.start(target);
    update();
  });
});

数値は適宜いじってください。
これで完成です!

デモ

デモページ

リポジトリ

もしよければプルリクやissueください!
sasurai-usagi3/camera

まとめ

今回はcanvasという要素を中心に使っていきました。このcanvasは3Dモデリングなどもできるので、ぜひ挑戦してみてください。あと今回はCPUで画像処理を行いましたが、WebGLを使うとGPUで計算できるので、より高速になります!
jsはライブラリ探すと結構出てくるからオススメ!