スプレッドシートで顔写真付き座席表を作った

こんにちは、gloopsの池田です。

社内のエンジニアから[Advent Calendar](http://qiita.com/advent-calendar/2016/gloops)をやるという話が来たので、3番目でエントリーを書きます。
ちなみに、誕生日にエントリーを書いている人もちらほらいて、私も23日が40歳を迎える記念すべき日なのですが、そこは別の人にとられちゃっていたので、中途半端な日付で書いています。

で、何を書こうかなと考えたときに、CTOなので組織の話とかそれっぽいことを書くべきかとも思ったのですが、たまたま最近少しだけコード書く機会があったので、ここではそれを紹介したいと思います。

# ここで紹介する話
- Googleスプレッドシートで管理ができる「顔写真付き簡易座席表」を作ったはなし
- [Google Cloud Vision API](https://cloud.google.com/vision/)での、登録写真を判定するための仕組みについて
- [MediaDivices API](https://developer.mozilla.org/ja/docs/Web/API/MediaDevices)を使ったブラウザ上での画像生成

# 顔写真付き座席表とは
社内システムなので公開はできませんが、こんな感じのものです。
![座席表.jpg](https://qiita-image-store.s3.amazonaws.com/0/51676/190b6625-405c-cf20-5cb3-c53b8d0f6cae.jpeg)


座席ID/写真/名前/職種/所属チームなどがわかるシンプルなものです。

# 背景など
なぜ作ったか？を少し書くと、gloopsも社員数が200人くらいまでの頃は、社内のメインの導線に面した大きな壁の一面を使って、マグネットに顔写真をつけた座席表を作成し、誰がどこにいるのかがひと目でわかるようなものがありました。
しかし、社員がどんどん増え、座席表のメンテナンスコストも問題になりはじめていた頃に事務所を移転することになり、そのタイミングでマグネットの座席表運用自体を止めてしまったという過去があります。

そこからはスプレッドシート上で簡易的な座席表を運用してきたのですが、最近の社内の取り組みの中で「社員のつながりを強化する」というテーマで募集をしたところ、昔の顔写真付きの座席表の復活を望む声が多く上がってきました。

社内には様々な部署やチームがあるため、顔と名前が一致した状態で確認できることで、より円滑なコミュニケーションを！ということで、今回オンライン版で顔写真付きの座席表を作りました。

少し前置きが長くなりましたが、ここからは少し技術っぽい話になります。

# まずはオンライン版を作るにあたって、何をベースにするか

今回は、大きな工数をかけない、運用の柔軟性を確保すること、の２つを重視し、既存で運用しているスプレッドシートを拡張する形にしました。
誰でも簡単に編集ができ、オフィスのレイアウトの変更などにも簡単に対応できる点などは、スプレッドシートに勝るものは無いとの考えです。

# スプレッドシートに画像を埋め込む
画像の埋め込みに関しては、[IMAGE関数](https://support.google.com/docs/answer/3093333?hl=ja)を使うのが最も簡単なやり方です。
ただし、ここで指定できるのはパブリックはURLのみで、個人の顔写真ようなイントラ向けのものは、パブリックアクセスできる場所には置けないため、少しまわりくどいですが、Chrome拡張でHTMLにパブリッシュされたスプレッドシートに、画像を埋め込むという形で実現しています。

これによるデメリットとしては、対応ブラウザが制限されることです。
MicrosoftのEdgeでもAnniversary Update以降はChrome互換の拡張が提供できるようになったため、主要ブラウザはJSベースでの拡張機能を作れますが、いまのところはChromeのみの対応としています。

一方でChrome拡張化したことのメリットについては、プレーンなスプレッドシートのHTMLに、他の社内システムとの連携機能をつけられたことです。いまは、顔写真のマウスオーバーで、社内SNSのプロフィールが閲覧できたりなどの拡張をしています。


# 顔写真登録の全体的な仕組み
今回作成したものは、人事システムのような硬いものではなく、もう少しカジュアルに自分で顔写真を登録するという仕組みにしたため、投稿された画像が適切なものかをチェックする仕組みを入れました。

使い方のフローとしては、以下のようなもの

1. 写真をアップロード or その場で撮影し、[Cropper](https://fengyuanchen.github.io/cropper/)で登録したい部分を選択
2. Cloud Vision APIを使って画像のチェック
3. アカウントと紐付けて画像データを保存

アップロード＆保存などは一般的なWebシステムなので、詳細は割愛しますが、ここでは技術的なトピックとして２点紹介します


# Cloud Vision APIを使った顔写真のバリデーション

APIのセットアップや、何ができるかを書き出すと長くなるのでここでは省略して、いきなりAPIのリクエストの話から。

Googleの画像認識には、テキスト認識やランドマークの認識など、[様々なフィーチャー](https://cloud.google.com/vision/reference/rest/v1/images/annotate#Type)がありますが、ここでは顔認識（FACE_DETECTION）のみを指定します。

C#のコードは以下のような形。

```csharp
var service = new VisionService(new BaseClientService.Initializer
{
    ApiKey = "xxxxx",
    ApplicationName = "xxxxx"
});

var request = service.Images.Annotate(new BatchAnnotateImagesRequest
{
    Requests = new[]
    {
        new AnnotateImageRequest
        {
            Image = new Image { Content = Convert.ToBase64String(content) },
            Features = new[]
            {
                "FACE_DETECTION"
            }.Select(x=> new Feature { Type = x }).ToArray()
        }
    }
});

var response = request.Execute();
```

これを実行すると以下のようなレスポンスが帰ってきます。

![response.png](https://qiita-image-store.s3.amazonaws.com/0/51676/fe2160dc-b521-27aa-df6f-75e22d5c6dbd.png)


ここでは、実際に使用しているバリデーション内容を一部紹介します。

- 写真に顔が含まれているか？複数含まれていないか？
  - ```response.FaceAnnotations.Count```で確認できます。
  - 今回は顔と名前の一致が大きな目標のため、イラストやペットの写真は登録できないようにしています。
  - ゲームのイラストなどでもテストをしてみましたが、[アニメタッチのイラスト](http://graphic-loops.com/gallery/a000758/)はきちんと除外できましたが、[精度の高いイラスト](http://graphic-loops.com/gallery/a000799/)になってくると、Googleも顔だと認識していました。
- 適切な大きさで顔が写っているか？
  - ```FaceAnnotation.BoundingPoly.Vertices```で顔検出の座標4点が取得できるため、画像における顔の占める面積がチェックできます。
- 正面を向いた写真かどうか？
  - ```FaceAnnotation.PanAngle```で顔の傾きの角度がとれます。```-180```〜```180```の間で表現され、真正面を向いていると```0```になります
  - 今回は、```-30```〜```30```の範囲に含まれるかをチェックし、正面を向いた画像のみを許可しています。
- 顔全体が写っているか？
  - 上記の```BoundingPoly```のチェックでは、顔の半分が画像から見切れているものなどの判定ができないため、```FaceAnnotation.Landmarks```を用いて、顔の主要パーツが画像内に含まれているかを確認しています。```Landmarks```はには全部で[34種類の顔のパーツ](https://cloud.google.com/vision/reference/rest/v1/images/annotate#type_1)が定義されていますが、これらの座標が画像に含まれるかどうかを確認しています。
  - ちなみに、マスクで口が隠れていたり横を向いていたりして、パーツが実際に写真に映り込んでいなくても、APIは予想した位置を返して来ます。


このような形で、ある程度フォーマットのそろった顔写真を収集することができています。

# デバイス系のAPIを使った、写真登録を手軽にする仕組み
仕組みだけができても、多くの人に自ら写真を登録してもらうというのはそれなりに難しいものです。
ここでは登録を簡単に、また楽しくするために取り入れた仕組みを２つ紹介します。

- まずは簡単に登録できる仕組みとして、ブラウザからのWebカメラを起動＆その場で撮影
  - スマホではファイルアップロードの仕組みがあれば、デバイス側で対応してくれますが、デスクトップ版ではDevice系のAPI ```navigator.mediaDevices.getUserMedia()``` を使ってアクセスします。
  - そのデバイスからの入力をCanvasに描画しておけば、そこからの画像生成は```canvas.toDataURL()```で一発です
- また、楽しくアピールできるような画像も登録できるようGIFアニメにも対応し、Webカメラからも直接GIFアニメ化できるようにしました
  - Facebookもプロフィール動画に対応していたりするのを参考にしました
  - GIFエンコーディングの重い処理は、Web Workerに任せる
  - 参考にしたサイト：[Webカメラで直接GIFアニメをつくる](http://www.gcgate.jp/engineerblog/2014/02/07/672/)


これらの機能を実装したフロントのサンプルコードが以下。
クライアントでのGIF生成には、[jsgif](https://github.com/antimatter15/jsgif)のライブラリを使っています。

```html:sample.html
<script src="js/jquery.min.js" type="text/javascript"></script>
<script src="js/capture.js" type="text/javascript"></script>
<script src="js/jsgif/b64.js" type="text/javascript"></script>

<video id="capture-video" autoplay width="640" height="480"></video>
<canvas id="capture-canvas" style="display:none;" width="640" height="480"></canvas>
<input type="button" id="btn-capture" value="静止画キャプチャ"/>
<input type="button" id="btn-capture-anim" value="動画キャプチャ(2秒)"/>
<img id="capture-output"/>
```


```js:capture.js
var hasGetUserMedia = function() {
    return !!(navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia);
}

if (hasGetUserMedia()) {
    navigator.mediaDevices.enumerateDevices().then(function(devices) {
        var videos = devices.filter(function(d) { return d.kind == 'videoinput'; });
        return videos.length;
    }).then(function(result){
        if (!result) return;

        var video = document.querySelector('#capture-video');
        var canvas = document.querySelector('#capture-canvas');
        var ctx = canvas.getContext('2d');
        var localMediaStream = null;

        navigator.mediaDevices.getUserMedia({
            video: true
        }).then(function(stream) {
            video.src = window.URL.createObjectURL(stream);
            localMediaStream = stream;
        }).catch(function(e){
            console.log('カメラアクセスが許可されていません');
        });

        $('#btn-capture').click(function() {
            if (localMediaStream) {
                ctx.drawImage(video, 0, 0);
                var base64 = canvas.toDataURL('image/png');
                //var form = $('form');
                //$('<input type="hidden" name="capture"/>').val(base64).appendTo(form);
                //form.submit();
                $('#capture-output').attr('src', base64);
                
            }
        });

        $('#btn-capture-anim').click(function() {
            $('#btn-capture, #btn-capture-anim').attr('disabled', true);
            
            if (localMediaStream) {
                var encoder = new Worker('js/encoder.js');
                var fps = 5;
                var option = {
                    delay: Math.round(1000 / fps),
                    repeat: 0,
                    width: 640,
                    height: 480
                };
                encoder.postMessage({ cmd:'start', data: option });

                var counter = 0;
                var timer = setInterval(function() {
                    ctx.drawImage(video, 0, 0, 640, 480);
                    encoder.postMessage({ cmd:'frame', data: ctx.getImageData(0, 0, 640, 480).data });
                    counter++;
                    if (counter == 5)
                        $('#btn-capture-anim').val('動画キャプチャ(1秒)');

                    if (counter > 10) {
                        $('#btn-capture-anim').val('GIF画像生成中...')

                        clearInterval(timer);
                        encoder.onmessage = function (e) {
                            var base64 = 'data:image/gif;base64,' + encode64(e.data);
                            //var form = $('form');
                            //$('<input type="hidden" name="capture"/>').val(base64).appendTo(form);
                            //form.submit();
                            $('#btn-capture-anim').val('動画キャプチャ(2秒)');
                            $('#btn-capture, #btn-capture-anim').attr('disabled', false);
                            $('#capture-output').attr('src', base64);
                        };
                        encoder.postMessage({ cmd:'finish' });
                    }
                }, Math.round(1000 / fps));
            }
        });
    });
}
```

```js:encoder.js
importScripts('jsgif/LZWEncoder.js', 'jsgif/NeuQuant.js', 'jsgif/GIFEncoder.js');

var encoder = new GIFEncoder();

self.addEventListener('message', function(e) {
	switch (e.data.cmd) {
		case 'start': 
			var data = e.data.data;
			encoder.setRepeat(data.repeat);
			encoder.setDelay(data.delay);
			encoder.setSize(data.width, data.height);
			encoder.start();
			break;
	    case 'frame':
			encoder.addFrame(e.data.data, true);
			break;
	    case 'finish':
			encoder.finish();
			postMessage(encoder.stream().getData());
			break;
    }
});
```

※上記コードではアップロード処理をコメントアウトし、キャプチャ画像を確認できるようにしています


# おわりに
ここ数年、HTML5のAPIはどんどん進化しできることも大幅に増えてきました。
またディープランニング系の画像認識のAPIも[Amazon Rekognition](https://aws.amazon.com/jp/blogs/news/amazon-rekognition-image-detection-and-recognition-powered-by-deep-learning/)の登場によって、主要なクラウドプラットフォームではどこでも提供される状況になりました。

このような高度なAPIが誰でも使える状況のため、使ってみた／触ってみた、というエントリーはよく見かけるようになりましたが、実際にこんな風に使ってるよというような事例は、まだまだ少ないという印象のため、今回は社内で実際に使っている仕組みの一部を紹介してみました。

以上、池田がお送りしました。

***
gloopsでは、一緒に働いていただける方を絶賛募集中です。募集中の職種は[こちら](http://gloops.com/recruit/)から！