OAK-D-Lite と DepthAI を用いてバーチャル背景合成プログラムを自作してみようという企画の3回目です。 ステップ3「カラー画像に対して視差データでマスクをかける」 の実装をやります。
ゴールと段取り (おさらい)
- ゴール:
OAK-D-Lite をWebカメラとして用いてバーチャル背景合成を行い、Web会議サービス (ZoomとかGoogle Meetとか) に出席する。 - 段取り:
- ステップ1: カラーカメラの画像を画面に表示できる
- ステップ2: 視差データを取得して表示する
- ステップ3: カラー画像に対して視差データでマスクをかける (今回)
- ステップ4: 背景画像を読み込みこんで合成する
- ステップ5: Web会議サービスに合成画像を連携する
ステップ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)
(...)
これで実際に表示してみるとこんな感じになります:
いちおう背景を消すことはできたのですが、最初にイメージしていた画像とはちょっと違いますね…
コード実装 (改良)
この画像には以下のような問題があります:
- (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: 背景画像を読み込みこんで合成する をやってみます。