予期せぬ形で降ってきた急なテレワーク命令・強制的な働き方改革.在宅勤務だ,打ち合わせはテレビ会議だ,と言われ,部屋がきたないとからイヤだと言っても,そんな理由でテレビ会議を中止するわけにはいきません.今回は,Azure Kinect DK を使って,きたない部屋をキレイに見せる方法をご紹介します.
はじめに
はじめにもなにも,大変なことになってまいりました.
快適なテレワークにむけて
予期せぬ形で降ってきた急なテレワーク命令・強制的な働き方改革に戸惑っておられる方も多いと思います(家族や本人が感染して大変な思いされてる方や,仕事がなくなって困ってらっしゃる方々のことを思えばなんてことはないのですが).
かくいう私も唐突に「出社すんな」「打ち合わせはテレビ会議で」と言われてしまいました.会社に行かなくていいのはむしろご褒美なのですがテレビ会議のほうはちょっとイヤかも.なぜならとにかく部屋がきたない.
本記事では,Azure Kinect DK を使って,きたない部屋をキレイに見せる方法をご紹介します.
Azure Kinect DK で背景消し
前回 Kinect DK を使ってモーションキャプチャを行いましたが,今回は Kinect DK の本来の機能である深度カメラ機能を使います.カメラから被写体の距離を測り,カメラのすぐ前の部分の映像だけを取り出して,背後の部分は別の画像に置き換えます.たとえばソフマ○プ風背景つけるとこんな映像が作れます.

カメラの直前にある私の姿だけが写り,人にお見せしたくない背後のもろもろがすっかり消えてます。なお目線は後加工で入れてます. (余談ですが 『2020.03.22 よどがわマラソン』 はコロナで中止になりました。オシャレTシャツだけが送られてきたんですが,これどこに着ていけばいいんだ?)
プロジェクトを準備する
さっそく作り方を説明します.今回は C/C++ API を使います.
Visual Studio で新規C++プロジェクトを作成して,必要なライブラリを追加します. 背景消しの加工をした映像を画面に表示するためにウインドウを作るわけですが,今回は GLFWライブラリを使って,OpenGL で表示してみます.OpenGL という名前を出すだけで「うゎ無理!」と引いてしまう方もいらっしゃるかもしれませんが,本稿ではシェーダーとか一切出てきませんので安心してください.ちょっとテクスチャ作ってウインドウ表示するのに使うだけです.
GLFWライブラリのインストールは Visual Studio の [ツール]->[パッケージマネージャ] で、検索ボックスに “GLFW” を入力してインストールボタンを押すだけ.簡単です.ついでに Azure Kinect DK の API も NuGet で導入すると,こんな感じになるはずです.

今回は面倒なのでCPUで絵を描きますが,GPU機能をばりばり使いたい場合はGLEWもここで追加しておいてもよいでしょう.
NuGetの便利さにふるえつつ,プロジェクトのプロパティの,[リンカ]->[入力]->[追加の依存ファイル]に opengl32.lib を追加すると,

準備完了.
プログラムを書く
Kinect DK の深度カメラ部とRGBカメラ部は別々のセンサです.当然のことながらそれぞれ解像度や画角などの特性が異なります.したがってそのままでは深度情報とRGB値をピクセルトゥーピクセルで重ねることはできません.クリッピングや投影変換などが必要です.
まずは深度とカラー映像を重ねられるようにセンサを設定します.Kinect DK では、画角を広くとる代わりに測定できる距離が短い(WFOV,Wide Field of Viewの略)と,画角が狭いが遠距離まで高密度に取れる(NFOV)のモードを選べます.深度情報をRGBカメラと重ねる場合は,公式ドキュメントのここの図解 などを見つつ適切なモードを選んでみてください.今回はRGB画像の全域に重なるように距離を測りたいので深度のモードはWFOVを使います.
Kinect DK API には,カラー画像と深度をピクセル単位でアライン・クリッピングするためのユーティリティクラスが用意されてるので,カメラのスタート後についでにその初期化もしておきます.
というわけで,カメラの初期化コードはこんな感じになります.
// kinect dk をopenする
k4a::device cam;
try {
cam = k4a::device::open(K4A_DEVICE_DEFAULT);
} catch (k4a::error) {
std::cerr << "Failed to opent k4a device" << std::endl;
return 1;
}
// Kinect DK の設定
k4a_device_configuration_t config = K4A_DEVICE_CONFIG_INIT_DISABLE_ALL;
config.camera_fps = K4A_FRAMES_PER_SECOND_15; // 30fps出したい場合はBINNEDモードにしてね
config.color_format = K4A_IMAGE_FORMAT_COLOR_BGRA32;
config.color_resolution = K4A_COLOR_RESOLUTION_720P;
config.synchronized_images_only = true;
config.depth_mode = K4A_DEPTH_MODE_WFOV_UNBINNED; // 深度は広角モードに設定しないとRGB画像の撮影範囲いっぱいに重ねられない
config.wired_sync_mode = K4A_WIRED_SYNC_MODE_STANDALONE;
// 撮影スタート
try {
cam.start_cameras(&config);
} catch (k4a::error) {
std::cerr << "Failed to start k4a device" << std::endl;
cam.close();
return 1;
}
// カメラの投影変換オブジェクトも作っておく
k4a::calibration calib = cam.get_calibration(config.depth_mode, config.color_resolution);
k4a::transformation transform = k4a::transformation(calib);
次に,背景を塗りつぶすためのビットマップ画像のローダーを用意します.
今回は、24bit カラー形式で保存した BMP 画像ファイルをロードします.Windows 付属の “ペイント” 等でフルカラービットマップ形式を指定して保存したら作られるやつです.フルカラーBMPはファイルのヘッダを外すだけで簡単に各画素 BGR 順の無圧縮バッファとして扱えます.プログラムはこんな感じになります.
#pragma once
namespace Bmp
{
class BmpBuf
{
public:
BmpBuf() {}
virtual ~BmpBuf() {}
int Load(const char * filename); // 画像の読み込み
size_t getWidth() { return bw_; } // 画像の幅
size_t getHeight() { return bh_; } // 画像の高さ
uint8_t* get(int x, int y); // x, y は 0オリジン, 原点は左上
uint8_t* getRaw();
private:
size_t bw_ = 0; // 画像幅(pixel)
size_t bh_ = 0; // 画像高(pixel)
size_t lineW_ = 0; // ラインの幅(byte)
std::unique_ptr<uint8_t[]> buf_; // 原点は左下
};
}
↑これがヘッダで,↓こっちが実装.
#include <iostream>
#include <fstream>
#include <memory>
#include "BmpBuf.h"
namespace Bmp
{
int BmpBuf::Load(const char* filename)
{
const size_t HdrSz = 54; // BMPファイルのヘッダサイズ
std::ifstream fin(filename, std::ios::in | std::ios::binary);
if (!fin) {
std::cout << "ファイルが開けない" << std::endl;
}
fin.seekg(0, std::ios_base::end);
size_t fsize = static_cast<size_t>(fin.tellg()); // ファイルサイズ取得
if (fsize > HdrSz) {
fin.seekg(0x12); // width
fin.read(reinterpret_cast<char *>(&bw_), sizeof(int));
fin.seekg(0x16); // height
fin.read(reinterpret_cast<char *>(&bh_), sizeof(int));
lineW_ = bw_ * 3;
lineW_ += lineW_ % 4 == 0 ? 0 : (4 - lineW_ % 4); // 行は4byteアライン
buf_.reset(new uint8_t[lineW_ * bh_]); // 24bit BGR形式のみ想定
if (fsize == HdrSz + lineW_ * bh_) {
fin.seekg(HdrSz);
fin.read(reinterpret_cast<char*>(buf_.get()), lineW_ * bh_);
}
else {
std::cout << "BMPデータが不正" << std::endl;
fsize = -1;
}
}
else {
std::cout << "BMPファイルじゃない" << std::endl;
fsize = -1;
}
fin.close();
return fsize;
}
uint8_t* BmpBuf::get(int x, int y)
{
x %= bw_ ;
y %= bh_;
return buf_.get() + x * 3 + (bh_ - 1 - y) * lineW_;
}
uint8_t* BmpBuf::getRaw()
{
return buf_.get();
}
}
次は OpenGL のテクスチャ作ります.
初期化してウインドウ作るところは OpenGL 全く知らなくても雰囲気でわかると思います.そっから先にちょっとだけ呪文みたいなところがありますが OpenGL は今回説明したいことの本題ではありませんし,今後の人生において OpenGL とお近づきになる可能性がない人はスルーでOKです.そんなの覚えなくたっていまは Unity なんかもありますしね.
#include <GLFW/glfw3.h>
~中略~
if (glfwInit() == GL_FALSE) {
std::cerr << "GLFW 初期化失敗" << std::endl;
return 1;
}
GLFWwindow* const window(glfwCreateWindow(winWidth, winHeight, "ObeyaCam", NULL, NULL));
if (window == NULL)
{
std::cerr << "ウインドウ作成失敗" << std::endl;
return 1;
}
// GLFW, OpenGL の描画準備
glfwMakeContextCurrent(window);
glfwSwapInterval(1);
glOrtho(0.0f, winWidth, 0.0f, winHeight, -1.0f, 1.0f);
次は,出力用の画像データを作っていきます.
まずは画像の編集用バッファのクラスを作って,
class TexSrcBmp {
public:
TexSrcBmp(size_t width, size_t height) : w_(width), h_(height) {
buf_.reset(new uint8_t[w_ * h_ * 4]); // BGRA なので * 4
}
// x, yの指定は0オリジン, 原点は左上
void set(int x, int y, uint8_t r, uint8_t g, uint8_t b) {
int ofs = (x + (h_ - 1 - y) * w_ ) * 4;
buf_[ofs + 0] = b;
buf_[ofs + 1] = g;
buf_[ofs + 2] = r;
buf_[ofs + 3] = 255;
}
int width() { return w_; }
int height() { return h_; }
uint8_t* getRaw() { return buf_.get(); }
private:
size_t w_ = 0;
size_t h_ = 0;
std::unique_ptr<uint8_t[]> buf_; // 原点は左下
};
で,このクラスに描画していきます.
Kinect DK が撮影した各RGB画素に対応する位置の距離を調べて,一定の範囲内であれば,上記のクラスにカラー画像の画素をセットしていきます. 一方,カメラから対象が一定距離以上離れてるか,対象の距離が判別できない画素については,さきほどロードした背景用BMP画像のパターンを埋めていきます.
Kinect DK からカラー画像と深度情報を取り出して深度情報をカラー画像にアラインする処理は,下記のforループの先頭付近で k4a::capture をごにょごにょしてる部分です.カメラから対象の距離が,各ピクセル16ビット符号なし整数の配列として出力されます.単位はmm.
// テクスチャ構築用メモリバッファ
TexSrcBmp texSrc(texWidth, texHeight);
// Windowが開いてる間繰り返す
while (glfwWindowShouldClose(window) == GL_FALSE)
{
// Kinect DK からキャプチャ
k4a::capture cap = k4a::capture::create();
if (cam.get_capture(&cap) != true) {
std::cerr << "failed to read a capture" << std::endl;
}
// 画像の取得
k4a::image img_c = cap.get_color_image();
k4a::image img_d = cap.get_depth_image();
if (!(img_c.is_valid()) || !(img_d.is_valid())) {
std::cerr << "get image failed" << std::endl;
}
// depthイメージをcolorイメージと同じ解像度に変換.深度は16bit 1ch
k4a::image img_dc = transform.depth_image_to_color_camera(img_d);
uint16_t* pDc = (uint16_t*)img_dc.get_buffer();
uint8_t* cIdx = img_c.get_buffer();
for (int row = 0; row < texHeight; row++) {
for (int col = 0; col < texWidth; col++) {
// 映像をコピー
uint8_t* bgPtr = bmpBuf.get(col, row);
if (*pDc > VisibleDistance) { // 指定距離より遠い
// 背景を単純にコピー
texSrc.set(col, row, *(bgPtr + 2), *(bgPtr + 1), *bgPtr);
}
else if (*pDc > 0) { // 指定距離より近くて,距離不明でない
// RGB画像を単純にコピー
texSrc.set(col, row, *(cIdx + 2), *(cIdx + 1), *cIdx);
}
else { // 距離不明
texSrc.set(col, row, *(bgPtr + 2), *(bgPtr + 1), *bgPtr);
// ↑本来背景にしときゃいいのだが,距離不明はたいていがカメラに対して垂直な面.
// きれいに見せるためには hole filling 的な処理したほうが絶対いい.
}
pDc++;
cIdx += 4;
}
}
// KinectDK関連のオブジェクトの掃除
img_dc.reset();
img_c.reset();
img_d.reset();
cap.reset();
~後略(後述)~
今回は説明を簡単にするために,深度センサーの値によって,カメラ画像を表示するか背景画像に置き換えるかのデジタルな判断をしてます.現実には距離センサにノイズが乗ったり,物体表面の材質によっては測定結果がぶれるピクセルも出てきますので,距離不明な部分などは近傍ポイントを判断に入れるとか,背景とカメラ画像を何らかの比率でブレンドするといったコードを入れたり,OpenGL の機能つかって補完するなりしてきれいな表示になるように工夫することをお勧めします.補正する対象が距離不明点だけであれば,画像全体の面積からすれば大した割合ではないので少々凝った処理してもさして負荷はふえないでしょう.
画像処理 for ループの後半は,画面への表示処理です.一行除いて全部 “glほげほげ()” って名前の関数ですが,全部 OpenGL 関係です.今はそこに踏み込むつもりはないので理解してなくても「あー完全に理解した」とか言いつつ流してください.作ったビットマップを OpenGL のテクスチャに送り込んで表示してるだけです.
~略~
// ビットマップをテクスチャメモリに設定
glBindTexture(GL_TEXTURE_2D, textureId);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, texWidth, texHeight, 0, GL_BGRA_EXT, GL_UNSIGNED_BYTE, texSrc.getRaw());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
// バインド解除
glBindTexture(GL_TEXTURE_2D, 0);
// 画面表示
render(textureId, winWidth, winHeight);
// カラーバッファを入れ替える
glfwSwapBuffers(window);
// イベント処理
glfwPollEvents();
}
render() の実装は一応こんな感じ.
void render(GLuint textureId, int width, int height)
{
static GLfloat vtx[] = {
0, height,
width, height,
width, 0,
0, 0,
};
glVertexPointer(2, GL_FLOAT, 0, vtx);
static GLfloat texuv[] = {
0.0f, 1.0f,
1.0f, 1.0f,
1.0f, 0.0f,
0.0f, 0.0f,
};
glTexCoordPointer(2, GL_FLOAT, 0, texuv);
// テクスチャの描画
glEnable(GL_TEXTURE_2D);
glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_TEXTURE_COORD_ARRAY);
glBindTexture(GL_TEXTURE_2D, textureId);
glDrawArrays(GL_TRIANGLE_FAN, 0, 4);
glBindTexture(GL_TEXTURE_2D, 0);
glDisableClientState(GL_TEXTURE_COORD_ARRAY);
glDisableClientState(GL_VERTEX_ARRAY);
glDisable(GL_TEXTURE_2D);
}
他のアプリでも背景消す
これでめでたく背景消しができるように・・・ アプリで加工したところでテレビ会議に使えないじゃんダメじゃん!
ここに spout っていうツールがあります.OpenGL のテクスチャをプロセス間共有できるらしい.ライセンスは BSD.で,テクスチャの共有先として仮想カメラ (spoutcam) が同梱されてます.
・・・さっき作ったアプリ,たまたま偶然 OpenGL で画面表示してたんで,↓こんなデータフローにしたら他のテレビ会議アプリに画面流せるんじゃね?

さっそくspout をインストールします.そして,さきほどの Visual Studio プロジェクトに,Spout SDK に入ってる spout.h と spout.cpp を全部ドラッグ&ドロップで雑にぶっこみます.
カメラの初期化の後あたりでspoutのオブジェクトを作って,
// SPOUTのオブジェクトを作る char senderName[256] = "OBY_CAM_OUT"; SpoutSender mySender; mySender.CreateSender(senderName, texWidth, texHeight);
キャプチャのループの中,テクスチャ作った後あたりで,SpoutCam にテクスチャ送ります.
// テクスチャをSpoutに送る
mySender.SendTexture(textureId, GL_TEXTURE_2D, texWidth, texHeight);
このプログラム実行した状態で,テレビ会議の Zoom 立ち上げるとあら不思議,Zoom でも Kinect で加工した映像が扱えるようになってます.

おわかりいただけただろうか?
最初の映像と違いがわかりづらいですが左上のZoomのアイコンとか下に並んだ操作バーとか見ると,間違いなくZoomです.
Zoomにもビルトインの背景消し機能があったのでは?
はい,ありますね.
撮影場所の背後に緑色の布などを貼っておくことで,緑色部分を別画像に差し替えられる機能があります.いわゆるクロマキーですが,背後のモノを隠したいのにまず背後に緑の布を貼れというのは本末転倒です.
最近のバージョンのZoomクライアントで,かつCPUとGPUが一定以上の性能なら,緑色背景なしで,画像処理で人物の背景を隠す機能を使える場合があります(Smart Virtual Background Package) 1.ただし,
- 後ろを別の人が通ると誤認識する.
- 部屋の明るさや服装と背景のコントラストによっては誤認識する.
- 手持ちの物体をカメラに写したくても人物でないので消えてしまう.
- 人物が大きく動いた場合や速く動いた場合に誤認識または人物をロストする.
- 背後に人物の写真やテレビなどがある場合や、体の一部しか写っていない場合に誤認識する.
- 誤認識が発生した場合,背景が盛大に写ってしまう
特に最後の事故は,部屋に貼ってるポスターの趣味によっては新たなタイプのハラスメント(見られるほうも,見せられるほうも)が生まれる可能性を感じざるを得ません.絶対に許されない.
実際にデプスカメラと人体認識でどういう違いが発生するか見てみましょう.

Zoom のビデオ設定画面です.左が Kinect DK で背景を消したもの,右が Zoom の ”Smart Virtual Background Pakcage” をインストールして背景を消したものです. 左右どちらも同じように本を持っているのですが,Zoom の人体検知では本が手ごと消えてしまってます. 肩が本にオクルージョンされありえない形にカッティングされててちょっとグロいです.
もうひとつ例をお見せします.

脇と本の間に,現実の背景が写ってしまってます.プライバシー機能としてはこれは良くない!っていうか部屋の中のきれいなところが脇の下に来るようにがんばりました.
まとめ
Azure Kinect DK の深度データとRGBデータを組み合わせた処理は意外と簡単に書けることがわかっていただけたかと.また,普通のWebカメラにのみ対応したアプリ2に,赤外線カメラや深度カメラ等の特殊カメラを接続したり,カメラですらない映像を入力したりするのも(Spoutを使えば)簡単です.
たとえば前回 の記事で書いた,リアルタイムモーションキャプチャを使って,Zoom でどうぶつアバター動かして「絶対にギスらないほのぼのテレビ会議」とか実現できるんじゃないか,等,夢がひろがります.
結局言いたいことは 『まだ Zoom の機能で背景消してるの? デプスカメラ買おうぜ はよ!』です.
いまやデプスカメラは在宅勤務の必需品です.マスクのように店頭から飛ぶように消え去っていくのは確定的にあきらかです.みなさんもいますぐデプスカメラ買いましょう.お部屋から通販で. そして面白い応用を思いついたらぜひ教えてください.
