推ししか勝たんカメラをChrome拡張で作ったら素晴らしい世界だったので祝福したくなった話！

# はじめに
こんにちは、NTTドコモ入社2年目の小笠原です。
普段はネットワークデータの分析およびストリーム処理基盤の開発に取り組んでいます。
今回は、業務とは全く関係のない「推しカメラ」を作ってみた、という記事になっています。
「推しカメラ」とは、ライブ映像中に自分の「推し」を表示し続けるためのカメラとなってます。
もし、ライブ映像中に「もっとこの人にフォーカスした映像が見たかった！！」と思ったことがある方には、ぴったり（？）かもしれないのでご一読いただけたら嬉しいです。

## 作ろうと思った経緯
単純な話で自分自身が欲しいと思ったからです。
少し前、某アーティストのライブをライブ配信サービスで視聴する機会があり、そのサービスの中に「マルチアングル視聴」という機能がありました。
この機能は、ライブ映像を単一視点ではなく、複数視点から視聴できるものであり、非常に便利なものでした。
しかし（というか当たり前なのですが）、この視点の切り替えは手動で行う必要があるため、ライブ中にわざわざ切り替える必要がありました。
（普段のライブでは、スクリーンに映し出される映像をオペレータが感覚で決めていると推測されるため、自身に決定権があるだけでも充分に価値があるかもですが...）
そこで、複数視点からの映像があるのなら、工夫次第で推しにフォーカスが当たり続けるようなカメラを用意することができるのでは？という考えになったのが作ろうと思った経緯です。

## 新体感ライブ CONNECT
NTTドコモが提供するライブ映像配信サービス。新体感ライブ CONNECTのリンクは[こちら](https://shintaikan.live/)。
この配信サービスには、マルチアングル機能もあり、おすすめのサービスです。
下図は無料のサンプル動画[^1]（SILENT SIREN 「フジヤマディスコ」@ 豊洲PIT(2018.7.14)）となっており、複数視点からの映像を楽しむことができます。

[^1]: ライブによってはマルチアングル視聴機能がなかったり、視点の数が異なる可能性があります

![マルチアングル_dアカウント削除v2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/892731/3e8c2292-d1d6-56c4-4c81-9226ccdf392b.png "マルチアングル視聴のサンプル画面")

また、複数視点から自分のお気に入りの視点を選択し、その視点からの映像を楽しむこともできます。
今回は、このサービスに焦点を当ててシステムを構築してみました。

![マルチアングル_拡大_dアカウント削除v2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/892731/407dc780-616c-4e35-8c42-eee9e9fb05a2.png "マルチアングル視聴のサンプル画面 カメラ2を選択（フォーカス）")

# 「推しカメラ」の起動方法&見え方
新体感ライブ CONNECTのHPにいき、特定のライブ映像を再生した後に「dボタン」（Chrome拡張）を押すことによって、推しカメラが表示されるようになります。
※ 「dボタン」をブラウザに表示（起動できる状態に）するには別途作業が必要ですが、ここでは省略します

![推しカメラ_起動方法_加工済.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/892731/e2af5b41-e391-5084-29c0-9e8540de52f0.png "【起動方法】")

このカメラは、複数視点の画像から推しの顔が写っている画像を選択して、順次表示し続けてくれます。（別ウィンドウで表示されます）
※ 推しカメラに表示される顔にラベル付けがされていますが、わかりやすくするためであり消すことができます

![推しカメラ1v2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/892731/0eb27c11-11f9-5745-283f-5153c52a567e.png "【推しカメラ 表示例1】 マルチアングル視聴（すぅさんを推しに設定した場合）")

ブラウザからサーバへの画像転送や顔検出+顔識別が行われるため、ライブ映像よりも少し遅れた画像が表示されます。
わかりやすくいうと、ライブ映像を少し後追いしたような画像が表示され続けるイメージです。
遅延はマシンスペック、画像を転送する間隔によりますが、私の環境（CPU：3.41GHz Intel Core i7、RAM：16GB、画像転送間隔：1回/1秒）では2~3秒ほどの遅延でした。
この機能は、マルチアングルでなく、どこかのカメラにフォーカスしている場合であっても機能します。
個人的には、マルチアングルのままだと一つ一つの画面が小さいので、どこかのカメラにフォーカスした状態にし、他のカメラに写った推しを見逃さないといった使い方が良いかなと思っています。
最終的には、推しに優先度を付け、優先度の高い推しが写っている画像を表示させる仕様にしました。

![推しカメラ2v2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/892731/49a6dd58-9176-aac9-7ea7-ad34329f1e0f.png "【推しカメラ 表示例2】 マルチアングル視聴&カメラ1選択（ゆかるんさんを推しに設定した場合）")

# システムアーキテクチャ

![アーキテクチャ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/892731/42289969-0921-1b6f-8325-e9edda323a3d.png "アーキテクチャ")

Chrome拡張がDOMから画像を取得し、サーバに送信するといった構成。

- Chrome
  - ライブ配信サイト（新体感ライブ）にいき、「dボタン」を押すと推しカメラが起動
    - 「dボタン」は新体感ライブのサイトにいくと押せるように、裏で制御している
- Chrome拡張
  - 「dボタン」が押されると、DOMから画像を取得し、サーバに一定間隔で送信する
    - 画像はマルチアングル機能により、6枚の画像が1枚となったような画像を送信する
- サーバ（今回はローカルでたてる）
  - 画像を受信し顔判別を行った後で、推しが写っている画像を表示させる
    - 顔判別は顔検出+顔識別
    - 推しに優先順位をつけ、優先順位の高い推しを表示させることにする

# 実装
以下にそれぞれの実装コードを記載していく。
とりあえず動かすことと目的としていたため、洗練されてなかったり、冗長な部分があるかもしれませんが、ご容赦ください。

## Chrome拡張
Chrome拡張の概要や作り方などは以下のサイトを参照ください。
大変参考にさせていただきました。

- [公式サイト(What are extensions? - Google Chrome)](https://developer.chrome.com/extensions)
- [Chrome拡張開発 超概要](https://qiita.com/M_Kagawa/items/539d7597536ec90b1bc6)
- [Chrome拡張の作り方まとめ](https://qiita.com/omukaik/items/4df89dd68a8359907d48)

### 構成

```
fave_camera/  
　├ manifest.json  
　├ background.js  
　├ content.js  
　├ sub_myscript.js  
　├ jquery-3.3.1.min.js  
　├ worker_proxy.js  
　├ worker_proxy.html  
　└ images/  
```

- `jquery-3.3.1.min.js`はjQueryを使うためのもので、[こちら](https://jquery.com/download/)からダウンロードできます
- `worker_proxy.js`,`worker_proxy.html`はChrome拡張でWebWorkerを利用するためのもので、以下のgithubから使わせていただきました。
  - [Rob--W/chrome-api](https://github.com/Rob--W/chrome-api/tree/master/worker_proxy)
- `images`フォルダにはChrome拡張として使うアイコンの画像が入っています

下記ファイルには構成や権限設定などを記載します。

```json:manifest.json
{
	"manifest_version": 2,
	"name": "live Extention",
	"version": "0.4",
	"background": {
		"scripts": ["background.js", "worker_proxy.js"],
		"persistent": false
	},
	"page_action": {
		"default_icon": "images/icon.png",
		"default_title": "推しカメラを表示"
	},
	"permissions": [
		"tabs"
	],
	"content_scripts": [{
		"matches": ["<all_urls>"],
		"js": ["jquery-3.3.1.min.js", "worker_proxy.js", "content.js"]
	}],
	"web_accessible_resources": [
		"worker_proxy.html"
    ]
}
```

バックグラウンドで動作するスクリプトを記載します。
ここでは、拡張機能が有効となるURLの制御やアイコン（dボタン）の設定を行っています。
無効となっている場合は「dボタン」の文字が黒色、有効となっている場合は紫色となります。

```js:background.js
// Chrome extensions の Page Action の API
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
    if (tab.url.includes('https://shintaikan.live/')) {
        // 新体感ライブ のページなら有効になる
        chrome.pageAction.show(tabId);
    }
});

chrome.pageAction.onClicked.addListener(function(tab) {
    chrome.tabs.sendMessage(tab.id, {message: "start-live"}, function(isActive) {
        setIcon(tab.id, isActive);
    });
});

function setIcon(tabId, isActive) {
    const path = isActive ? "images/icon_active.png" : "images/icon.png";
    chrome.pageAction.setIcon({
        path, tabId
    });
}
```

次に、拡張機能が有効となった（「dボタン」が押された）際に起動するスクリプトです。
ここでは、画像の取得からローカルサーバへの送信までの一通りの処理を行っています。画像を取得する間隔は`interval`で制御しています。
工夫した部分は、サーバに送るために必要なHTML要素の画像化処理です。
ライブ映像の推しカメラであるため、できるだけ低遅延で行いたいのですが、一般的な（ググったらよく出てくる）方法（HTML要素->Canvasに描画->画像化）では、速度が出ませんでした。
そこで、以下の方法を取ることで改善を行いました。
HTML要素->Canvasに描画->imageDataに変換->画像化
また、より高速に動作させるために[WebWorker](https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API)を利用しました。

```js:content.js
let isActive = false;
const interval = 1000;
let timer;

function load_image(){
    video = $("video",$("div#dlv-watchPlayer-video > iframe")[0].contentWindow.document)[0];
    let canvas = document.createElement("canvas");
    canvas.width = 1280;
    canvas.height = 1080;
    canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);

    // imageData
    let imageData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
    let worker = new Worker(chrome.runtime.getURL('sub_myscript.js'));
    worker.onmessage = function(e) {
        let blob = new Blob( [e.data.data], {type: 'image/jpeg'} );
        let reader = new FileReader();
        reader.addEventListener("load", function () {
            // post request
            $.post( 'http://127.0.0.1:8000/fave-camera', reader.result )
            worker.terminate();
        }, false);
        reader.readAsDataURL(blob);
    };
    // error handler
    worker.onerror = function(e) {
        alert(e.filename + ":" + e.lineno + "\r\n" + e.message);
    }
    worker.postMessage({image: imageData});
}

chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
    isActive = !isActive; // toggle the active state
    if(request.message === "start-live" && isActive === true) {
        timer = setInterval("load_image()", interval);
    }
    else {
        clearInterval(timer);
    }
    sendResponse(isActive); // respond with the new active state
    return true;
});
```

WebWorkerにより呼び出されるスクリプト。主に、imageDataのJPGエンコードを行っています。

```js:sub_myscript.js
self.onmessage = function(e) {
    var jpgInfo = encode(e.data.image, 50);
    self.postMessage(jpgInfo);
};
// error handler
self.onerror = function(e) {
    alert(e.filename + ":" + e.lineno + "\r\n" + e.message);
}

/*

ここにJPG変換用の関数（長いので省略）

*/
```

## 推しカメラサーバ

### 環境

- python(3.6.8)
  - opencv-python==4.4.0.46
  - numpy==1.18.5
  - Pillow==8.0.1
  - tensorflow==2.3.1
  - Keras==2.4.3
  - tornado==6.1

画像処理には`OpenCV`、webフレームワークには`Tornade`を利用しました。
また、顔検出にはOpenCVのカスケード分類器を利用しました。

```python:web_server.py
# -*- coding: utf-8 -*-
import cv2
import numpy as np
from io import BytesIO
from PIL import Image
import base64
import re
import threading
from keras.models import  load_model

import os
import sys
import json
from datetime import date
import tornado.escape
import tornado.ioloop
import tornado.web

face_cascade_path = './haarcascades/haarcascade_frontalface_default.xml'

IMAGE_INTERVAL = 500

# 作成した識別モデル
model = load_model('./my_model-silent-4_1.h5')

# SILENT SIRENメンバー
Infer_member_1 = "Yukarun"
Infer_member_2 = "Suu"
Infer_member_3 = "Ainyan"
Infer_member_4 = "Hinanchu"

# 優先度を返す関数
def prior_member(name):
    if name == Infer_member_1:
        return 4
    elif name == Infer_member_2:
        return 3
    elif name == Infer_member_3:
        return 2
    elif name == Infer_member_4:
        return 1
    else:
        return 0

# 画像を分割する関数
def img_devide(img: 'mat') -> 'mat[]':
    h_split = 2
    v_split = 3
    dst = []
    [dst.extend(np.hsplit(h_img, h_split))
        for h_img in np.vsplit(img, v_split)]
    return dst

# 顔検出をする関数
def face_detect(image: 'mat') -> 'mat[]':
    # OpenCVを使って顔検出
    image_gs = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    cascade = cv2.CascadeClassifier(face_cascade_path)
    face_list=cascade.detectMultiScale(image_gs, scaleFactor=1.1, minNeighbors=2, minSize=(64,64))

    # 顔が１つ以上検出された時
    if len(face_list) > 0:
        max_width = 0
        for rect in face_list:
            x,y,width,height=rect
            if width > max_width:
                dst = image.copy()
                cv2.rectangle(dst, tuple(rect[0:2]), tuple(rect[0:2]+rect[2:4]), (255, 0, 0), thickness=3)
                img = image[rect[1]:rect[1]+rect[3],rect[0]:rect[0]+rect[2]]
                if image.shape[0]<64:
                    continue
                img = cv2.resize(image,(64,64))
                img=np.expand_dims(img,axis=0)
                name = detect_who(img)
                cv2.putText(dst,name,(x,y+height+20),cv2.FONT_HERSHEY_DUPLEX,1,(255,0,0),2)
                max_width = width
        image = dst
        return True, name, image, max_width
    # 顔が検出されなかった場合
    else:
        return False, "no face", image, 0

# 顔識別し、メンバーの名前を返す関数
def detect_who(img):
    global model
    #予測
    name=""
    nameNumLabel=np.argmax(model.predict(img))
    if nameNumLabel== 0:
        name="Yukarun"
    elif nameNumLabel==1:
        name="Ainyan"
    elif nameNumLabel==2:
        name="Hinanchu"
    elif nameNumLabel==3:
        name="Suu"
    return name

# 画像をデコードする関数
def decode_img(data: 'byte') -> 'img':
   stream = data.decode("UTF-8")[23:]
   img_data = base64.b64decode(stream)
   image = cv2.imdecode(np.asarray(bytearray(img_data), dtype=np.uint8), cv2.IMREAD_COLOR)
   return image

class PostHandler(tornado.web.RequestHandler):
    def post(self):
        self.set_header("Access-Control-Allow-Origin", "*")
        self.set_header('Access-Control-Allow-Methods', 'POST')

        image = decode_img(self.request.body)

        images = img_devide(image)
        max_width = 0
        prior = 0
        for img in images:
            isFace, name, face_img, width = face_detect(img)
            if isFace:
                if prior < prior_member(name):
                    find_face_img = face_img
                    max_width = width
                elif prior == prior_member(name):
                    if width > max_width:
                        find_face_img = face_img
                        max_width = width
        print(name)
        if not max_width == 0:
            cv2.imshow('fave-camera', find_face_img)
            cv2.waitKey(IMAGE_INTERVAL)

if __name__ == "__main__":
    application = tornado.web.Application([
        (r"/fave-camera", PostHandler)
    ])
    application.listen(8000)
    tornado.ioloop.IOLoop.instance().start()
```

## 顔識別モデルの構築
以下のサイトを参考にモデルを構築しました。
今回は、無料でサンプル視聴のできるSILENT SIRENのライブで行うため、SILENT SIRENのメンバーを識別するモデルの構築を行いました。
参考にしたものに対して大きな変更は行っていないため、コードは省略します。

- [KerasのCNNで、顔認識AIを作って見た〜スクレイピングからモデルまで〜
](https://qiita.com/yottyann1221/items/a08300b572206075ee9f)
- [乃木坂メンバーの顔をCNNで分類](https://qiita.com/nirs_kd56/items/bc78bf2c3164a6da1ded)

モデルを作るまでの流れ

1. 画像のクローリング
   - 各メンバー 300-400枚取得
2. アノテーション
   - 各メンバーのラベル付け&間違っている画像の除去
   - （大変だった...）
3. 画像の水増し
   - 回転、二値化、ぼかし処理を行うことで学習に使用する画像を約9倍に増やす
5. CNNによる学習

# 感想
- ライブ映像を楽しみながら推しにフォーカスしたカメラを作ることができた（おそらく）
  - Chrome拡張は初めて作ってみたが意外と簡単に作れることが分かった
- 顔検出、顔識別の精度が良くなかった
  - 学習に使用した画像とライブ中の画像が違いすぎることが主要因だと考えられる
    - ライブ中の画像は照明のあたり方やマイクの有無など通常の画像とは異なるシーンが多い
- 自動でカメラを切り替えてくれる仕様にするともっと便利
- 別のアーティストのライブで適用するには新たなモデルを作成する必要がある
  - モデルを作成する仕組みまでシステム化できれば、映像オペレータの仕事を補助できる（？）