• このエントリーをはてなブックマークに追加

【WebRTC使ってみた】ブラウザカメラとあそぼ!

どうもこんにちは。本当はいつまでも新人でいたかった、フロントエンドエンジニア大月です。春ですね。

さて、昨年9月にリリースされたiOS11でMedia Capture APIがサポートに加わり、iOS版Safariから本体のカメラにアクセスできるようになったようですね。

Media Capture APIとは

Media Capture and Streams API (Media Streams) – Web API インターフェイス | MDN
難しいことが色々書いてあります。ざっくりと説明するとWebRTC関連のブラウザ用APIの一つで、これを使うとブラウザからカメラ・マイクにアクセスして動画や音声を扱うことができます。ちなみにここでいうWebRTCとはWebブラウザ同士でリアルタイムコミュニケーションを実現できる技術のことです。こちらのページで大変詳しく解説されています。勉強になりますね。

ニジボックスきってのカメラフェチ(自称)としてはこれを試さずにはいられません。むしろなぜ今まで試していなかったのかが分かりません。

早速やってみましょう!

カメラにアクセス

iOSでブラウザからカメラを扱うときは、httpsでアクセスしないと動きません(android chromeならlocalhostもOK)。
毎回サーバーにアップするのは骨が折れますね。ローカルでも効率的に作業したいので、今回はhttp-serverを使いました。

グローバルにインストール

$ npm install http-server -g

オレオレ証明書発行

$ openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 -keyout key.pem -out cert.pem

サーバー立ち上げ

$ http-server -S -C cert.pem

これでコマンドラインからサクッとwebサーバを立ちあげられます。

カメラにアクセスするには、MediaDevices.getUserMedia()メソッドを使います。

■ index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>browserCamamera sample01</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
    <style>
        html {
            height: 100%;
        }
        body {
        margin: 0 auto;
        height: 100%;
        }

        video {
            width: 100%;
            height: 100%;
            object-fit: fill;
        }
    </style>
  </head>
  <body>
    <video id="video" autoplay playsinline="true"></video>
    <script>
    var video = document.getElementById('video');
    var constraints = {
        audio: false,
        video: {
            // スマホのバックカメラを使用
            facingMode: 'environment'
        }
    };
    //  カメラの映像を取得
    navigator.mediaDevices.getUserMedia(constraints)
        .then((stream) => {
            video.srcObject = stream;
        })
        .catch((err) => {
            window.alert(err.name + ': ' + err.message);
        });
    </script>
  </body>
</html>

とても簡単ですね。
デモを置いておきます。
今回は下記の環境で動作を確認しています。

 ・ Safari(iOS版11.2/デスクトップ版11.0.3)
 ・ Google Chrome(mac版)

フロントカメラが立ち上がって画面に自分の顔が映ると毎回ビクッとしてしまうので、今回はスマホではバックカメラを使うことにしました。PCだと環境に合わせてカメラが立ち上がります。
バックカメラを使うデメリットは自分以外の誰かの顔を探さなければならないということです。
フロントカメラを使いたいときは、

var constraints = {
    audio: false,
    video: true
};

もしくは、

var constraints = {
    audio: false,
    video: {
        facingMode: 'user'
    }
};

とするとセルフィ出来ます。

また、CSSでvideoタグにobject-fit: fillを指定することで、映像を親要素の大きさいっぱいなるようにしています。
デフォルト以外のアスペクト比でvideoを扱いたい時には、こちらを指定すると良い感じになります。

顔認識してみる

こんなに簡単にカメラが使えるとなると、もっと何かしたくてうずうずしてきました。
clmtrackrという素敵なライブラリを使って顔認識をしてみようと思います。
こちらのライブラリはデモが充実しているので、そちらを大いに参考にさせていただくことにします。機能が豊富でデモをいじっているだけでもかなり楽しめます。

■ index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>browserCamamera TEST</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
    <link rel="stylesheet" href="./css/normalize.css">
    <link rel="stylesheet" href="./css/style.css">
  </head>
  <body>
    <div class="wrapper">
      <video id="video" class="video" autoplay playsinline="true"></video>
      <canvas id="overlay" class="overlay"></canvas>
    </div>
    <!-- ライブラリ -->
    <script src="./js/lib/clmtrackr.js"></script>
    <!-- 顔のモデル -->
    <script src="./js/lib/model_pca_20_svm.js"></script>
    <script src="./js/app.js"></script>
  </body>
</html>

■ style.css

html {
  height: 100%;
}

body {
  height: 100%;
  margin: 0 auto;
}

.wrapper {
  width: 100%;
  height: 100%;
  background: #000;
  position: relative;
}

.main {
  width: 100%;
  position: relative;
}

.video {
  width: 100%;
  max-width: 100%;
  height: 100%;
  object-fit: fill;
  display: block;
  margin: 0 auto;
}

.overlay {
  width: 100%;
  max-width: 100%;
  height: 100%;
  display: block;
  position: absolute;
  top: 0;
  left: 0;
}

■ app.js

var video = document.querySelector('#video');
var canvas = document.querySelector('#overlay');
var context = canvas.getContext('2d');
var constraints = {
  audio: false,
  video: {
    // スマホのバックカメラを使用
    facingMode: 'environment'
  }
};
var track = new clm.tracker({
  useWebGL: true
});

function adjustVideo() {
  // 映像が画面幅いっぱいに表示されるように調整
  var ratio = window.innerWidth / video.videoWidth;

  video.width = window.innerWidth;
  video.height = video.videoHeight * ratio;
  canvas.width = video.width;
  canvas.height = video.height;
}

function startTracking() {
  // トラッキング開始
  track.start(video);
  drawLoop();
}

function drawLoop() {
  // 描画をクリア
  context.clearRect(0, 0, canvas.width, canvas.height);
  // videoをcanvasにトレース
  context.drawImage(video, 0, 0, canvas.width, canvas.height);

  if (track.getCurrentPosition()) {
    // 顔のパーツの現在位置が存在
    track.draw(canvas);
  }
  requestAnimationFrame(drawLoop);
}

track.init(pModel);

// カメラから映像を取得
navigator.mediaDevices.getUserMedia(constraints)
  .then((stream) => {
    video.srcObject = stream;
    // 動画のメタ情報のロードが完了したら実行
    video.onloadedmetadata = function() {
      adjustVideo();
      startTracking();
    };
  })
  .catch((err) => {
    window.alert(err.name + ': ' + err.message);
});

なんとも簡単に顔をトラッキングすることができました!
興奮します。
デモはこちら

表情解析してエフェクトを加える

やはり笑顔というものはいいもので、元気が出ますね。
私はいつもアイドルの笑顔に元気をもらっています。
実はこのライブラリ、モデルを読み込むことで顔のパーツの座標を取得して表情解析もできちゃうのです。
取得した映像に手を加えたいので映像をvideoタグからcanvasにトレースしています。

■ app.js

var video = document.getElementById('video');
var canvas = document.getElementById('overlay');
var context = canvas.getContext('2d');
var isPortrait = true;
var imageData;
var mosaicSize;
var constraints = {
  audio: false,
  video: {
    // スマホのバックカメラを使用
    facingMode: 'environment'
  }
};
var track = new clm.tracker({
  useWebGL: true
});
var emotionClassifier = new emotionClassifier();

function successFunc (stream) {
  video.srcObject = stream;
  // 動画のメタ情報のロードが完了したら実行
  video.onloadedmetadata = function() {
    adjustProportions();
    startTracking();
  };
};

function startTracking() {
  // トラッキング開始
  track.start(video);
  drawLoop();
}

function adjustProportions() {
  var ratio = video.videoWidth / video.videoHeight;

  if (ratio < 1) {
    // 画面縦長フラグ
    isPortrait = false;
  }
  video.width = Math.round(video.height * ratio);
  canvas.width = video.width;
  canvas.height = video.height;
}

function displaySnapshot() {
  var snapshot = new Image();

  snapshot.src = canvas.toDataURL('image/png');
    snapshot.onload = function(){
      snapshot.width = snapshot.width / 2;
      snapshot.height = snapshot.height / 2;
      gallary.appendChild(snapshot);
    }
}

function drawLoop() {
  // 描画をクリア
  context.clearRect(0, 0, canvas.width, canvas.height);
  // videoをcanvasにトレース
  context.drawImage(video, 0, 0, canvas.width, canvas.height);
  // canvasの情報を取得
  imageData = context.getImageData(0, 0, canvas.width, canvas.height);

  if (track.getCurrentPosition()) {
    // 顔のパーツの現在位置が存在
    determineEmotion();
  }
  requestAnimationFrame(drawLoop);
}

function determineEmotion() {
  // 顔の顔のパーツのパラメータ
  var currentParam = track.getCurrentParameters();
  var emotionResult = emotionClassifier.meanPredict(currentParam);

  if (emotionResult) {
    // 感情解析結果が得られた時の処理
  }
}

function makeRosesBloom(level) {
  for (i = 0; i < level; i++) {
    if (isPortrait) {
      roseImage.src = portraitImagePath[i];
    } else {
      roseImage.src = landscapeImagePath[i];
    }
    context.drawImage(roseImage, 0, 0, canvas.width, canvas.height);
  }
}

pModel.shapeModel.nonRegularizedVectors.push(9);
pModel.shapeModel.nonRegularizedVectors.push(11);

track.init(pModel);
emotionClassifier.init(emotionModel);

// カメラから映像を取得
navigator.mediaDevices.getUserMedia(constraints)
.then(successFunc)
.catch((err) => {
    window.alert(err.name + ': ' + err.message);
});

笑顔の時にお花が咲くと平和な気持ちになれそうなので、ハッピーなときはセクシーローズを咲かせることにします。

■ app.js

var isHappy = false;
var happyLevel;
var roseImage = new Image;
// 画像のパス
var portraitImagePath = [
  './img/roses_portrait_1.png',
  './img/roses_portrait_2.png',
  './img/roses_portrait_3.png'
];
var landscapeImagePath = [
  './img/roses_landscape_1.png',
  './img/roses_landscape_2.png',
  './img/roses_landscape_3.png'
];

function drawLoop() {
  // 描画をクリア
  context.clearRect(0, 0, canvas.width, canvas.height);
  // videoをcanvasにトレース
  context.drawImage(video, 0, 0, canvas.width, canvas.height);
  // canvasの情報を取得
  imageData = context.getImageData(0, 0, canvas.width, canvas.height);

  if (track.getCurrentPosition()) {
    // 顔のパーツの現在位置が存在
    determineEmotion();
    if (isHappy) {
      makeRosesBloom(happyLevel);
    }
  }
  requestAnimationFrame(drawLoop);
}

function determineEmotion() {
  // 顔の顔のパーツのパラメータ
  var currentParam = track.getCurrentParameters();
  var emotionResult = emotionClassifier.meanPredict(currentParam);

  if (emotionResult) {
    for (var param in emotionResult) {
      var emotion = emotionResult[param].emotion;
      var value = emotionResult[param].value;

      if (value) {
        score = value.toFixed(1) * 100;
        switch(emotion) {
          case 'happy':
            if (80 < score) {
              happyLevel = 3;
              isHappy = true;
            } else if (70 < score) {
              happyLevel = 2;
              isHappy = true;
            } else if (60 < score) {
              happyLevel = 1;
              isHappy = true;
            } else {
              isHappy = false;
            }
            break;
        }
      }
    }
  }
}
// バラの画像描画処理
function makeRosesBloom(level) {
  for (i = 0; i < level; i++) {
    if (isPortrait) {
      roseImage.src = portraitImagePath[i];
    } else {
      roseImage.src = landscapeImagePath[i];
    }
    context.drawImage(roseImage, 0, 0, canvas.width, canvas.height);
  }
}

悲しい顔の時には顔にモザイクを入れます。
だって涙は見せられないから!
モザイクの処理はこちらの記事を参考にしました。

■ app.js

var isSad = false;

function drawLoop() {
  // 描画をクリア
  context.clearRect(0, 0, canvas.width, canvas.height);
  // videoをcanvasにトレース
  context.drawImage(video, 0, 0, canvas.width, canvas.height);
  // canvasの情報を取得
  imageData = context.getImageData(0, 0, canvas.width, canvas.height);

  if (track.getCurrentPosition()) {
    // 顔のパーツの現在位置が存在
    determineEmotion();
    if (isSad) {
      createMosaic(mosaicSize);
    }
    if (isHappy) {
      makeRosesBloom(happyLevel);
    }
  }
  requestAnimationFrame(drawLoop);
}

function determineEmotion() {
  // 顔の顔のパーツのパラメータ
  var currentParam = track.getCurrentParameters();
  var emotionResult = emotionClassifier.meanPredict(currentParam);

  if (emotionResult) {
    for (var param in emotionResult) {
      var emotion = emotionResult[param].emotion;
      var value = emotionResult[param].value;

      if (value) {
        score = value.toFixed(1) * 100;
        switch(emotion) {
          case 'sad':
            if (80 < score) {
              mosaicSize = 16;
              isSad = true;
            } else if (60 < score) {
              mosaicSize = 8;
              isSad = true;
            } else {
              isSad = false;
            }
            break;
          case 'happy':
            if (80 < score) {
              happyLevel = 3;
              isHappy = true;
            } else if (70 < score) {
              happyLevel = 2;
              isHappy = true;
            } else if (60 < score) {
              happyLevel = 1;
              isHappy = true;
            } else {
              isHappy = false;
            }
            break;
        }
      }
    }
  }
}

function createMosaic(mosaicSize) {
  for (y = 0; y < canvas.height; y = y + mosaicSize) {
    for (x = 0; x < canvas.width; x = x + mosaicSize) {
      // getImageData で取得したピクセル情報から該当するピクセルのカラー情報を取得
      var cR = imageData.data[(y * canvas.width + x) * 4];
      var cG = imageData.data[(y * canvas.width + x) * 4 + 1];
      var cB = imageData.data[(y * canvas.width + x) * 4 + 2];

      context.fillStyle = 'rgb(' +cR+ ',' +cG+ ',' +cB+ ')';
      context.fillRect(x, y, x + mosaicSize, y + mosaicSize);
    }
  }
}

今回は使用しませんが、clmtrackrではhappy・sadの他にも以下の表情の解析値が使えます。
 ・ anger: 怒り
 ・ disgusted: 嫌悪
 ・ fear: 恐れ
 ・ surprised: 驚き

キャプチャ機能を追加

せっかくお花がきれいに咲いたら、思い出にとっておきたいので、キャプチャ機能をつけましょう。

■ app.js

// 保存ボタン
var button = document.getElementById('button');
// 画像を表示する要素
var gallary = document.getElementById('gallary');

function displaySnapshot() {
  var snapshot = new Image();

  snapshot.src = canvas.toDataURL('image/png');
    snapshot.onload = function(){
      snapshot.width = snapshot.width / 2;
      snapshot.height = snapshot.height / 2;
      gallary.appendChild(snapshot);
    }
}

// 保存ボタンを押したら実行
button.addEventListener('click', displaySnapshot);

完成

あとは細かい処理やスタイルの調整をして…
さあ、できました。
デモはこちらです。
全ソースはこちらでご覧いただけます。

さあ、お花を咲かせに出発だ!

我らがリーダー、デザイナーの神田さんに出会いました。

怒っていらっしゃいます…?

あっ悲しいのかな?


弾けんばかりの笑顔ですね。いつもの神田さんで安心しました。

あそんでみて

とても簡単にブラウザからカメラの映像を取得してあそぶことができました。
ネイティブアプリではなくWEBブラウザから実現できると、htmlやjsで手軽に、端末依存も少なく実装できるので嬉しいですね!
現段階ではiOSでWebRTCを扱う場合コーデックがH.264で固定されてしまう等、実用レベルではまだまだ制限があるようです。今後のアップデートに期待ですね。
今回はjsのライブラリを使用しましたが、Google CLOUD VISION APIAmazon RekognitionMicrosoft Azure Computer Visionといった画像解析APIサービスも日に日に進化しているため、こちらと組み合わせるとさらに色々できそうでとても夢が広がります。

喜びと悲しみのマリアージュ