FastAPIを用いてクロマキーAPIをつくろう！

# はじめに
はじめましての方ははじめまして、豊田高専2年情報工学科の[GoRuGoo](https://twitter.com/gorurobo_tech)と申します!「ゴルゴ」と呼んでください！
プログラミング歴も浅く、初めての投稿となります！温かい目で見守ってください...!

# 概要＆制作背景
今回、こうよう祭(文化祭)で2年情報は動物タワーバトルの人間バージョンを展示することになり、来場者の写真の背景を透過する必要がありました。ですが、いちいち画像処理ソフトを使って透過していては時間がかかってしまうのでWeb技術とOpenCVの勉強も兼ねてワンクリックで透過するAPIを作ろう！というノリで作成しました。

# リポジトリ
https://github.com/GoRuGoo/class_exhibition_color_technology


# ディレクトリ構造
クラス化してないのとファイル名が汚いのはご容赦ください、すみません...
uvicornを使ってapp/main.pyを起動してやるとローカルでシステムが立ち上がるようになってます。
app/run.pyではuvicornの起動オプションにSSL証明書と秘密鍵を渡してあげれば一応外部からの接続にも対応できます。(ホスト名とかは変えないといけない)
当日はオフライン環境だったのでjQueryはダウンロードして使用しています。
app/main.pyに長々とコードを書いてしまったのは反省点かもしれません...
変数名とかも汚いので時間があればリファクタかけたいです...
```
fastapi
├── app
│   ├── functions
│   │   ├── add_alpha_channel.py
│   │   ├── binary_to_bgr_convert.py
│   │   ├── convert_bgra_bgr.py
│   │   ├── convert_green_to_black.py
│   │   ├── convert_image_color.py
│   │   ├── create_black_image_array.py
│   │   ├── make_visualization_graph.py
│   │   ├── split_and_stack_alpha.py
│   │   └── transparent_black_ground.py
│   ├── main.py
│   └── run.py
├── static
│   ├── jquery-3.6.1.min.js
│   ├── main.css
│   └── main.js
├── templates
│   └── index.html
└── testcode
    └── mediatest.py
```
# コードの大まかな流れ
[メインページ受信](#メインページ受信)
[画像埋め込み](#画像埋め込み)
[画像送信](#画像送信)
[FastAPI受信](#FastAPI受信)
[画像加工](#画像加工)
[FastAPI送信](#FastAPI送信)
[画像受信](#画像受信)

## メインページ受信
立ててるサーバのURLにGETリクエストを投げてるだけです。以下のコードでFastAPIからtemplates/index.htmlを返します。
```Python
@app.get("/")
async def mainpage(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})
```
ただ単に静的のページを返してるだけですね、簡単です！
## 画像埋め込み
```HTML:templates/index.html
<div class="video_class">
    <video id="video" width="640" height="480" autoplay class="videocap"></video>
</div>
```
```JavaScript:static/main.js
var video= document.getElementById('video');
var media = navigator.mediaDevices.getUserMedia({video:true});


media.then((stream)=>{
    video.srcObject = stream;
});

var canvas = document.getElementById('canvas');
canvas.setAttribute('width', video.width);
canvas.setAttribute('height', video.height);


video.addEventListener(
    'timeupdate',
    function () {
        var context = canvas.getContext('2d');
        context.drawImage(video, 0, 0, video.width, video.height);
    },
    true
);
```
[MediaDevicesインターフェース](https://developer.mozilla.org/ja/docs/Web/API/MediaDevices)を用いてリアルタイムの映像をvideoタグに埋め込んでいます。こちらも特に複雑なことはしていません！
## 画像送信
```JavaScript:static/main.js
document.addEventListener('keydown', (event) => {
    var keyName = event.key;
    if (keyName === ' ') {
        console.log(`keydown: SpaceKey`);
        context = canvas.getContext('2d');
        // 取得したbase64データのヘッドを取り除く
        var img_base64 = canvas.toDataURL().replace(/^.*,/, '')
        captureImg(img_base64);
    }
});

var xhr = new XMLHttpRequest();
var image_to_embed = "";

// キャプチャ画像データ(base64)をPOST
function captureImg(img_base64) {
    const body = new FormData();
    body.append('img', img_base64);
    body.append('min_hue',hue_min_range.value);
    body.append('max_hue',hue_max_range.value);
    body.append('min_sat',sat_min_range.value);
    body.append('max_sat',sat_max_range.value);
    body.append('judge_mani_black',JudgeCheckbox(judge_mani_black.checked));
    //Formはboolを入力した場合文字列になってしまうので1と0に変換して送信する。
    body.append('detect_min_bright',min_bright.value);
    body.append('detect_max_bright',max_bright.value);
    if ((location.hostname == 'localhost') || (location.hostname == '127.0.0.1')){
        xhr.open('POST','http://' + location.host + '/transparent/',true);
        console.log('=')
    }else{
        xhr.open('POST','https://'+ location.host + '/transparent/',true);
        console.log('!=')
    }
```
```JavaScript:static/main.js
  xhr.send(body);
```
スペースキーを押した瞬間、流れている映像から画像を取得しbase64に変換してPOSTリクエストに追加してあげます。
この時、以下のコードでpngからbase64に変換しています。
```JavaScript:static/main.js
var img_base64 = canvas.toDataURL().replace(/^.*,/, '')
```
このtoDataURL()はbase64に変換してくれるのですが、画像をURLとして表示するための変換メソッドなのでいらないヘッダが含まれています。後にOpenCVに通す前に処理してあげてもいいのですが、綺麗なデータを送ってあげた方が個人的にいいと思ったので最初に処理しています。

Formを作ってあげて、そこに「先ほどの画像データ、スライダーから受け取ったHSVのしきい値、チェックボックスから受け取ったモード選択ボタン」の情報をXMLHttpRequestオブジェクトを使って画像処理用のURLにPOSTリクエストを送っています。
## FastAPI受信
```Python:app/main.py
@app.post("/transparent/")
async def test(
    img: str = Form(...),
    min_hue: int = Form(...),
    max_hue: int = Form(...),
    min_sat: int = Form(...),
    max_sat: int = Form(...),
    judge_mani_black: int = Form(...),
    detect_min_bright: int = Form(...),
    detect_max_bright: int = Form(...),
):
```
受け取ったFormから順番にデータを受け取っています。
特に何か捻っているわけでもありませんね！
## 画像加工
以下の順番で加工しています。
1. 画像デコード
2. ヒストグラム作成
3. HSV化orグレースケール化
4. 2値化＆ビット反転
5. 輪郭取得＆バイナリ→BGR変換
6. 輪郭内塗りつぶし
7. 置換＆アルファチャンネル追加

![slide2.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2882674/69c68a7a-d39d-3bb5-9c05-68b8b5aec78c.jpeg)
![slide3.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2882674/7615a964-764a-fd4b-063a-fdafc5630115.jpeg)
![slide4.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2882674/4cf1f963-7c3a-14bf-3e82-ba51081f9079.jpeg)
上の画像、少しだけ間違いがあるので下記の詳細解説を参照ください。

### 1.画像デコード
```Python:app/main.py
    image_binary = base64.b64decode(img)
    png = np.frombuffer(image_binary, dtype=np.uint8)
    after_convert_bin_to_image = cv2.imdecode(png, cv2.IMREAD_COLOR)
```
base64ライブラリを使って送られてきたbase64の画像をpngに変換しています。

### 2.ヒストグラム作成
```Python:app/funcitons/make_visualization_graph.py
import io

import cv2
import matplotlib.pyplot as plt
import numpy as np


def make_visualization_graph(image: np.ndarray) -> np.ndarray:
    h, s, v = image[:, :, 0], image[:, :, 1], image[:, :, 2]
    hist_h = cv2.calcHist([h], [0], None, [256], [0, 256])
    hist_s = cv2.calcHist([s], [0], None, [256], [0, 256])
    hist_v = cv2.calcHist([v], [0], None, [256], [0, 256])
    plt.plot(hist_h, color="r", label="H")
    plt.plot(hist_s, color="g", label="S")
    plt.plot(hist_v, color="b", label="V")
    plt.legend()
    buf = io.BytesIO()
    plt.savefig(buf, format="png")
    enc = np.frombuffer(buf.getvalue(), dtype=np.uint8)
    dst = cv2.imdecode(enc, 1)
    w, h = dst.shape[:2]
    setting_w = 656
    setting_h = 496
    dst = cv2.resize(dst, dsize=(setting_w, setting_h), fx=w / 656, fy=h / 496)
    dst = cv2.resize(dst, dsize=None, fx=1.1, fy=1.1)

    plt.cla()

    return dst

```
Numpyとmatplotlibを使ってヒストグラムを作成しています。安いグリーンバックなどを使用していたので環境に左右されやすかったので、目視で環境を確認できるように作成しました。アスペクト比を保ったまま自由にサイズ変更が可能です！

### 3.HSV化orグレースケール化
黒い服だと光を吸収してグリーンバックの部分が白飛びするのでこの場合は明暗だけで透過しています。
```Python:app/functions/convert_green_to_black.py
 gray_scale_img = cv2.cvtColor(image_wo_alpha, cv2.COLOR_BGR2GRAY)
```
Formから受け取った明度の最大値最小値をもとに
それ以外はHSVのしきい値を用いて透過しています。
```Python:app/functions/convert_green_to_black.py
 hsv_img = cv2.cvtColor(image_wo_alpha, cv2.COLOR_BGR2HSV)
```

### 4.2値化＆ビット反転
先ほどの黒い服の場合はFormから受け取った最小最大の検出明度の値を用いて２値化してビット反転しています。
(ビット反転するのは背景を黒にするため)
```Python:app/functions/convert_green_to_black.py
ret, binary_image = cv2.threshold(
    gray_scale_img, detect_min_bright, detect_max_bright, cv2.THRESH_BINARY
)
binary_image = cv2.bitwise_not(binary_image)
```
それ以外の場合は以下のようにしています。
```Python:app/functions/convert_green_to_black.py
binary_image = cv2.inRange(
    hsv_img, (min_hue, min_sat, 0), (max_hue, max_sat, 255)
)
binary_image = cv2.bitwise_not(binary_image)
```

### 5.輪郭取得＆バイナリ→BGR変換
以下のようにバイナリ画像を渡してあげて輪郭データを取得します。
```Python:app/functions/convert_green_to_black.py
countours, hierarchy = cv2.findContours(
    binary_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
```
以下の関数にバイナリ画像を渡してBGRに変換します。このとき、色がおかしくならないように要素の操作も行います。
```Python:app/funcitons/binary_to_bgr_convert.py
def binary_to_bgr_convert(bin_image: np.ndarray) -> np.ndarray:
    """バイナリイメージをBGRイメージに変換する.
    Args:
        bin_image(_type_):二次元配列のバイナリイメージ

    Returns:
        _type_:バイナリイメージの色を保ったままBGR形式にしたイメージ

    See Also:
    バイナリと同じサイズの全要素255配列を三次元方向に2個用意する
    ->連結->このままだと色がおかしいので三次元方向0番目の要素かつ、
    白であったらそれに合うように三次元方向の2個目3個目も変更
    ->np.uint8型に直して変換

    """
    binary_height, binary_width = bin_image.shape[:2]

    dummy_binary = np.full((binary_height, binary_width, 2), 255)
    dst_binary = np.dstack((bin_image, dummy_binary))
    dst_binary[:, :, 1] = np.where(dst_binary[:, :, 0] == 0, 0, 255)
    dst_binary[:, :, 2] = np.where(dst_binary[:, :, 0] == 0, 0, 255)
    dst_binary_eight = dst_binary.astype(np.uint8)
    return dst_binary_eight
```

### 6.輪郭内塗りつぶし
人間の形をとってその中を塗りつぶします。このようにすることにより、人間の体にできた細かなつぶつぶのような誤透過をなかったことにできます。
この時にも、色がおかしくならないように配列操作を最後に行います。
```Python:app/funcitons/convert_green_to_black.py
    after_fill_image = cv2.drawContours(
        after_convert_binary_image, countours, -1, (255, 0, 0), 3
    )
    cv2.fillPoly(after_fill_image, countours, 255)
    after_fill_image[:, :, 1] = np.where(
        after_fill_image[:, :, 0] == 0, 0, 255
    )
    after_fill_image[:, :, 2] = np.where(
        after_fill_image[:, :, 0] == 0, 0, 255
    )
```

### 7.置換＆アルファチャンネル生成
BGRが(255,255,255)だったら元画像に置換します。
```Python:app/functions/convert_green_to_black.py
    transparent = (255, 255, 255)
    result_image = np.where(
        after_fill_image == transparent, image_wo_alpha, after_fill_image
    )
```
ここで一旦全ての要素を255で埋め尽くした配列をBGRの最後に連結させます。
その後、黒の部分だけ透過する操作を以下のように行います。
```Python:app/functions/transparent_black_ground.py
def transparent_black_ground(image_w_alpha_black_background: np.ndarray) -> np.ndarray:
    """Transparent black ground.

    Args:
        image_w_alpha_black_background(_type_):背景が黒なアルファチャンネルを含んだ画像
    Returns:
        _type_:黒背景を透過した画像
    """
    b_ch, g_ch, r_ch, a_ch = cv2.split(image_w_alpha_black_background[:, :, :4])

    judge = (
        (image_w_alpha_black_background[:, :, 0] == 0)
        & (image_w_alpha_black_background[:, :, 1] == 0)
        & (image_w_alpha_black_background[:, :, 2] == 0)
    )

    image_w_alpha_black_background[:, :, 3] = np.where(
        judge, 0, image_w_alpha_black_background[:, :, 3]
    )

    return image_w_alpha_black_background

```
以上で画像加工は終了です！




## FastAPI送信
先ほど処理した画像とヒストグラムをbase64にエンコードしてJSONにして返しています。
(JSONで送るのが正しいのかは知らない...)
## 画像受信
```JavaScript:static/main.js
    xhr.onload = () => {
        let jsonObj = JSON.parse(xhr.responseText);

        image_to_embed = "data:image/png;base64," + jsonObj['image']
        image_graph = "data:image/png;base64," + jsonObj['graph']
        $('.image').attr('src',image_to_embed);
        $('.graphimage').attr('src',image_graph);
    };
```
受け取ったbase64の画像データをURLとしてのbase64にするためにヘッダを付ける加工をしています。
そして埋め込みます。


# おわりに
この製作を通して触ったことないJSが触れてよかったです。
プログラミング大好きなので皆んなで楽しみましょう！



# 主な参考サイト
ChromeでWebカメラを使う時に参考にしました。

https://qiita.com/sumita_v09/items/f1ada937ec64729b6c63

https://qiita.com/h_000/items/e5ff1152aae8c67af476

https://qiita.com/tatsuya11bbs/items/ff4a07d73e14385b6922