📝 この記事は forge.workstyle.tech に掲載した記事の転載です。
背中で手を組んで静止しているはずのキャラクターが、右手を前へ17cm、後ろ上へ15cm、また前へ17cmと動かしました。腕のめり込みを直す処理が、腕を跳ねさせていたのです。
前腕を後ろへ逃がしたい深さは74mm、手を前へ逃がしたい深さは73mm。フレームごとに深い側を選ぶと、1mmの揺れで方向が入れ替わります。そこで、各フレームの候補からクリップ全体の経路を動的計画法で選ぶ構成にしました。
第3回は足を床に合わせました。今回は、体格の違いで体にめり込む腕を扱います。押し出す方向を安定させ、両手を組む姿勢をできるだけ残すまでの話です。
固定カメラの実写動画からVRMアニメーションを作る連載の第4回です。
前回は第3回「脚を1%縮めたら膝が16°曲がった ── VRMの接地補正で踏んだ罠」でした。連載の各回へのリンクは末尾にまとめます。
押し出す処理の形
前提を先に決めておきます。リターゲット(演者の動きを別の体格のキャラクターへ写す処理)した腕は、体を突き抜けることがあります。キャラクターのほうが手足が太かったり、胴が厚かったりするからです。
左は推定をそのまま写した状態で、手が腰の中に入っています。右が補正後です。この記事で扱うのは、「押し出すかどうか」ではなく「どちらへ押し出すか」の決め方です。
そこで、上腕・前腕・手の骨に沿ってサンプル点を置き、毎フレーム「体の表面から、その部位の半径+6mm だけ外に出す移動量」を求めます。前腕と手のサンプルのうち最大の押しが手首を動かし、前腕と上腕のサンプルのうち最大の押しが肘の極を動かし、そのあと肘を2ボーンIKで解き直します(骨の長さは保つ)。動いた前腕が別の部位に当たることがあるので、この試行は数回繰り返します。
この形自体はうまく動きます。問題はどちらに押し出すかでした。
前と後ろでコイン投げになる
あるダンス素材の冒頭120フレーム、演者は背中で手を組んで立っています。推定ではその手が腰に 5cm めり込んでいます。静止しているのに、結果はこうなりました。
右手:前に17cm → 後ろ上に15cm → また前に17cm → ...
フレームごとにパタパタ入れ替わる。
原因は「一番深いサンプルの方向に逃がす」という決め方です。腕を下ろした姿勢では、
- 前腕(腰の側面にめり込んでいる)は後ろ上に押し出したい:深さ 74mm
- 手(太ももにめり込んでいる)は前に押し出したい:深さ 73mm
深さがほぼ同じです。1mm の揺れでどちらが勝つかが変わる。フレームごとに独立に決めている限り、これはコイン投げです。
対処1:候補を持って、クリップ全体で経路を選ぶ
各フレームに候補解を最大3つ持たせて(KEEP = 3)、その候補の組み合わせの中で、クリップ全体のコストが最小になる経路を動的計画法で選びます。選ばれるのはあくまで生成した候補の中での最適であり、取りうる腕の姿勢すべての中での最適ではありません。
候補の作り方は3種類。
- 推定の腕から押し出した解
- 隣のフレームの解から始めて押し出した解(前向きと後ろ向きに1回ずつ掃く。持ち込む量は0.8倍に減衰させるので、体がもう要求していないオフセットは消えていく)
- 画像が言う側に、体を通り越した位置から始めた解(後述)
コストは以下です。距離はすべてメートルで入れています。
- 単項:手首と肘の移動量の二乗 + 10×(まだ体の中にいる深さ)+ 側の好み
- 遷移:2.0 ×(前フレームからの答えの変化量の二乗)
# pipeline/clearance/modes.py:42-45
def _unary(c):
dW, dK, inside = c[:3]
side = c[3] if len(c) > 3 else 0.0
return float(dW @ dW + dK @ dK) + INSIDE * inside + side
# pipeline/clearance/modes.py:118-125
for i in range(1, n):
prev, cur = cands[i - 1], cands[i]
step = np.array([[turn * float(np.sum((a[0] - b[0]) ** 2) + np.sum((a[1] - b[1]) ** 2))
for b in prev] for a in cur])
total = cost[-1][None, :] + step
j = np.argmin(total, axis=1)
cost.append(total[np.arange(len(cur)), j] + np.array([_unary(c) for c in cur]))
back.append(j)
残っためり込みには距離あたり10のペナルティを与え、移動量だけを小さくする解より、体から出る解を優先します。ただし有限のペナルティなので、非貫通を保証するものではありません。実際、後述の表のとおり補正後もめり込むフレームは残ります。距離をメートルで入れている以上、この 10 や遷移の 2.0 という係数もその尺度に依存します。別の単位系へ移すときは、まずここを読み替えてください。
対処2:差が小さいときは、カメラ側を弱く優先する
動的計画法だけだと「移動量が小さいほう」が選ばれます。この姿勢では、前に逃がすほうがわずかに移動量が少ない。つまり手が体の前に出ます。映像では背中で組んでいるので、間違いです。
そこで、差が小さいときの決め手として手の検出の有無を使います。手が検出された場合はカメラ側へ、検出されなかった場合はカメラから遠い側へ逃がす候補を、弱く優先します。
ここは言葉を分けておく必要があります。これはカメラ側かどうかの話であって、解剖学的な体の前側かどうかの話ではありません。背面から撮れば、背中側の手のほうがよく見えます。この規則は、この素材での遮蔽を手掛かりにしているだけで、手の前後関係を確定する判定ではありません。
# pipeline/clearance/modes.py:48-54(docstring 省略)
def _side(dW, toward, seen):
if toward is None:
return 0.0
a = float(dW @ toward)
return SIDE * (seen * max(0.0, -a) + (1.0 - seen) * max(0.0, a))
重み SIDE = 0.3(メートルあたり)は弱めです。手の検出はモーションブラーや手が小さいときにも落ちるので、重みを小さくし、他のコストが近いときに選択へ影響しやすくしてあります。
もう1つ必要だったのが、その側の候補をそもそも作ることでした。局所的な押し出しだけでは、体の反対側へ抜ける解は永遠に見つかりません。なので「画像が言う側に、めり込み深さ+5cm だけ通り越した位置」から始めた候補を1つ足します。
# pipeline/clearance/modes.py:86-92(docstring 省略)
def _seed(i):
if toward is None or depth0[i] <= 0.0:
return []
d = toward[i] * (1.0 if seen[i] >= 0.5 else -1.0) * (depth0[i] + SEED)
return [_run(H[i], K[i], W[i], T[i], field, radii, margin, i, (d, d), *cam(i))[0]]
結果:手は背中側で安定し、切り替わりも減った
冒頭120フレームでは、両手が前後を往復せず、腰の後ろ10cmで安定しました。
残りの集計は次のとおりです。評価対象は同じダンス素材1本で、「前」と「後」の列はどちらも干渉処理を通したあとの結果です(動的計画法とカメラ側の優先を入れる前と、入れたあと)。
| 項目 | 候補+動的計画法の前 | 入れたあと |
|---|---|---|
| 前後の切り替わり(10フレーム以内)左 / 右 | 65回 / 29回 | 29回 / 11回 |
| 補正後もめり込むフレーム 左 / 右 | 73 / 148 | 16 / 70 |
めり込みフレーム数は片腕ごとの集計です(run_solve が出す frames_inside_after、深さ 0.1mm 超のフレーム数)。一方、「10フレーム以内の切り替わり」を1件とどう数えたかは実装側の記録に残っていないので、回数そのものより減り方を見てください。
計算時間は、ダンス素材1本(14,373フレーム、両腕)で3分でした。これはNumPyのソルバ単体の時間で、Blenderでの観測と書き戻しは含みません。第1回で挙げたパイプライン全体の所要時間とは、測っている範囲が違います。
押し出し用の体表面から、腕と一緒に動く頂点を除く
ここからは、押し出す相手(体の表面)の作り方です。毎フレーム用意しますが、単純にメッシュ全体を使うわけにはいかず、3段階でふるいにかけます。
- 支配ウェイトが胴体・太もも・頭のボーンである頂点だけを採る(裾が床をかすめても足ではない、と同じ考え方)
- 法線が内側を向いている頂点を落とす。VRoid素体は服の内側の面を持っていて、内向きの法線をそのまま使うと、押し出す側を取り違えます
- 腕・肩のボーンに15%以上追従する頂点を落とす。三角筋の上の皮膚は腕と一緒に半分動くので、体でも腕でもない
そのうえで 2.5cm のグリッドで間引きます。腕の太さも決め打ちではなく、ボーンごとに自分の頂点から測ります(前腕全体を1つの半径にすると、手首が肘と同じ太さになって、置いた手が体から浮きます)。
この押し出し処理では、近傍の体表面を平面で近似し、サンプル点がその裏側にあるかを調べます。実メッシュ全体に対する厳密な内外判定ではありません。第2回では計測の検証にカプセル近似を使わない話をしましたが、こちらは補正を解くための近似で、目的が違います。見た目の最終確認は、あくまでレンダリングで行います。
押し出す方向は16頂点で決める
近似が平面である以上、近傍の取り方が効きます。
最初は最近傍の1頂点で決めていました。すると、丸い体の上を腕が滑るときに最近傍が乗り換わり、押し出す方向が頂点間の角度ぶん飛びます。見た目には「ピクつき」です。
実測はデスク素材での値です。指標は腕(手首)の軌道を1フレームずつ2階差分した大きさで、単位は mm/フレーム²。この段を通したあとのピークが 3 から 9 に戻りました。ただし「ピーク」を最大値で取ったか上位パーセンタイルで取ったかまでは実装側の記録に残っていないので、悪化の向きと桁として読んでください。
直したのは、近傍 16頂点 の距離重み付き平均で平面を作ることです。
# pipeline/clearance/geom.py:136-145
w = np.exp(-((dist - dist.min(1, keepdims=True)) / self.SOFT) ** 2)
w = w / np.maximum(w.sum(1, keepdims=True), 1e-12)
n = np.einsum("sk,skj->sj", w, N[j])
ln = np.linalg.norm(n, axis=1, keepdims=True)
n = np.where(ln > 1e-9, n / np.maximum(ln, 1e-12), N[j][:, 0])
s = np.einsum("sk,skj,sj->s", w, P[:, None, :] - V[j], n)
depth = np.where(near, np.maximum(-s, 0.0), 0.0)
need = np.where(near, np.maximum(np.asarray(clearance, float) - s, 0.0), 0.0)
return n * need[:, None], depth
法線 n は16頂点の法線の重み付き平均で、符号付き距離 s も各頂点自身の平面に対する値を同じ重みで混ぜています。重みを「最近傍からの相対距離」で測っているのは、近傍全部が遠いときに重みがアンダーフローしないようにするためです。
なお、同じ指標・同じ素材で「3 → 9」はもう1回出てきます。キーを打つフレームを間引いたときでした。押し出した腕だけにキーを打つと、書いたフレームと書いていないフレームの境目が全部段差になります(デスク素材では85%のフレームが押し出される)。いまは全フレームに書いています。同じ症状に別の原因が2つある、という例です。
肘も動かさないと、4倍押すことになる
押し出しの割り当てはこうです。
# pipeline/clearance/solve.py:109-117(コメント省略)
pf = _largest(pushes[:nf])
ph = _largest(pushes[nf:nf + nh])
pu = _largest(pushes[nf + nh:])
dW = _largest(np.stack([pf, ph]))
dK = _largest(np.stack([pf, pu]))
前腕のサンプル pf が、手首の移動量 dW と肘の極の移動量 dK の両方に入っているのが要点です。肘を固定して手首だけを動かすと、前腕の肘寄り1/4の点には手首移動の約1/4しか伝わりません。この近似では、その点を押し出すのに手首を約4倍動かす必要があります。肘も一緒に動かせば、前腕は平行移動に近い形で体から出られます。
上腕は先端30%しかテストしません(UPPER_T = (0.7, 1.0))。肩に近い側は「腕であると同時に体」なので、テストすると自分が住んでいる胴体と喧嘩します。
両手を組む姿勢:押し出すと引き剥がされる
もう1つ、両手が関わる問題です。
腰の前で手を組んでいる姿勢で、左右の腕をそれぞれ独立に押し出すと、手が離れます。お腹が丸いからです。へその左の面は左を向き、右の面は右を向く。それぞれ最寄りの面から出ようとすると、左手は左へ、右手は右へ行きます。
実測で、手首の向きを直して 5.3cm まで寄せた両手が、干渉処理を通ると 19.5cm に開きました。直前の仕事を打ち消しています。
「離れる成分だけ共有」は間違いだった
最初の実装は「押し出しベクトルのうち、両手が離れる方向の成分だけを平均する」でした。これだと横方向の押し出しを捨ててしまい、丸いお腹では2509フレームが体の中に残りました。
正解は逆で、方向を共有して、距離は各自が持つことでした。左右の押し出しベクトルを足し合わせ、その方向を共通の逃がし先にします。補正の強さは左右それぞれの元の長さを使い、結合の強さ amount に応じて元のベクトルと混ぜます。丸い体から出る自然な方向は「まっすぐ外」であり、それは2つの押し出しの平均そのものです。
# pipeline/clearance/couple.py:40-51
dL = np.asarray(push_left, float)
dR = np.asarray(push_right, float)
w = np.asarray(amount, float)[:, None]
u = dL + dR
n = np.linalg.norm(u, axis=1, keepdims=True)
u = np.divide(u, np.maximum(n, _EPS), where=n > _EPS)
u[(n <= _EPS).ravel()] = 0.0
out = []
for d in (dL, dR):
m = np.linalg.norm(d, axis=1, keepdims=True)
out.append(d + w * (u * m - d))
return out[0], out[1]
そして、寄せられる量はフレームごとに探索します。今回の補正方法では、両手を寄せたまま体から出せないフレームがありました。丸いお腹の正中線ではとくにそうです。その場合は結合を弱め、体から出すことを優先します。体が勝ちます。
# pipeline/clearance/run_solve.py:49(コメント省略)
amt = couple.ease(_allowed(field, PL, PR, got, meta["radii"], margin, amt))
_allowed が「そのフレームがどこまで結合を受け入れても体から出ていられるか」を探索し、couple.ease がその結果を時間方向に均します。
探索した値を、あとから滑らかにしてはいけない
探索の結果は階段状になります。探索で求めた値を普通に平滑化すると、上限として使う許容量を超えたり、下限として使う必要量を下回ったりします。どちらの境界を守る値なのかを分けて扱う必要があります。
そこで、片側にしか動かさない平滑化を使います。
- 上限(許容量):収縮 → ぼかし → 最小値(
ease)。もとの許容量を超えない - 下限(必要量):膨張 → ぼかし → 最大値(
hold)。もとの必要量を下回らない
実際の使い分けも、この向きどおりです。両手をどこまで寄せてよいかという許容量には ease を、指先を逃がすためにどれだけ開く必要があるかという必要量には hold を使っています。
この処理が守るのは、探索で得た上限・下限です。その探索自体が確認していない衝突まで保証するものではありません。この考え方は次回の親指の話でも出てきます(そちらでは3回間違えました)。
指先が相手の手を突き抜ける
最後に、これは「直した」ではなく「折り合いをつけた」話です。
今回の手の推定と補正では、左右の指が組み合う関係を扱っていません。推定は片手ずつ行い、反対の手のことを知らないまま姿勢が決まります。そのため、演者が指を組む区間では、キャラクターの指がまっすぐ相手の手を突き抜けました。
対処は「手首を結ぶ線に沿って、指先が相手の手首を越えなくなるまで左右対称に開く」ことです。正の値は相手の手首を越えることを許し、負の値は手首の手前で止める設定です。どこまで許すかは、3つの値でレンダリングを見比べて決めました。
| 相手の手首を越えてよい指先の量 | 見た目 |
|---|---|
| +2.5cm | 指が組んだ手からはっきり飛び出す |
| 0 | 突き抜けはなくなる |
| −3.5cm | 手の間に隙間ができる ← 採用 |
組めない指は、融合して見えるより離れて見えるほうがマシ、という判断です。忠実さより見た目を取っています。指を組む関係を推定側で扱えるようになれば、この折り合いは不要になります。現在の実装の限界として残しておきます。
まとめ
- 押し出す方向を近傍1頂点で決めると、乗り換わりでピクつく(デスク素材で、腕の軌道の2階差分のピークが 3 → 9 mm/フレーム²)。16頂点の重み付き平均で決める
- 前腕のめり込みは肘も動かさないと、てこ比のぶん余計に押すことになる
- 方向の決定はクリップ全体で、量の決定はフレームごとに
- 深さが拮抗しているときは、画像の情報(手が検出できたか)で弱く優先する。ただしそれは「カメラ側」であって「体の前側」ではない
- 動的計画法が選ぶのは生成した候補の中での最適で、めり込みのペナルティも有限。非貫通は保証されない
- 探索で保証した値は、片側にしか動かさない平滑化で均す
- 作れないもの(組んだ指)は、折り合いの付け方を決めて記録に残す
次回(第5回)は親指です。人差し指から離すだけでは、握りがサムズアップに変わってしまいました。「どこへ載せるか」を目標にし、その補正を平滑化するときの落とし穴を扱います。
連載の各回
- 第1回 実写動画1本からVRMを踊らせる ── 推定後に残る足・腕・指の破綻を直す
- 第2回 頭と胸の角度差が全フレーム0.000° ── モーション計測が壊れていた話
- 第3回 脚を1%縮めたら膝が16°曲がった ── VRMの接地補正で踏んだ罠
- 第4回(本記事)腕のめり込み補正が前後に跳ねる ── 動的計画法で逃がす方向を決める
- 第5回 親指を「どける」とサムズアップになる ── 握りを保つ補正と平滑化
- 第6回 手首の皮膚が潰れる原因は、ポーズではなくスキニングだった
補足
このパイプラインは squall01337/mixamo-llm-mocap(MIT)を土台にしています。引用しているコードのうち pipeline/clearance/ は、そこから先に自分で足した部分です。
この記事で引用しているコードのうち、fork 元から先に足した部分は公開していません。リポジトリを見て追試することはできない前提で読んでください(引用はファイル名と行番号で示しています)。
コードは説明に必要な部分を抜粋しています。省略した初期化や補助関数を含む実装は、各節の参照先を確認してください。行番号は執筆時点のものです。
