AIによる背景判定処理の実験 後編 Meta Quest編

# はじめに
前編ではPythonとPyTorchを使って背景判定の実験をしましたが 後編では同じCNNモデルをMeta Quest3で実行しアマレコVRの背景透過処理と連携します。

https://qiita.com/amaman/items/5af159135983568ea2ea

# どうやってMeta Quest3で実行するか

## Meta Quest3ではPython、PyTorchが使えない？
おそらくMeta Quest用のPython等は無いと思うのでCNNモデルの実行はCompute Shaderを使って実装することにしました。
具体的にはコンボリューション、プーリング、全結合の3つを実装します。

:::note info
方針
- CNNモデルの設計や学習、テストまではPython、PyTorchを使ってPCで行う
- Meta Quest3ではPCで保存したCNNモデルの学習データ（パラメータ）を読み込んでCompute Shaderで実行する
- コンボリューション、プーリング、全結合をCompute Shaderで実装する
:::





## Meta Quest3の処理能力
512x512のfloat32型 1chのデータに対し3x3のコンボリューションを1フレーム期間（72Hzなので アプリの実行が72fpsを下回らない範囲）に何回実行できるか検証したところ**150回から200回程度**でした。
なのでこの範囲でコンボリューション回数が収まるようにCNNモデルを設計します。


## 背景透過処理に加えAIの処理もできるの？
現在のMeta Quest3は72Hzで動作しているので 60fpsの動画を再生した場合 1秒当たり12フレームの「お休み(背景透過処理を行わないフレーム)」があります。
この12フレームを使ってAIの処理を行います。



# 背景透過処理との連携
背景透過処理の背景判定時間※の設定をAIの結果により自動で制御します。
![amarecvr_setting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3624096/e5036d62-d0bc-3675-c743-fe5e38daeab0.png)

具体的にはAIが背景と判断した青いエリアでは判定時間を短くし、前景と判断した赤いエリアでは長くなるように制御します。
これにより 背景は消えやすく、被写体が含まれる前景は消えにくくなります。
特に、（AIがちゃんと前景を判断してくれるのであれば）動きの少ない被写体が背景と誤判定され消えてしまうのを防ぐ効果が期待できます。


:::note info
※背景判定時間
背景透過処理では動画を再生しながらリアルタイムで解析し 一定時間変化が無い部分を背景と判断し透明にします。
通常 3秒から5秒程度に設定しますが、短いと動きの少ない被写体も背景と判断され消えてしまう場合があります（誤判定）。
逆に長くすると誤判定を軽減できる一方、なかなか背景が消えず 使い勝手が悪くなります。

手動の場合の背景判定時間は画像全体で一律となるため どちらかを優先し もう一方をあきらめる形になりますが、AIを使うことでエリア毎にきめ細かく自動で調整することができます。

- 背景時間が短い どんどん背景を消すことができるけど 誤判定が増える
- 背景時間が長い 誤判定を軽減できるけど 背景がなかなか消えない
- AIによる制御 背景はどんどん消えていく 誤判定を軽減できる
:::






# 結果
https://x.com/amarectv/status/1743452767921070385?s=20

Pythonでのテストと同様に、背景を青、前景を赤くしています。
Meta Quest3の実機でしっかり動作しました。

なお、このサンプル動画は被写体が適度に動くので もともと誤判定は起こらずAIの効果はありません。

https://pixabay.com/ja/videos/%E8%B8%8A%E3%82%8A%E5%AD%90-%E3%83%88%E3%83%B3%E3%83%8D%E3%83%AB-%E9%80%9A%E8%B7%AF-%E3%83%80%E3%83%B3%E3%82%B9-84047/


# 実装例
前編で紹介した[CNNモデル](https://qiita.com/amaman/items/5af159135983568ea2ea#cnn%E3%83%A2%E3%83%87%E3%83%AB)の最初のコンボリューション処理


```python
nn.Conv2d( 3, 16, kernel_size=3, padding=(1,1), padding_mode="replicate"), #448
nn.ReLU(),
# 32x32のRGB 3チャンネルの画像を入力して 3x3の畳み込みをして 16チャンネル出力するフィルター
```
の実装例を紹介します。


## Compute Shader部
```glsl:bg_test.compute

#define FULL_SIZE 32

  Texture2D<float4> _RGB; // RGB画像入力用テクスチャ
RWTexture2D<float> _Dst;  // 出力用テクスチャ
StructuredBuffer<float4> _Param; // パラメータ群 (ウエイト値、バイアス値)

// 3チャンネルの画像を入力 nチャンネル出力
#pragma kernel _Conv3x3Image
[numthreads(FULL_SIZE, FULL_SIZE, 1)]
void _Conv3x3Image(int3 id : SV_DispatchThreadID)
{
    // id.xyは32x32内の座標
    // id.zは出力チャンネルの位置
    
    // 32x32の画像のパディング処理 (画像端の処理)
    // はみ出した場合は一番端のデータを使う
    int x0 = max(id.x-1, 0);
    int x1 = id.x;
    int x2 = min(id.x+1, FULL_SIZE-1);
    //
    int y0 = max(id.y-1, 0);
    int y1 = id.y;
    int y2 = min(id.y+1, FULL_SIZE-1);
    
    // 各画素値を-1から+1の範囲に変換
    float3 s0 = _RGB[int2(x0,y0)].rgb*2.0 - 1.0;
    float3 s1 = _RGB[int2(x1,y0)].rgb*2.0 - 1.0;
    float3 s2 = _RGB[int2(x2,y0)].rgb*2.0 - 1.0;
    float3 s3 = _RGB[int2(x0,y1)].rgb*2.0 - 1.0;
    float3 s4 = _RGB[int2(x1,y1)].rgb*2.0 - 1.0;
    float3 s5 = _RGB[int2(x2,y1)].rgb*2.0 - 1.0;
    float3 s6 = _RGB[int2(x0,y2)].rgb*2.0 - 1.0;
    float3 s7 = _RGB[int2(x1,y2)].rgb*2.0 - 1.0;
    float3 s8 = _RGB[int2(x2,y2)].rgb*2.0 - 1.0;
    
    // 3x3コンボリューション処理
    int k = id.z*16; // 出力チャンネルに該当するパラメータの先頭を算出
    float3 d0 =
        s0*_Param[k+0].rgb + s1*_Param[k+1].rgb + s2*_Param[k+2].rgb +
        s3*_Param[k+3].rgb + s4*_Param[k+4].rgb + s5*_Param[k+5].rgb +
        s6*_Param[k+6].rgb + s7*_Param[k+7].rgb + s8*_Param[k+8].rgb ;
    
    // 各入力チャンネルの値とバイアス値(_Param[k+9].r)を加算
    float dst = d0.r + d0.g + d0.b + _Param[k+9].r;
    
    // 出力チャンネルにあわせてY座標を加算
    // 出力するテクスチャは 32x32のデータを出力チャンネル数分 縦に並べた 縦長のテクスチャ
    id.y = id.y + id.z*FULL_SIZE;
    _Dst[id.xy] = max(dst, 0.0); // 0以上の値になるよう活性化 ReLU()相当
}
```

## C#部
```csharp:bg_test.cs
using CComputeShader;
using System.Runtime.InteropServices;
using UnityEngine;

// 32x32のRGB画像のテクスチャに対し3x3のコンボリューション処理を実行します。
// 入力テクスチャはRGB画像の3chで固定です。
// 出力テクスチャのチャンネル数はスレッドグループのz値で指定します。
// cs.Dispatch(kernel_index, 1, 1, ch_output);

public class bg_test : MonoBehaviour
{
    private ComputeShader cs;
    private int kernel_index;
    private RenderTexture rgb;
    private RenderTexture dst;
    private ComputeBuffer param;
    
    const int w  = 32; // 画像の横サイズ
    const int h  = 32; // 画像の縦サイズ
    const int ch = 16; // 出力チャンネル数
    
    void Start()
    {
        // bg_test.computeを読み込む
        cs = (ComputeShader)Resources.Load("Shaders/bg_test");
        kernel_index = cs.FindKernel("_Conv3x3Image");
        
        // 入力テクスチャ 32x32のRGB(32bit)の画像
        rgb = new RenderTexture(w, h, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear);
        rgb.enableRandomWrite = true;
        
        // 出力テクスチャ 32x32のfloat32型データを出力チャンネル数分 縦に並べた 縦長のテクスチャ
        dst = new RenderTexture(w, h * ch, 0, RenderTextureFormat.RFloat, RenderTextureReadWrite.Linear);
        dst.enableRandomWrite = true;
        
        // パラメータの準備
        int n = ch * 16; // 1チャンネルにつき 16x4=64個分のメモリを確保します。
        param = new ComputeBuffer(n, Marshal.SizeOf(typeof(Vector4)));
        Vector4[] v = new Vector4[n];
        // vへ学習したパラメータを読み込む
        // パラメータの読み込みは学習結果を保存したpthファイルから読み込むか、
        // PythonのCNNモデルのクラス内でself.feature[0].weight、self.feature[0].bias等で
        // 学習後のパラメータを取得して独自形式でファイルへ保存しQuestで読み込みます。
        
        // 例 すべてのパラメータを1に設定します
        for (int i = 0; i < ch; i++)
        {
            int k = i * 16; // 出力チャンネルに該当するパラメータの先頭を算出
            float r_weight=1, g_weight=1, b_weight=1, bias=1, not_use=0;
            v[k + 0] = new Vector4(r_weight, g_weight, b_weight, not_use); // ウエイト値 左上
            v[k + 1] = new Vector4(r_weight, g_weight, b_weight, not_use); // ウエイト値 上
            v[k + 2] = new Vector4(r_weight, g_weight, b_weight, not_use); // ウエイト値 右上
            v[k + 3] = new Vector4(r_weight, g_weight, b_weight, not_use); // ウエイト値 左
            v[k + 4] = new Vector4(r_weight, g_weight, b_weight, not_use); // ウエイト値 中央
            v[k + 5] = new Vector4(r_weight, g_weight, b_weight, not_use); // ウエイト値 右
            v[k + 6] = new Vector4(r_weight, g_weight, b_weight, not_use); // ウエイト値 左下
            v[k + 7] = new Vector4(r_weight, g_weight, b_weight, not_use); // ウエイト値 下
            v[k + 8] = new Vector4(r_weight, g_weight, b_weight, not_use); // ウエイト値 右下
            v[k + 9] = new Vector4(bias, not_use, not_use, not_use); // バイアス値
            //v[10] - v[15] : 未使用
        }
        param.SetData(v);
    }
    
    private void Update()
    {
        // 入出力テクスチャとパラメータを指定して コンボリューションを実行
        cs.SetTexture(kernel_index, "_RGB", rgb);
        cs.SetTexture(kernel_index, "_Dst", dst);
        cs.SetBuffer (kernel_index, "_Param", param);
        cs.Dispatch (kernel_index, 1, 1, ch); // 出力チャンネル数はスレッドグループのz値で指定します。
    }
    
    private void OnDestroy()
    {
        // パラメータ用のメモリを解放
        param?.Release();
    }
}
```








# 課題：学習データをどうする？
動画から画像を作り学習するのですが 学習に使った動画であれば期待する効果が得られる一方、学習していない未知の動画の効果は低くなります。
なので沢山の動画を学習することになりますが 一つの動画を学習する準備（手作業）に1時間程度かかり 結構大変です。
さらに私が作った学習データは私が観る動画でなければ効果が得られないかもしれません。
最悪、利用者自身がよく観る動画を使って学習作業をする必要があるかもしれないとしたら現実的な話ではないかもしれない。

:::note info
課題
- 手作業による学習の準備作業が大変
- 学習にも時間がかかる
- 利用者自身が学習する必要があるかもしれない
- 学習用のツールを提供しないといけない
- ツールの説明をしないといけない
- そこまでして使う人がいるだろうか
:::



:::note info
学習プロセス
1. 動画からスクリーンショットを作る（専用ツールを使う）
1. スクリーンショットから 背景画像と前景画像を作る（手作業、非常に面倒）
1. 学習用の画像を作る（専用ツールを使う）
1. 学習（専用ツールを使う、作業は簡単だけど時間がかかる）
1. 確認用の動画を作って 学習成果を評価（専用ツールを使う）
:::


# 最後に
極めて小さいCNNモデルではありますが現在のMeta Quest3でも実行でき、一定の効果が得られることが分かったので 今回の実験は私にとって大きな転換期になるものととらえています。
Meta Quest3の間は補助的な使い方に留まると思われますが、その後の背景透過処理は間違いなくAI中心になっていくでしょう。

2018年のVRアプリの開発、2021年のCompute Shader、に次ぐ今までできなかったことや 考えもしなかったことが具現化できる 夢中になれるテーマなので2024年はAIにどっぷりはまる年になるのかなと思っています。