OAK-D-Lite と DepthAI を用いてバーチャル背景合成プログラムを自作してみようという企画の3回目です。 ステップ3「カラー画像に対して視差データでマスクをかける」 の実装をやります。

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

ステップ3「カラー画像に対して視差データでマスクをかける」の実装

今回やりたいことは「画像から前景として表示したい部分だけを切り抜く」です。

すなわち、

元画像

を加工して、こんな感じの画像

変換後の画像

にします。

この切り抜き処理 (マスキング) を行う道具 (マスク) として、前回取得した以下の視差データを使います。

視差データ

コード実装

前回作成した視差データは「カメラに近い部分 (前景) はより白(255)に近く、カメラから遠い部分 (背景) は黒(0) に近い値」になっています。 ということは、視差データの全ピクセルの値 (0~255) を 0%~100% (0.0~1.0) に変換して、元画像のそれぞれ該当するピクセルに掛け合わせれば「前景だけが残り背景は黒くなる」となるはず…という想定でひとまずこれを実装してみます。

# 視差データの全ピクセルの値 (0~255) を 0.0~1.0 に変換して、マスクデータにする
def to_mask(frame):    
    return frame / 255.0

# 変換元画像の全ピクセルに対してマスクデータ (0.0~1.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')

(...)

        # カラーカメラの画像を取得する
        frame = ...

        # 視差データを取得する
        disp_frame = ...

        # 視差データの全ピクセルの値 (0~255) を 0.0~1.0 に変換して、マスクデータにする
        mask = to_mask(disp_frame)

        # 変換元画像の全ピクセルに対してマスクデータ (0.0~1.0) を掛け合わせる
        foreground = apply_mask(frame, mask)

        # 加工後の画像を表示する
        cv2.imshow("Foreground", foreground)

(...)

これで実際に表示してみるとこんな感じになります:

加工後データ(1)

いちおう背景を消すことはできたのですが、最初にイメージしていた画像とはちょっと違いますね…

コード実装 (改良)

この画像には以下のような問題があります:

  • (a) 輪郭 (前景と背景の境目部分) が汚い
  • (b) 前景が全体的に暗い
  • (c) 背景部分が真っ黒になっておらずまだら

これらの問題は、視差データから作成したマスク画像をそのまま使うのではなく、以下のように加工することで改善できそうです。

  • (a) 輪郭が汚い ⇒ 全体的にぼかしをかけて輪郭をまろやかにする
  • (b) 前景が暗い ⇒ 一定値以上のピクセルはすべて255に置換する
  • (c) 背景が黒くない ⇒ 一定値以下のピクセルはすべて0に置換する

to_mask にこれらの処理を追加します。

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

def to_mask(frame):
    # ぼかしをかけて輪郭をまろやかにする
    frame = cv2.medianBlur(frame, params['blur'])

    # ぼかしをかけると輪郭が少しやせるので戻す
    frame = cv2.dilate(frame, np.empty(0, np.uint8), iterations=params['dilate'])

    # 一定以上の濃度のピクセルは255に変換する
    frame = np.where(frame > params['max_brightness'], 255, frame)

    # 一定以下の濃度のピクセルは0に変換する
    frame = np.where(frame < params['min_brightness'], 0, frame)

    # 0~255 を 0.0~1.0に変換する
    return frame / 255.0

微調整が必要なパラメタに関しては後で変更しやすくするために params に格納しています。

これにより、もともとこんな感じだったマスクが

元のマスク

以下のような輪郭がなめらかで濃淡がはっきりしたものになり

加工後のマスク

これ元画像に適用して前景を切り取ると

マスク的用語画像

当初想定していた通りの出来栄えになりました。

ひとまずこれでOKなのですが、マスク加工用パラメタ (params) の値は変更後の画像を見ながら微調整できると便利なので、画面表示中のキー入力で各値を変更できる機能を追加しておきます。

# 入力されたキーでパラメタ調整する
def edit_params(key):
    # 'p' で現在設定されているパラメタ値を表示する
    if key == ord('p'):
        print('blur:', params['blur'])
        print('dilate:', params['dilate'])
        print('max_brightness:', params['max_brightness'])
        print('min_brightness:', params['min_brightness'])

    # 'b' でぼかしを強く
    if key == ord('b'):
        params['blur'] += 2
        print('blur:', params['blur'])

    # 'B' でぼかしを弱く
    if key == ord('B') and params['blur'] > 1:
        params['blur'] -= 2
        print('blur:', params['blur'])

    # 'd' で輪郭を太らせる
    if key == ord('d'):
        params['dilate'] += 1
        print('dilate:', params['dilate'])

    # 'D' で輪郭を痩せさせる
    if key == ord('D') and params['dilate'] > 0:
        params['dilate'] -= 1
        print('dilate:', params['dilate'])

    # 'x' で 255 にするピクセルの最大値を上げる
    if key == ord('x') and params['max_brightness'] < 256:
        params['max_brightness'] += 10
        print('max_brightness:', params['max_brightness'])

    # 'X' で 255 にするピクセルの最大値を下げる
    if key == ord('X') and params['max_brightness'] > 10:
        params['max_brightness'] -= 10
        print('max_brightness:', params['max_brightness'])

    # 'n' で 0 にするピクセルの最大値を上げる
    if key == ord('n') and params['min_brightness'] < 256:
        params['min_brightness'] += 10
        print('min_brightness:', params['min_brightness'])

    # 'N' で 0 にするピクセルの最大値を下げる
    if key == ord('N') and params['min_brightness'] > 0:
        params['min_brightness'] -= 10
        print('min_brightness:', params['min_brightness'])

with dai.Device() as device:
    (...)

    while True:
        (...)

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

        # パラメタ調整
        edit_params(key)

コード全体

import cv2
import depthai as dai
import numpy as np

# マスク調整用パラメタ
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'])

with dai.Device() as device:
    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())
        cv2.imshow("Preview", frame)

        # 視差データを取得して表示する
        disp_frame = to_grayscale(resize(q_disp.get().getFrame()))
        cv2.imshow("Disp", disp_frame)

        # マスク用画像を作成して表示する
        mask = to_mask(disp_frame)
        cv2.imshow("Mask", mask)

        # カラーフレームにマスクを適用して前景を切り取って表示する
        foreground = apply_mask(frame, mask)
        cv2.imshow("Foreground", foreground)

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

        # パラメタ調整
        edit_params(key)

次回

次回は ステップ4: 背景画像を読み込みこんで合成する をやってみます。

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