機械学習基礎理論独習

誤りがあればご指摘いただけると幸いです。数式が整うまで少し時間かかります。リンクフリーです。

勉強ログです。リンクフリーです
目次へ戻る

【Blender】Tension Map

Tension Map とは

Tension Map のちゃんとした定義はないようですが、Copilot に定義させてみると「オブジェクトの変形前後の状態を比較し、頂点ごとの伸縮量」らしいです。
Diffuse Map, Normal Map などの変化しないものとは異なり、姿勢が変わるごとに動的に作成されることに注意しましょう。
なお、本記事はcgvirusさんがこちらの動画で概要欄から参照できる 4.5 above を解説しました。


使用するモデル

Blender で使用する Mesh と Armature の作成方法を簡単に示します。
Blender のバージョンは 4.5.2 LTS です。

[Mesh]
1. Object Mode で Shift + A で Cylinder を作成
2. G, Z, 1 で Mesh を Z 方向に + 1 平行移動
3. Menu > Object > Set Origin > Origin to 3D Cursor で原点を3Dカーソルに合わせる(3Dカーソルはワールド原点に合わせておいてください)
4. Menu > Object > All Transforms でメッシュに対する変形を適用する
5. Edit Mode で Ctrl + R でループカットを入れて、Number of Cuts を 15 にする

6. Object Mode で Mesh を選択し、右クリックから Shade Auto Smooth を選択する

[Armature]
1. Object Mode で Shift + A で Armature を作成
2. Object Properties > Viewport Display の In Front にチェックを入れ、Display as を Wire に変更して見やすくする
3. Edit Mode で Tail を選択し、G, Z, 3, Enter で Mesh を Z 方向に + 3 平行移動
4. E, Z, 4, Enter で子のボーンを作成

[Armature に Mesh を紐づける]
1. Object Mode で Mesh を選択し、Shift を押しながら Armature を選択する
2. Object > Parent > Armature Deform > With Automatic Weights を実行する

[Armature に Mesh を紐づいたことを確認する]
1. Object Mode で Armature を選択する
2. Pose Mode へ遷移する
3. 子のBone(Bone.001) の Rotation Mode を XYZ Eluer にする
4. Bone.001 の Rotation の Z 値を変更する
すると、ボーンに連動して Mesh が動くことが分かります。

[Animation作成]
1. Timeline で 1 Frame に Bone.001 の Rotaion の Z = 0 を割り当てる
2. 91 Frame に Bone.001 の Rotaion の Z = 90 を割り当てる

このままだとZ角度が Frame に線形に補間されないので、修正します。

3. Graph Editor で 1 Frame と 91 Frame の Interpolation を Linear に変更する。


基本的な計算方法

以下に左に曲がっている、ニュートラル、右に曲がっている 3 つのオブジェクトを用意しました。

縮んでいる箇所を赤色で、伸びている箇所を緑色で囲っています。

さて、どのように伸び縮みを計算で求めればよいでしょうか。

縮んでいる状態、ニュートラルの状態、伸びている状態を特定の頂点に着目してそれぞれ拡大してみてみると、以下のようになっているはずです。

縮んでいる状態では頂点間が近く、伸びている状態では頂点間が離れています。
ですので、特定の頂点に接続している辺の長さの合計または面の面積の合計の変化を見れば良さそうです。
辺の長さと面の面積のどちらを使うかは議論の余地があると思いますが、本記事では計算量の少ない「辺の長さの合計」を採用しようと思います。

数式で書くと頂点 i に接続する辺の長さの平均 l_i は、以下のようになります。

\begin{eqnarray}
 l_i=\dfrac{1}{|{\mathcal N}(i)|}\sum_{j\in{\mathcal N}(i)}||{\bf v}_i-{\bf v}_j||\tag{1}
\end{eqnarray}

(1) において {\bf v}_i は頂点 i の位置ベクトルであり、{\mathcal N}(i) は頂点 {i} と辺で結ばれる頂点の添え字の集合です。
(1) で平均を求める必要は無く合計を求めればよいのですが、あとで Geometry Node を使って計算する際に平均が求まってしまうのであえて平均にしています。
ニュートラル状態と変形後の頂点 i に接続する辺の長さの平均をそれぞれ {l_{\rm N}}_i,{l_{\rm B}}_i とおくと、変化率 T_i は以下のように書けます。

\begin{eqnarray}
 T_i=\dfrac{{l_{\rm N}}_i}{{l_{\rm B}}_i}\tag{2}
\end{eqnarray}

(2) は割り算をしているだけですね。
T_i < 1 の時は、「伸びている状態」で T_i > 1 の時は「縮んでいる状態」であるとことがわかります。またその値の大小で伸び縮みの具合も分かります。

(2) の値に対してもう少し演算を行うのですが、それは Geometry Node のところで解説します。

T 値の取りうる範囲

(2) の値を T 値と呼ぶことにします。
Bone.001 を Z 軸に 0~90度回転させて(左側に曲げて)、どのような値を取るか調べてみましょう。

T 値に変化がありそうな 頂点番号 190, 191, 192, 236, 386, 430, 431, 432 の頂点の T 値を調べてみました。

頂点番号 190 と 192 は同じ値を取るので重なっています。
頂点番号 430 と 432 は同じ値を取るので重なっています。
どうでしょうか、概ね思った通りの値を取っていることが分かります。
75°近辺で 431 が最大でなくなっています。
これは Automatic Weight で Weight を作成すると大きい角度の時に関節の内側がゆがんでしまう現象故です。
もう少し Weight を調整すれば改善すると思われます。
以上より 0~60°で調べると良さそうなので、Beon.001 の角度の可動範囲は 0~60° とします。

ちなみに 1~60°まで1°ずつ調べた結果、T値の最小にするのは 191, 最大にするのは 431 でした。

T 値の値を変換する基本方針

以下で Geomtery Node を組んでいくのですがどういう方針なのか(私の予想ですが)説明します。

0~60° で T 値は [0.729, 1.767] の範囲取ります。
伸びと縮みで境界である1からの距離の大きさが異なり使いにくいです。
また、伸びと縮みで異なる調整をしたい場合も困ります。

そこで T 値の伸び [0.729,1] の 1 が0 になるように [0, a] に変換し
T 値の伸び [0, 1.767] の 1 が 0 になるように [0, b] に変換するようにします。

そして伸びと縮みの2つの値を返すようにします。
こうすると ニュートラルの姿勢が 0 なのでわかりやすいですし、最大でも1なんだなという安心感使いやすさを提供することなります。
a, b ともに 1 に変換出来れば一番いいのですが、モデルやボーンの可動範囲などにより T 値の範囲が異なるためそれは難しいと思われます。
ですので、パラメータを追加して a,b や関数の形状などを滑らかにすることとを目指します。

Tension Map を作成する

では、Tension Map を作成していきましょう。
Object Mode で Mesh を選択し Geometry Nodes タブを押下します。
Modifiers タブから Add Modifier > Geometry Node を選択し、Geometery Node Editor の New ボタンを押下します。
すると、以下のようになるはずです。

{l_{\rm B}}_i すなわち変形する Mesh の頂点 i に接続する辺の長さの合計を求めましょう。

1. Edge Vertices Node から辺の両端点の座標を出力する
2. Distance Node で辺の両端点の距離(辺の長さ)を計算し、出力する
3. Capture Attribute Node で Domain を Edge にして、辺の長さを辺に一次記憶する
これに 頂点で参照すると、点に接続する辺の長さの平均が得られます。
(欲しいのは辺の長さの合計ですが分母が同じ数になるので問題ありません。)

次に {l_{\rm N}}_i すなわちニュートラルの Mesh の頂点 i に接続する辺の長さの合計を求めましょう。
その前にニュートラルの Mesh を作成しておきます。
Mesh と Armature をまとめて選択し、Shift + D でコピーを作成し、X, 4, Enter で X+ 方向に移動します。

Node は以下のように組みます。

1. Object Info で ニュートラルの Mesh を参照します。
2. Capture Attribute Node で辺の距離を辺に辺に記憶します。
3. Sample Index で Domain を Point にして、Index で辺の距離を参照します。
これにより、点に接続する辺の長さの平均が得られます。
4. Divide でニュートラルの Mesh、変形した Mesh の同じ頂点に接続した辺の長さの平均値の比を求めています。
※Transfer Attribute が 3.6 から Sample Index に変わったようです。

さて、とりあえず欲しい値は計算でましたので、Tension Map を目視で確認してみましょう。

Divide Node と Output Node の間に 以下のような値の Map Range Node を作成しましょう。
Output の Value 名を test にしておきます。

Shader Editor で Attribute Node を作成し test と入力し Ctrl + Shift + LMB で Material Output Node につなぎます。

後は Material Preview にして、適当に Pose をとります。

すると、伸びている箇所が黒くなり目視で確認できました。

Divide Node の Value を入れ替えると縮んでいる箇所が黒くなります。

Material を使って Tension Map を確認しましたが Viewer Node で値を参照してもよいかもしれません。

Tension Map の目視による確認ができたら一時的に作成した Map Range Node は削除してください。

Tension Map を仕上げる

このままの Tension Map では使いにくいので、調整用のパラメータを追加したり、Output を変更して仕上げていきます。
先に全 Node をお見せします。

[やりたいこと]
Compress, Stretch の強さを調整できる引数の追加。
Compress, Stretch それぞれの範囲を指定できる引数の追加。
Compress, Stretch の上限の引数を追加。
Reference を引数に追加。
Compress, Stretch を色で出力。
Compress, Stretch を [0,1] で出力。

「やりたいことを実現するために」入力項目、出力項目を以下のようにします。

[入力項目]
・Geometry:Deformed Mesh
・Falloff:Compress, Stretch の強さ
・Stretch Area:Stretch の範囲
・CompressArea:Compress の範囲
・Weight Limit:Compress, Stretch の上限
・Reference:Reference Mesh

[出力項目]
・Geometry:Input Geometry
・Tension Map:R値が Compress, G値が Stretch の色
・Compress Map:[0,1] のCompress の値
・Stretch Map:[0,1] の Steretch の値

まずは、Falloff、Stretch Area、Stretch の値について説明します。

Falloff が Multiply Add Node で-1 倍されて +! されていますが、これは To Min の値を正の値で制御するためだと思います。
Streatch Area が value 1.0 の Smooth Minimum Node に接続されているのは、1.0 以上で入力値を滑らかに補間するためだと思います。Streatch Area をスライダで入力することを想定しているのではないでしょうか?
そして Stretch の値ですが、Map Range で先ほど計算した値  T_i を調整しています。

次に Compress Area と Compress の値に関する説明です。

Compress Area が Maximum Node で 下限を 0.001 にしています。
(これ入力値を制限すればよいだけの気がしますが、まあいいでしょう。)
次に Divide Node で 1 を自身で割って逆数を求めています。これは Map Range の From Min を 1 より大きくするためです。
最後に Smooth Maximum で 1 を下限にして滑らかに補間しています。

Weight Limit と出力項目の説明です。

最初に Stretch の値、Compress の値 を Mix Node でそれぞれ緑色と赤色に変換し、足し合わせます。
次に Mix Color Node で Weight Limit の値を使い、黒と線形補間します。黒は (0,0,0)なので実質重みづけです。
最後にその色を Tension Map として、R値を Compress Map、G値を Stretch Map として出力します。

Tension Map の使い方

Modifier で作成した Geometry Node の戻り値に属性名を付けます。
今回は Tension Map, Compress Map, Stretch Map をそれぞれ tension, compress, stretch と命名します。

後は Shader Editor を以下のように組みます。

この時に Blend される皺の法線マップを Wrinkle Map と呼ぶようです。

すると、縮んだ箇所に皺ができます。

Tension Map を観たい場合は、以下のように Node を組んでください。
縮んでいる箇所が赤色で、伸びている箇所が緑色で表示されます。


他の .blend ファイルで Tension Map を使用する方法

1. File > Append を押下する

2. Geometry Node を作成した .blend をダブルクリックする

3. NodeTree をダブルクリックする

4. 作成した Geometry Node でダブルクリックする

5. Geometry Shader から Shift + A で 追加した Geomerty Node が検索から追加できます。
後は先ほど同様 Geometry Node の戻り値に属性名を付けで、Shader Editor で Material に Tension Map を組み込めば OK です。

T 値を出力する Python コード

T 値 のプロットする時の元となる json ファイルを作成したコードを貼り付けておきます。
Keyframe 1,91をそれぞれZ角度0,90°にして、直線で補間している状態で動かしてください。

import bpy, bmesh, json

def find_mesh_obj_by_name(name):
    for obj in bpy.data.objects:
        if obj.type == 'MESH' and obj.data.name == name:
            return obj
    return None

def get_vert_average_lengths(obj):
    depsgraph = bpy.context.evaluated_depsgraph_get()
    eval_obj = obj.evaluated_get(depsgraph)
    mesh = eval_obj.to_mesh()
    
    # Edge Lengths
    edge_lengths = []

    for edge in mesh.edges:
        v1 = eval_obj.matrix_world @ mesh.vertices[edge.vertices[0]].co
        v2 = eval_obj.matrix_world @ mesh.vertices[edge.vertices[1]].co
        length = (v1 - v2).length
        edge_lengths.append(length)

    # Clean up the evaluated mesh
    eval_obj.to_mesh_clear()
    
    # Compute the average length of edges connected to a vertex
    mesh = obj.data
    bm = bmesh.new()
    bm.from_mesh(mesh)
    bm.verts.ensure_lookup_table()
    bm.edges.ensure_lookup_table()
    
    vert_lengths = []
    for vert in bm.verts:
        length_sum = 0
        for edge in vert.link_edges:
            length_sum += edge_lengths[edge.index]
        length_ave = length_sum / float(len(vert.link_edges))
        vert_lengths.append(length_ave)
    
    bm.free()
    return vert_lengths

def write_json(path, data):
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=4)

def main_proc(def_name, ref_name, vert_indices, max_frames, path):

    if bpy.context.scene.frame_current != 0:
        print("not 0 frame")
        return

    # Find mesh
    def_mesh_obj = find_mesh_obj_by_name(def_name)
    ref_mesh_obj = find_mesh_obj_by_name(ref_name)

    # Compute the average length of edges connected to a vertex    
    ref_vert_lengths = get_vert_average_lengths(ref_mesh_obj)

    # Initialize data
    data = {}
    for vert_index in vert_indices:
        data[str(vert_index)] = []
    data["min_index"] = []
    data["max_index"] = []

    def interval_proc():
        nonlocal count
        count += 1
        bpy.context.scene.frame_current += 1
        def_vert_lengths = get_vert_average_lengths(def_mesh_obj)
        # Compute ratios between corresponding elements
        t_values = [r / d for r, d in zip(ref_vert_lengths, def_vert_lengths)]

        # Append to data
        for vert_index in vert_indices:
            data[str(vert_index)].append(t_values[vert_index])

        min_index = t_values.index(min(t_values))
        max_index = t_values.index(max(t_values))
        data["min_index"].append(min_index)
        data["max_index"].append(max_index)
                                 
        if count >= max_frames:
            # Write json
            write_json(path, data)   
            bpy.context.scene.frame_current = 0         
            return None # stop
        else:
            return 0.05 # continue
        
    # Kick timer
    count = 0    
    bpy.app.timers.register(interval_proc, first_interval=0.05)    

main_proc(def_name="Cylinder",
          ref_name="Cylinder.001",
          vert_indices=[190, 191, 192, 236, 386, 430, 431, 432],
          max_frames=91,
          path=r"C:\tmp\test.json")

T 値が出力された .json を表示する Python のコード

Google Colab で動作を確認しています。

import json
from google.colab import drive
import matplotlib.pyplot as plt

# Google Driveをマウント
drive.mount('/content/drive')

# ファイルパスを指定(例:MyDrive直下)
path = '/content/drive/MyDrive/temp/test.json'

# JSONファイルを読み込む
with open(path, 'r', encoding='utf-8') as f:
    data = json.load(f)

# データ
x = [0, 1, 2, 3, 4, 5]
y = [0, 1, 4, 9, 16, 25]
values = list(range(0, 91, 1))

# グラフを描画
for d in data:
    if d == "min_index" or d == "max_index":
        continue
    plt.plot(values, data[d], linestyle='-', label=d)

# 装飾
plt.title("T Value Plot")
plt.xlabel("Angle(Degree)")
plt.ylabel("T Value")
plt.legend()
plt.grid(True)

# 表示
plt.show()

以下のように出力されます。


Tension Map の弱点

Mesh の姿勢が変わるたびに Tension Map を作成しなければならないので、計算量が多い点が Tension Map の弱点です。
Tension Map の作成時間は辺の数に依存するので、ローポリならそんなに影響を受けないとは思います。
以上より、Tension Map はリアルタイムレンダリング(ゲームなどの描画)には向いていないような気がしています。

最後に

今回cgvirusさんが作成した Tension Map を説明しましたが、少し冗長な箇所があるので自分なりにアレンジした Tension Map を作成する記事も近々書こうと思っています。

目次へ戻る