読者です 読者をやめる 読者になる 読者になる

自習室

こもります

Three.jsのオフスクリーンレンダリングとping-pongで、リアルタイム動体除去を行う

はじめに

f:id:AMANE:20150420184058p:plain

前回に続き、ブラウザ内だけで画像処理的なことをしよう、というチャレンジです。今回は動体除去というか、モーションブラーというか、画像の時間平均処理をしてみます。

上の写真は、割と短時間の平均画像です。動いているものが透けているのがわかると思います。画像の平均処理をもっと長時間で行うと、ほぼ動体が見えなくなります。アルゴリズムはあとから説明します。

OpenCVC++で使うと、普通にMatとかで前回のフレームを保存しておいたり差分を取ったりとお手軽に出来ますが、おなじことをJavaScriptでやろうとすると、必要な枚数画像を展開して、ピクセルを全走査するなど、かなりヘビーな感じになります。今回はブラウザ内で完結させてみるのが目的なので、WebGLのシェーダを使ってみます。

完成品

完成品はこちらで確認出来ます。ページを開いたら、カメラ利用に許可を与えて下さい。上部のバーで除去具合を調整出来ます。ゼロにするとリアルタイムの動画をそのまま出しますが、大きくしていくと、だんだん動体が薄くなっていくのがわかると思います。

動作確認は以下の環境で行っています。

動体除去のアルゴリズム

FIRフィルタによる時間平均処理

動画像を時間にわたって足し合わせて平均を取ると、動かないものは残り、動くものは薄ーくなります。これが基本。FIRフィルタ的な感じ。動画像のローパスフィルタですね。

f:id:AMANE:20150420154518p:plain

式で書くとこんな感じ。 X(n) がカメラから得られる最新フレームの画像で、 X(n-1) が一個前のカメラフレーム。 Y(n) が今回表示される絵。

 {
Y(n) = a_{n}X(n) + a_{n-1}X(n-1) + a_{n-2}X(n-2)+\ldots
}

 {
\sum_{k=0}^{N}a_{n-k} = 1.0
}

係数の合計が1になる様にします。1より小さいと全体の画像がグレイがかって薄くなり、1より大きいと、白飛びした画像になります。

この手法でちゃんと動体を消すにはタップ(データを記憶して、演算をする)を多くしなければなりません。ブラウザに大量のframebuffer objectを確保しなければならなくなるので、リーズナブルではありません。

IIRフィルタ(的)な時間平均処理

上記のような問題は、画像処理だけでは無く電気回路におけるノイズ取りなどでも似たようなことがあるようで、解決法としてIIRという手法があります。

大量のタップを用意する代わりに、過去のフィルタ結果をその後も使い回し続けることで、バッファとしてはひとつか少ない数しか用意しないけど、常に平均画像を求め続けているような計算になります。

最もシンプルにバッファ一個だけでやる場合、式で書くとこんな感じ。 X(n) がカメラから得られる最新フレームの画像で、Y(n)が、今回表示される絵。Y(n-1) が、前回表示された絵。

 {
Y(n) = a * X(n) + (1.0 - a) * Y(n-1)
}

Y(n-1) ももちろん上と同じ式で一回前のカメラフレームが係数をかけて足されて出来た画像でY(n-2)も…と続くので、過去のフレームが秘伝のたれのように残りながら足され続け、平均が求められている、という感じです。一回前の描画内容を覚えておくだけで良いので、コストが低いのがポイントです。

実装

上記IIRフィルタ的処理をウェブカムに対してやってみます。

手順としては以下の様な感じになります

  • 新しいカメラ入力と前回の表示内容をテクスチャにしたもの★をブレンドする
  • ブレンド結果をオフスクリーンレンダリングして、テクスチャ★化しておく
  • ブレンド結果を画面にレンダリングする

ここで、★がついている二つの画像が、メモリ上で同一のものである、と言うことが味噌になります。

Three.js でウェブカム画像を利用する

前記事をご参照下さい -> WebRTCの動画にThree.jsのポストプロセスでエフェクトをかける - 自習室

オフスクリーンレンダリング

THREE.WebGLRenderTarget に対してシーンを書き込めば、その画像をテクスチャとして利用する事が出来ます。オフスクリーンレンダリングについては、下記記事様が勉強になります

今回の例では、ブレンドした結果を WebGLRenderTarget に書き込んでいます。

var rt1 = new THREE.WebGLRenderTarget( vidWidth, vidHeight, { minFilter: THREE.LinearFilter, magFilter: THREE.NearestFilter, format: THREE.RGBFormat }  );

// 中略
// ここで、最新のカメラ画像とまえのフレームの描画結果をブレンドした新しいテクスチャを作り、
// それをプレーンに貼って、シーン内に配置する

renderer.render(scene, camera, rt1); // 別途用意した、rt1というレンダーターゲット(フレームバッファを含む) に書き出す
renderer.render(scene, camera);      // 「標準フレームバッファ」つまり画面に書き出される。

ブレンド

Three.js リポジトリexamples/js/shaders に、多数のシェーダのサンプルが含まれています。今回はこの中から、 BlendShader.js を利用します。

videoMaterial = new THREE.ShaderMaterial(THREE.BlendShader);
videoMaterial.uniforms['tDiffuse1'].value = videoTexture; // カメラ画像で毎フレーム更新されるTextureオブジェクト
videoMaterial.uniforms['tDiffuse2'].value = null;         // ブレンドされるもの。初めは無し
videoMaterial.uniforms['mixRatio'].value = 0.0;           // y = x1 * (1-mixRatio) + x2 * mixRatio となります

var planeGeometry = new THREE.PlaneBufferGeometry( vidWidth, vidHeight, 1, 1 );
var plane = new THREE.Mesh( planeGeometry, videoMaterial );  // ブレンドした結果の絵をテクスチャとしてプレーンに描き込む
plane.position.z = 0;

scene.add(plane);  // シーン内に配置する

ちなみに、Three.js における ShaderMaterialの扱いかたについては、下記サイトが勉強になります。

起動時は、ブレンドする2枚目のテクスチャがnullになっていますが、mixRatioも0にしてあるので、1枚目のvideoTextureがそのまま出ることになります。2枚目のテクスチャに適切に画像を当てて、mixRatioを動かすと、ブレンドされます。

FBO ping-pong

IIR的に画像の平均を求め続けるには、前回の描画結果に今回のカメラ入力をブレンドして出力する必要があるのですが、WebGLでは、Aというテクスチャを使ってシーンを作ったあと、Aにrenderする、と言うのは出来ません。メモリに書き込むにつれて絵が変わる、なんてことが起きてしまうから、そりゃそうだ、という感じなのですが。

WebGLRenderTargetを2つ用意し、交互に利用する事で、それを回避します。

var rt1, rt2;
var rtSwitch = true;

// 中略

// 交互に利用する2つの RenderTarget を用意する。
rt1 = new THREE.WebGLRenderTarget( vidWidth, vidHeight, { minFilter: THREE.LinearFilter, magFilter: THREE.NearestFilter, format: THREE.RGBFormat }  );
rt2 = new THREE.WebGLRenderTarget( vidWidth, vidHeight, { minFilter: THREE.LinearFilter, magFilter: THREE.NearestFilter, format: THREE.RGBFormat }  );

毎フレーム、BlendShaderで利用するテクスチャと、オフスクリーンレンダリング対象のレンダーターゲットを切り替えていきます。

if(rtSwitch) renderer.render(scene, camera, rt2); // (フラグonの時)rt2に書き込む
else renderer.render(scene, camera, rt1);         // (フラグoffの時)rt1に書き込む
renderer.render(scene, camera);                   // 同じ内容を画面にも書き出す

if(rtSwitch) videoMaterial.uniforms['tDiffuse2'].value = rt2; // (フラグonの時)次のターンでは、rt2の内容をブレンドに使う
else videoMaterial.uniforms['tDiffuse2'].value = rt1;         // (フラグoffの時)次のターンでは、rt1の内容をブレンドに使う

rtSwitch = !rtSwitch;  // フラグを切り替える

ちなみに、これをミスって、シーン内に使っているテクスチャに書き込もうとすると、以下の様に怒られます

[.WebGLRenderingContext-07E445A0]GL ERROR :GL_INVALID_OPERATION : glDrawElements: Source and destination textures of the draw are the same.

さいごに

改めて、完成品はこちらです

今回の手法だと、背景に対してコントラストが高い動体は、焼きついたように長時間残ることもあります。たとえば下のような例。ラズパイのマークが、白い壁に染みついちゃってます(笑)

f:id:AMANE:20150420184533p:plain

今回の手法は、厳密に背景と動体を分離して消す、というよりは、「動体をうっすらとぼかすことでプライバシーを守りつつ、背景はほぼリアルタイムのものを撮影する」といった用途にむいていると思います。

焼き付きを抑えるには、「動体がない」瞬間を検知して、テクスチャを全部書き換えちゃう、とかすると良いかと思います。

改善案募集

もちっと綺麗に動体を消せる技術ご存じの方、是非とも教えていただきたいです!

(追記) 改善しました

焼き付きのようになってしまう問題を改善しました。追加で投稿していますので、ご参照下さい。

izmiz.hateblo.jp

GPGPU的な用法

FBOのping-pongは、こういった画像の保存という単純な用途以外にも、さまざまな可能性があります。たとえば、大量の点群の

  • 速度
  • 座標
  • 質量

などの「色」ではない情報をテクスチャ風の配列群に仕立ててシェーダに渡し、fragment shaderでガッと計算をした後に、それらを再度オフスクリーンレンダリングという形でテクスチャ的データに書き出して、次のフレームでも利用する、という用法があります。Three.jsの公式サンプルにも作例があります

シェーダでの計算結果を次のフレームで活用するために、FBO ping-pong が使われています。かなり複雑ですが、GPUの計算リソースを活用出来るので、ブラウザでも上記サンプルのようになにやらすごいことが出来ます