OAK-D-Lite と DepthAI を用いてバーチャル背景合成プログラムを自作してみようという企画の5回目です。 ステップ5「Web会議サービスに合成画像を連携する」 の実装です。

ゴールと段取り (おさらい)

ステップ5「Web会議サービスに合成画像を連携する」の実装

今回やることは以下4つです。

  1. 仮想カメラを利用可能する
  2. Web会議サービス送信用に画像を加工する
  3. 仮想カメラに画像を送信する
  4. 動作確認

1. 仮想カメラを利用可能する

作成した画像をWeb会議サービスから利用可能にするために仮想カメラ環境をセットアップします。

今回は仮想カメラ環境として OBS を利用します。 ダウンロードしてインストールしたら一度起動して画面右側の「仮想カメラ開始」をクリックし、さらに「仮想カメラ停止」をクリックして終了します。一度この操作を行えばあとはOBSを起動する必要はありません。

さらに仮想カメラ環境を python から利用するためにpyvirtualcam をセットアップします。

pip install pyvirtualcam

2. Web会議サービス送信用に画像を加工する

以下の2つの処理を行います。

  1. OpenCV形式のイメージをそのまま送信すると色がおかしくなるので、RGB形式イメージに変換する。
  2. 一部のWeb会議サービスでは正方形画像のままだと表示が崩れるため、画像の左右に余白を入れて長方形の画像する。
# 仮想カメラ送信用のイメージに変換する
def to_vcam_image(img):
    # OpenCV形式イメージ(BGR)をRGB形式イメージに変換する
    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

    # 元画像 (360*360) の左右に余白を入れて長方形 (640*360) にする
    return cv2.copyMakeBorder(img, 0, 0, 140, 140, cv2.BORDER_CONSTANT, (0, 0, 0))

3. 仮想カメラに画像を送信する

仮想カメラへの画像送信はこんな感じで書けます。

with pyvirtualcam.Camera(width=640, height=360, fps=30) as vcam:

      (...)

      # 仮想カメラ送信用に画像を変換する
      vcam_img = to_vcam_image(composite_image)

      # 仮想カメラに送信する
      vcam.send(vcam_img)

4. 動作確認

プログラムを起動した状態で、Web会議サービスを立ち上げてビデオデバイス設定画面で「OBS Virtual Camera」を選択してください。

zoom

ちなみに、Windows + Chrome + Google Meet で試した際には、OBSインストール後に一度 Windows を再起動しないと「OBS Virtual Camera」を認識してくれませんでした。

コード全体 (完成版)

import argparse
import cv2
import depthai as dai
import numpy as np
import pyvirtualcam

# マスク調整用パラメタ
params = {
  'blur': 67,             # ボケの強さ
  'dilate': 4,            # 輪郭拡張サイズ
  'max_brightness': 100,  # この濃度を上回るピクセルは 255 にする
  'min_brightness': 60,   # この濃度を下回るピクセルは 0 にする
}

# パイプラインを作成する
def create_pipeline():
    pipeline = dai.Pipeline()

    # カラーカメラを作成
    cam = pipeline.createColorCamera()
    isp_xout = pipeline.createXLinkOut()
    isp_xout.setStreamName("cam")
    cam.isp.link(isp_xout.input)

    # モノクロカメラ(左)を作成
    left = pipeline.createMonoCamera()
    left.setResolution(dai.MonoCameraProperties.SensorResolution.THE_400_P)
    left.setBoardSocket(dai.CameraBoardSocket.LEFT)

    # モノクロカメラ(右)を作成
    right = pipeline.createMonoCamera()
    right.setResolution(dai.MonoCameraProperties.SensorResolution.THE_400_P)
    right.setBoardSocket(dai.CameraBoardSocket.RIGHT)

    # ステレオ深度ノードを作成
    stereo = pipeline.createStereoDepth()
    stereo.setDepthAlign(dai.CameraBoardSocket.RGB)

    # ステレオ深度ノードに左右カメラを接続
    left.out.link(stereo.left)
    right.out.link(stereo.right)

    # 視差データ出力用ノードを作成
    xout_disp = pipeline.createXLinkOut()
    xout_disp.setStreamName("disparity")

    # 視差データ出力用ノードにステレオ深度ノードを接続
    stereo.disparity.link(xout_disp.input)

    return pipeline

# フレームを 360*360 に縮小する
def resize(frame):
    h = frame.shape[0]  # 高さ
    w  = frame.shape[1] # 幅
    d = int((w-h) / 2)
    return cv2.resize(frame[0:h, d:w-d], (360, 360))

# 視差データの各ピクセルの値を 0 ~ 255 に変換する
def to_grayscale(frame):
    multiplier = 255 / 95
    frame = (frame * multiplier).astype(np.uint8)
    return frame

# マスクデータを作成する
def to_mask(frame):
    frame = cv2.medianBlur(frame, params['blur'])
    frame = cv2.dilate(frame, np.empty(0, np.uint8), iterations=params['dilate'])
    frame = np.where(frame > params['max_brightness'], 255, frame)
    frame = np.where(frame < params['min_brightness'], 0, frame)
    return frame / 255.0

# 変換元画像の全ピクセルに対してマスクデータを掛け合わせる
def apply_mask(img, mask):
    masked = np.zeros(img.shape)
    for i in range(3):
      masked[:,:,i] = img[:,:,i] * mask

    return masked.astype('uint8')

# 入力されたキーでパラメタ調整する
def edit_params(key):
    if key == ord('p'):
        print('blur:', params['blur'])
        print('dilate:', params['dilate'])
        print('max_brightness:', params['max_brightness'])
        print('min_brightness:', params['min_brightness'])

    if key == ord('b'):
        params['blur'] += 2
        print('blur:', params['blur'])

    if key == ord('B') and params['blur'] > 1:
        params['blur'] -= 2
        print('blur:', params['blur'])

    if key == ord('d'):
        params['dilate'] += 1
        print('dilate:', params['dilate'])

    if key == ord('D') and params['dilate'] > 0:
        params['dilate'] -= 1
        print('dilate:', params['dilate'])

    if key == ord('x') and params['max_brightness'] < 256:
        params['max_brightness'] += 10
        print('max_brightness:', params['max_brightness'])

    if key == ord('X') and params['max_brightness'] > 10:
        params['max_brightness'] -= 10
        print('max_brightness:', params['max_brightness'])

    if key == ord('n') and params['min_brightness'] < 256:
        params['min_brightness'] += 10
        print('min_brightness:', params['min_brightness'])

    if key == ord('N') and params['min_brightness'] > 0:
        params['min_brightness'] -= 10
        print('min_brightness:', params['min_brightness'])

# 背景の読み込みを行う
def read_background_image(background_image_path):
    background = cv2.imread(background_image_path)
    return resize(background)

# 背景画像を取得する
def get_background(frame):
    if background_image is not None:
        return background_image

    # 背景画像指定がない場合はぼかしたカメラ画像を代用
    return cv2.GaussianBlur(frame, (99, 99), 0)

# 仮想カメラ送信用のイメージに変換する
def to_vcam_image(img):
    # OpenCV形式イメージ(BGR)をRGB形式イメージに変換する
    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

    # 元画像 (360*360) の左右に余白を入れて長方形 (640*360) にする
    return cv2.copyMakeBorder(img, 0, 0, 140, 140, cv2.BORDER_CONSTANT, (0, 0, 0))

# コマンドラインで背景画像指定を可能にする
parser = argparse.ArgumentParser()
parser.add_argument('-b', '--background', help="Path to background image file")
args = parser.parse_args()

background_image = None
if args.background is not None:
    background_image = read_background_image(args.background)


with dai.Device() as device, \
      pyvirtualcam.Camera(width=640, height=360, fps=30) as vcam:
    pipeline = create_pipeline()
    device.startPipeline(pipeline)

    q_color = device.getOutputQueue(name="cam", maxSize=1, blocking=False)
    q_disp = device.getOutputQueue(name="disparity", maxSize=1, blocking=False)

    while True:
      # マスクを適用した画像を作成する
      frame = resize(q_color.get().getCvFrame())
      disp_frame = to_grayscale(resize(q_disp.get().getFrame()))
      mask = to_mask(disp_frame)
      foreground = apply_mask(frame, mask)
      background = get_background(frame)
      background = apply_mask(background, 1.0 - mask)
      composite_image = foreground + background
      cv2.imshow("Preview", composite_image)

      # 仮想カメラ送信用に画像を変換する
      vcam_img = to_vcam_image(composite_image)

      # 仮想カメラに送信する
      vcam.send(vcam_img)

      # 画面で 'q' が入力されたら終了する
      key = cv2.waitKey(1)
      if key == ord('q'):
          break

      # パラメタ調整
      edit_params(key)

かわかみしんいち。島根県津和野町在住のフリーランスエンジニア。複合現実(Mixed Reality)と3DUXでおもちゃを作るのが趣味。 https://github.com/ototadana