【画像処理】Web会議ツールに依存しないバーチャル背景

今回はWeb会議ツールに依存しないバーチャル背景を作成してみようと思います。

![out_2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/4175505d-8ff4-c65b-c269-4460e8c9ee4a.gif)

使用するパッケージ名とバージョンは以下の通りです。
|パッケージ名|バージョン|説明|
|:-:|:-:|:-:|
|pyserial|3.5|シリアル通信を簡単に実装できるパッケージで、PCに接続されているカメラのポートIDを探索・取得するために使用します。|
|mediapipe|0.8.11|人間の顔や身体、表情などの検出を簡単に行うことができるパッケージです。https://google.github.io/mediapipe/|
|OpenCV|4.6.0|カメラから画像を取得・処理するために使用します。|
|pyvirtualcam|0.9.1|仮想カメラにフレームを送るために使用します。|

これに加えて、仮想カメラ機能を標準で搭載している[OBS Studio](https://obsproject.com/download)も使用します。

# データの流れ

![構成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/99e365f8-7c4c-5ae5-76aa-b994555d138f.png)



# 実装
```python:background_cover.py
import argparse
import numpy as np
from enum import Enum

import cv2
import serial.tools.list_ports
import mediapipe as mp
import pyvirtualcam
from pyvirtualcam import PixelFormat


def get_args():
    parser = argparse.ArgumentParser()

    parser.add_argument("--width", help="cap width", type=int, default=1280)
    parser.add_argument("--height", help="cap height", type=int, default=720)

    parser.add_argument("--model-type", help="model type", type=int, default=1)
    parser.add_argument("--score-th", help="score threshold", type=float, default=0.5)
    parser.add_argument(
        "--bg-color",
        help="background color ex.'BLACK','GRAY','WHITE'",
        type=str,
        default="GRAY",
    )
    parser.add_argument(
        "--bg-path", help="background image path", type=str, default=None
    )

    args = parser.parse_args()

    return args


def serial_find():
    ports = []
    ports = list(serial.tools.list_ports.comports())
    for p in ports:
        print(p.name, "- Port ID : " + str(p.hwid[-1]))

    print(f"Number of connected serial: {len(ports)}")


class bg_color(Enum):
    BLACK = (0, 0, 0)
    GRAY = (192, 192, 192)
    WHITE = (255, 255, 255)


if __name__ == "__main__":
    args = get_args()
    serial_find()

    port_id = input("select port ID : ")
    if not port_id.isnumeric():
        raise ValueError("error port ID")

    # windows only
    cap = cv2.VideoCapture(int(port_id), cv2.CAP_DSHOW)
    print("cap.isOpened() :", cap.isOpened())

    if not cap.isOpened():
        raise ValueError("error opening cam port")

    cap.set(cv2.CAP_PROP_FRAME_WIDTH, args.width)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, args.height)

    length = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
    height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))

    BG_COLOR = None
    for color in bg_color:
        if color.name == args.bg_color:
            BG_COLOR = color.value

    # create background image
    if args.bg_path == None:
        bg_image = np.zeros((height, width, 3), dtype=np.uint8)
        bg_image[:] = BG_COLOR
    else:
        bg_image = cv2.imread(args.bg_path)
        bg_image = cv2.resize(bg_image, (width, height))

    mp_drawing = mp.solutions.drawing_utils
    mp_selfie_segmentation = mp.solutions.selfie_segmentation

    with mp_selfie_segmentation.SelfieSegmentation(
        model_selection=args.model_type
    ) as selfie_segmentation:
        with pyvirtualcam.Camera(width, height, fps=30, fmt=PixelFormat.BGR) as cam:
            print(
                f"Virtual cam started: {cam.device} ({cam.width}x{cam.height} @ {cam.fps}fps)"
            )
            count = 0

            while cap.isOpened():
                # Restart video on last frame
                if count == length:
                    count = 0
                    cap.set(cv2.CAP_PROP_POS_FRAMES, 0)

                ret, frame = cap.read()
                if not ret:
                    raise RuntimeError("Error fetching frame")

                frame = cv2.cvtColor(cv2.flip(frame, 1), cv2.COLOR_BGR2RGB)
                frame.flags.writeable = False
                results = selfie_segmentation.process(frame)

                frame.flags.writeable = True
                frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)

                condition = (
                    np.stack((results.segmentation_mask,) * 3, axis=-1) > args.score_th
                )
                output_image = np.where(condition, frame, bg_image)

                cam.send(output_image)
                cam.sleep_until_next_frame()

                count += 1

    cap.release()

```

### オプション引数
```
usage: background_cover.py [-h] [--width WIDTH] [--height HEIGHT] [--model-type MODEL_TYPE]
                           [--score-th SCORE_TH] [--bg-color BG_COLOR] [--bg-path BG_PATH]

options:
  -h, --help            show this help message and exit
  --width WIDTH         cap width
  --height HEIGHT       cap height
  --model-type MODEL_TYPE
                        model type
  --score-th SCORE_TH   score threshold
  --bg-color BG_COLOR   background color ex.'BLACK','GRAY','WHITE'
  --bg-path BG_PATH     background image path
```


# 解説

### １. PCに接続されているWebカメラのポート番号を検出(serial.tools.list_ports)
```Python:serial_find()

def serial_find():
    ports = []
    ports = list(serial.tools.list_ports.comports())
    for p in ports:
        print(p.name, "- Port ID : " + str(p.hwid[-1]))

    print(f"Number of connected serial: {len(ports)}")

```

### ２. 指定したカメラから映像を取得
```Python

    cap = cv2.VideoCapture(int(port_id), cv2.CAP_DSHOW)
    print("cap.isOpened() :", cap.isOpened())

    if not cap.isOpened():
        raise ValueError("error opening cam port")

```

### ３. 取得した画像から人間を検出
```Python

                results = selfie_segmentation.process(frame)

```


### ４. 検出された人間以外の部分にバーチャル背景を描画
```Python

                condition = (
                    np.stack((results.segmentation_mask,) * 3, axis=-1) > args.score_th
                )
                output_image = np.where(condition, frame, bg_image)

```

### ５. 描画した画像を仮想カメラに出力
```Python

                cam.send(output_image)

```


### ６. 任意のWeb会議ツールで仮想カメラを入力に指定

試しにteamsのデバイス設定から仮想カメラの映像を確認してみました。
![teams_サンプル.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/e4ed43e4-92ee-2b7b-38aa-2b362560b364.png)


# 結果
無事にWebカメラの映像から人間を検出して、バーチャル背景を適用することができました。
また、仮想カメラの映像を取得することでアプリに依存せずにバーチャル背景を作成することが可能になりました。
ただし、人間の検出精度がバーチャル背景の輪郭精度に大きく影響するため、今回使用したmediapipeはteamsやzoomに標準搭載されているバーチャル背景よりも精度は落ちました。

![sunshine.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866058/87a89cbc-30bc-5a5d-3861-0c4efb136c0d.gif)


# さいごに
今回は、「Web会議ツールに依存しないバーチャル背景」について解説しました。

目次は以下の記事からご覧になれます。

https://qiita.com/spc_ehara/items/e425b6dcc0398299c40d#%E7%9B%AE%E6%AC%A1