ゲームのキャラクターのように、画像生成で描いた人物を動かしたい時があります。動きは 3D の人形で作れます。人形の腕を少しずつ上げた 4 コマを用意し、各コマの深度マップ(depth map)を渡せば、人形どおりの姿勢の絵が 4 枚描けます。深度マップは、カメラからの距離を明るさで表した白黒の画像です。画像生成のモデルにこうした形の画像を渡す仕組みを ControlNet と呼びます。

ところが、描いた 4 枚をパラパラ漫画のように続けて見ると、動かしていない胴や脚までちらつきます。以下は、同じ 4 コマを 4 通りの方法で作り、並べて再生した動画です。

4 列の動画。左から、1 コマずつ描く、弱く描き直す 0.45、並べて 1 枚、1 枚を歪める。1 コマずつ描く列は、腕が上がるたびに剣の形・向きと鎧の飾りが変わる。弱く描き直す列は、絵はほぼ同じで腕が上がらない。並べて 1 枚の列は、剣が無く、腕は上がり、絵は揃っているが塗りが平たい。1 枚を歪める列は、胴と脚がほとんど動かず、腕だけが上がるが、最後のコマで剣が曲がり、元の手の跡が残る

同じ 4 コマの動きを 4 通りの方法で作った動画(1 コマ 0.5 秒ずつ表示し、4 コマ目で 3 秒止める)

左端の「1 コマずつ描く」は、腕が上がるたびに剣の形と向きが変わり、鎧の飾りも入れ替わります。2 列目は弱く描き直したもので、ちらつきは小さい代わりに、腕がほとんど上がりません。3 列目は 4 コマを横に並べて 1 枚で描いたもので、絵は揃いますが、塗りが平たくなりました。右端は 1 枚目の絵を描き直さずに歪めたもので、胴と脚はほとんど動きません。ただし、腕を大きく上げた最後のコマで、剣が曲がり、手があった所に跡が残りました。


本記事では、生成した絵をコマにして動かしたいエンジニアに向けて、ちらつく理由と、それを抑える 3 つの方法の得失を手元の実験で検証します。ちらつきは、人形が動いていない所で、隣り合うコマで色がどれだけ変わったかとして数えます。扱う疑問は次の通りです。

  • 同じ文面(プロンプト)と seed でも、1 コマずつ描くと、なぜ止まっている所までちらつくのか。
  • 1 枚目を元に弱く描き直せば、ちらつかずに動くのか。
  • 4 コマを並べて 1 枚に描けば、揃うのか。
  • 描き直さずに 1 枚を歪めるには、何が必要なのか。どこで壊れるのか。
  • 動きの大きさに応じて、どれを選べば良いのか。

動画を描くために学習したモデル(AnimateDiff など)は扱いません。本記事は、止め絵の生成モデルと 3D の人形だけでコマを作る場合の話です。人形の作り方と、深度マップが体型や服の厚みまで絵に写す話は Depth Body Shape のノートで説明しています。


前提: 描くたびに細部が作り直される

拡散モデルは、ノイズだけの画像から始めて、少しずつノイズを除いて絵にします。seed は最初のノイズを決める数です。seed を固定すれば、文面と条件が同じ限り同じ絵が出ます。

コマを作る時は、条件が少しずつ違います。腕を 10 度上げた深度マップは、前のコマとは別の条件です。条件が変わると、ノイズを除く途中の計算が最初のステップから変わり、その差は最後まで積み重なります。深度マップが縛るのは体の外形だけです。剣の形、鎧の飾り、塗りの明暗のように深度マップにも文面にも書いていない細部は、コマごとにモデルが新しく考えます。

Video Upscale のノートでは、動画を 1 枚ずつ拡散モデルで描き直すと、止まっている所までざわざわと動いて見えることを確認しました。コマを 1 枚ずつ描く時も、同じことが起きます。

筆者がゲームのコマを作った時は、コマごとに絵を描き直して 8 コマを作りました。見た感想は「ブレが気になる。ノイズ感がある」でした。肌の艶や細部が、コマごとにばらばらに変わっていたからです。後の実験 2 で扱う弱い描き直しでも、細部は揺れます。


実験の作り方

人形は、Depth Body Shape のノートで作った標準体型の成人女性です。Blender 4.5 と人体を作るアドオンの MPFB 2.0.18 で作り、服の分だけ体を膨らませています。体を 35 度回した斜め前の立ち姿で、左腕だけを 10 度ずつ上げた 4 コマを書き出しました。

人形の深度マップが 4 枚並ぶ。左腕が少しずつ横へ上がる。右端は 4 コマ目に色を重ねた図で、胴と脚の大半が青(止まっている所)、上がった腕が橙(腕が新しく入る所)。青の中には丸い穴があり、頭と、動く腕に近い肩の周りは青くない

人形の 4 コマと、計測の範囲(青: 止まっている所、橙: 腕が新しく入る所)

動くのは左腕だけなので、胴と脚の大半は 4 コマで同じ位置にあります。右端の図で青く塗った範囲です。ここで絵の色がコマごとに変われば、それは動きではなく、ちらつきです。動く画素から 24 画素以内と、人形の座標で対応が取れなかった画素の周りは、念のため外しています。青い範囲にある丸い穴と、頭や肩の周りが青くないのはそのためです。

ちらつきだけを測ると、何も動かさない方法が一番良く見えてしまいます。そこで、腕が人形どおりに上がったかも数えます。4 コマ目で人形の腕が新しく入った所(橙)のうち、絵が背景ではない画素の割合です。背景は、絵の縁と近い色の画素とします。人形どおりに腕が上がれば 1 に近く、上がらなければ 0 に近くなります。


生成の条件

生成は ComfyUI で、公開されているモデルだけを使いました。

項目値
モデルWAI-illustrious-SDXL v17.0
ControlNetxinsir の union promax(種類は depth)
文面銀の板金鎧・ポニーテールの女騎士(剣は頼んでいない)
大きさ832 x 1216(並べて 1 枚は 1536 x 768)
seed20261112、20261113、20261114

本記事では、文面と seed を全コマで同じにします。seed は、生成の始めに置く乱数のノイズを決める数です。1 コマずつ描く時は、深度マップの強さを 0.7 にし、28 ステップのうち前の 6 割で効かせます。文面の全文と、方法ごとの細かい値は lab/README.txt にあります。


ちらつきの数え方

並べて 1 枚の絵は 1 コマが 384 x 768 と小さいので、どの方法の絵も同じ大きさに縮めてから比べます。カメラが写す縦の範囲と中心は、全て同じです。以下は差を数える関数で、全体は lab/measure.py です。

def flicker(frames, mask):
    """止まっている所(mask)で、隣り合うコマの RGB の差の絶対値を平均する(0〜255)。"""
    m = np.array(mask) > 0
    a = [np.asarray(f, np.float32) for f in frames]
    return [float(np.abs(a[i + 1] - a[i])[m].mean()) for i in range(len(a) - 1)]

本記事では、コマ 1→2、2→3、3→4 の 3 つの差を平均した値をちらつきと呼びます。0 なら止まっている所が完全に同じで、値が大きいほどちらつきます。


実験 1: 1 コマずつ描くと、剣と鎧の飾りが変わる

まず、4 コマの深度マップを 1 枚ずつ渡して描きました。seed ごとに、4 コマとも同じ文面と seed です。下の図は、2 コマ目と 3 コマ目の差を明るさで描いたものです。明るいほど、その画素の色が変わっています。

4 枚の白黒の図。1 コマずつ描くは、鎧の縁・胸当て・脚当て・剣の全体が白く光る。弱く描き直す 0.45 は、輪郭がうっすら光るだけでほぼ黒い。並べて 1 枚は、鎧と脚当ての輪郭と、上がった腕が白い。1 枚を歪めるは、胴と脚がほぼ黒く、腕と剣の周りだけが白い

2 コマ目と 3 コマ目の差(明るいほど差が大きい。4 倍に強調、seed 20261112)

1 コマずつ描いた絵は、動かしていない胴と脚まで、鎧の縁と飾りが光っています。剣の全体も光っています。最初の動画で見た通り、剣はコマごとに形と向きが変わり、seed 20261113 と 20261114 では、1 コマ目に無かった剣が 2 コマ目から現れました。

ちらつきは 3 seed で 17.3〜22.1 でした。腕が付いてきた割合は 0.90〜0.96 で、姿勢は人形どおりです。姿勢は深度マップで揃っても、深度マップに無い細部はコマごとに作り直されます。


実験 2: 弱く描き直すと、腕が上がらない

ちらつきを抑える方法としてまず思い付くのは、実験 1 で描いた 1 コマ目の絵を元に、各コマの深度マップを渡して弱く描き直すことです(img2img)。描き直す強さは denoise で決めます。denoise が小さいほど、元の絵が多く残ります。各コマは元の 1 枚目から描き、前のコマを順につなげることはしていません。描き直しの誤差が積み重ならないようにするためです。

denoise 0.45 では、ちらつきは 2.8〜3.3 まで下がりました。1 コマずつ描いた時の 6 分の 1 ほどです。ところが、腕が付いてきた割合は 0.00〜0.03 でした。腕がほとんど上がっていません。

4 枚の絵。左端は元にした 1 コマ目で、左腕を下ろして剣を持つ。残りの 3 枚は denoise 0.45、0.6、0.75 で描き直した 4 コマ目で、どれも左腕は下ろしたまま剣を持ち、元の 1 コマ目とほぼ同じ姿勢。0.75 では鎧の細部が少し変わる

元の 1 コマ目と、denoise を変えて描き直した 4 コマ目(seed 20261112)

denoise を 0.6、0.75 に上げても、4 コマ目の腕は下ろしたままでした。腕が付いてきた割合は、0.75 でも 0.07〜0.12 です。一方で、ちらつきは 0.6 で 5.8〜7.2、0.75 で 9.3〜13.3 と増えていきます。denoise を上げると、動かない所の細部だけが先に作り直されます。


姿勢は最初の数ステップで決まる

腕が上がらないのは、img2img が最初のステップを飛ばすからです。ComfyUI の img2img は、denoise 0.75 ならノイズを除く列のうち前の約 4 分の 1 を飛ばし、元の絵にノイズを混ぜた所から描き始めます。denoise 0.45 なら、前の半分以上を飛ばします。

横長の帯の図。左がノイズの多い段、右がノイズの少ない段。左端の約 2 割に姿勢が決まる範囲の色が付いている。txt2img は左端から描く。img2img の denoise 0.75 は 24% から、0.45 は 55% から描き、それより左は飛ばす。どちらも姿勢が決まる範囲より後から描き始める

ノイズを除く列のどこから描き始めるか(28 ステップ)

姿勢が決まる範囲は、別の実験から分かっています。ControlNet のノートで行った実験では、28 ステップのうち前の 6 ステップだけ深度マップを効かせても、姿勢は最後まで残りました。本記事の 1 コマずつ描く条件(前の 6 割で効かせる)とは別の設定です。姿勢は最初の数ステップで決まり、その後のステップは細部を描いています。

img2img はその最初の数ステップを飛ばし、元の絵が持つ姿勢を引き継ぎます。深度マップが「腕を上げよ」と指示しても、姿勢を決める段は既に終わっています。denoise 0.75 で腕がわずかに付いてきた(0.07〜0.12)のは、描き始めが姿勢の決まる範囲のすぐ後ろだからと見ています。img2img Denoise のノートでも、立った姿の下絵から座った絵を頼むと、0.8〜0.9 でも立った姿のまま残りました。

もう 1 つ落とし穴があります。元の 1 枚目をそのまま 1 コマ目に使い、描き直した絵を 2 コマ目から続けると、1→2 の差だけが 17.5〜19.6 に跳ねました。描き直すと、denoise 0.45 でも塗りの細部が元の絵と変わるからです。描き直すなら、1 コマ目も同じ条件で描き直して揃えます。


実験 3: 並べて 1 枚に描くと、揃うが平たくなる

1 回で生成する時、モデルは画面全体を同時に描きます。4 コマを横に並べた 1 枚の深度マップを渡せば、4 人が同じ服と髪で描かれるかもしれません。1 コマ 384 x 768 を 4 つ並べた 1536 x 768 の深度マップで描きました。

文面には、複数の姿を並べた絵を頼む語(multiple views)と「同じ人の 4 つの姿、同じ服、同じ髪型」を足しました。1 人だけの絵を頼む語(1girl、solo)と、打ち消しの文面にあった multiple girls と multiple views は外しています。

ちらつきは 11.2〜12.6 で、1 コマずつ描くより小さくなりました。腕が付いてきた割合は 0.89〜0.91 で、腕も人形どおりに上がります。3 seed とも、4 コマで鎧の形と色は揃いました。剣は 3 seed とも描かれませんでした。

ちらつきが 0 に近くならないのは、4 人が完全に同じではないからです。差の図では、鎧の輪郭と脚当ての縁が光っています。並べて描いても、線の位置はコマごとに数画素ずれます。seed 20261113 では、4 コマとも片目を閉じていましたが、4 コマ目だけ両目が細くなりました。


1 人あたりの画素が減る

並べて 1 枚は、1 人に使える画素が少なくなります。1 コマずつ描く絵は 832 x 1216 ですが、並べた 1 枚の 1 コマは 384 x 768 で、人物の縦は 0.63 倍になり、1 人に使える画素は約 4 割です。

2 枚の顔の拡大。左の 1 コマずつ描いた顔は、目に光と細い線があり、髪の束が細かく描かれる。右の並べて 1 枚で描いた顔を同じ大きさに拡大したものは、目が単純で、髪と肌の塗りが平たく、線がぼやけている

同じ範囲の顔を同じ大きさで比べる(seed 20261112)

左は 1 コマずつ描いた顔、右は並べて 1 枚で描いた顔を同じ大きさに拡大したものです。右は目と髪の描き込みが減り、塗りが平たくなっています。筆者がゲームのコマで試した時も、4 枚を並べて描くと線が太く平たくなり、魅力が落ちました。並べた後に 1 コマずつ拡大して弱く描き直すと細部は戻りますが、それは実験 2 の描き直しと同じで、細部が少し揺れます。


1 枚を歪めて動かす

ここまでの 3 つは、どれもコマごとに何かを描いています。描く限り、深度マップに無い細部は揺れます。最後の方法は、描くのをやめ、1 枚目の絵にある画素を人形の動きどおりに動かします。

画素を動かすには、「4 コマ目のこの画素は、1 コマ目のどこにあったか」を全ての画素について知る必要があります。この対応を流れ(flow)と呼びます。普通は 2 枚の絵を見比べて推測しますが、人形があれば正確に計算できます。


人形の表面に座標を描く

人形の表面にある各点に、変形する前の位置を色として描きます。Blender の Texture Coordinate の Generated という値で、x・y・z を赤・緑・青にします。人形の手足は、中に入れた骨(骨組み)を回して曲げています。Generated は骨で曲げる前の形から決まるので、腕を上げても、腕の表面にある点は同じ色のままです。

人形の表面を虹色で塗った絵が 2 枚。左はコマ 1 で腕を下ろし、右はコマ 4 で腕を横へ上げている。手の甲は両方とも同じ桃色で、手の位置に黒い丸が付いている

表面の座標を色にした人形(左: コマ 1、右: コマ 4)。丸は手の同じ点

4 コマ目で手に当たる画素は桃色です。1 コマ目で同じ桃色の画素を探せば、手が 1 コマ目のどこにあったかが分かります。全ての画素についてこれを行うと、流れが決まります。以下は、lab/warp.py から流れを求める関数と歪める関数を抜き出したものです。

def doll_flow(passes, kind, frame):
    """コマ frame の各画素が、1 コマ目のどの画素から来たか(dx, dy)。対応が取れた画素の印も返す。"""
    c0, m0 = load_coords(passes, kind, "f0")
    h, w = m0.shape
    ys0, xs0 = np.nonzero(m0)

    def feats(c, ys, xs):
        return np.column_stack([c[ys, xs, 0], c[ys, xs, 1], c[ys, xs, 2], xs / w * POS_W, ys / h * POS_W])

    ci, mi = load_coords(passes, kind, frame)
    ys, xs = np.nonzero(mi)
    dist, idx = cKDTree(feats(c0, ys0, xs0)).query(feats(ci, ys, xs), k=1)
    dx, dy = xs0[idx] - xs, ys0[idx] - ys
    ok = (dist < MATCH) & (np.hypot(dx, dy) < MAX_SHIFT * max(h, w))
    flow = np.zeros((h, w, 2), np.float32)
    good = np.zeros((h, w), bool)
    flow[ys[ok], xs[ok]] = np.column_stack([dx[ok], dy[ok]])
    good[ys[ok], xs[ok]] = True
    return flow, good, mi


def warp(pic, flow, good, sigma):
    _, (iy, ix) = ndimage.distance_transform_edt(~good, return_indices=True)
    flow = flow[iy, ix]  # 体の外と外れた画素は、いちばん近い体の画素の流れ
    fx = ndimage.gaussian_filter(flow[..., 0], sigma)
    fy = ndimage.gaussian_filter(flow[..., 1], sigma)
    H, W = pic.shape[:2]
    gx, gy = np.meshgrid(np.arange(W, dtype=np.float32), np.arange(H, dtype=np.float32))
    return cv2.remap(pic, gx + fx, gy + fy, interpolation=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)

load_coords は、色にした座標を読み、2 つの面が混ざった境目の画素を外します。doll_flow は、色(座標)が最も近い 1 コマ目の画素を k-d 木で探します。座標に画面の位置を少しだけ(POS_W = 0.05 の重みで)混ぜているのは、左右の脚のように座標が似た別の場所を取り違えないためです。座標と位置を合わせた距離が MATCH(0.01)を超える対応と、画面の長い辺の MAX_SHIFT(12%)より大きく動く対応は、外れとして捨てます。

warp は、人形の外にある画素(髪・剣・背景)に、いちばん近い体の画素が持つ流れを使わせ、流れをガウスで sigma 画素ほどならしてから、1 枚目の絵をその流れで引き伸ばします。


ちらつきは 0、腕も付いてくる

ならし 20 画素で歪めた 4 コマは、ちらつきが 0.07〜0.08 でした。ならし 3 画素では 0.00 です。止まっている所はほぼ 1 枚目のままで、残る差は、ならした流れがわずかに染み出した分です。腕が付いてきた割合は 0.96〜0.98 で、3 つの方法のどれよりも人形に近くなりました。


動きが大きいと壊れる

ただし、絵が壊れない保証はありません。下の図は、腕の周りを切り出したものです。

腕と剣の周りを切り出した 4 枚。元の絵は、下ろした左手で剣を持つ。ならし 3 画素のコマ 4 は、腕の黒い袖がぎざぎざに裂け、剣が 2 本に分かれる。ならし 20 画素のコマ 2 は、腕が少し上がり、剣も手と一緒に動いて崩れていない。ならし 20 画素のコマ 4 は、腕は上がるが、剣の刃が曲がり、元の手があった所に手の跡の黒い塊が残る

腕の周り(seed 20261112)。左から、元の絵、ならし 3 画素の 4 コマ目、ならし 20 画素の 2 コマ目と 4 コマ目

ならしが弱い 3 画素では、腕がぎざぎざに裂けました。描いた絵の袖は、人形の腕より太く描かれています。袖のはみ出した画素は、止まっている胴と動いている腕のうち、近い方の流れを使います。そのため、境目で動きが急に切り替わって裂けます。ならしを 20 画素にすると、この切り替わりがなだらかになり、裂けは消えました。数値では、ならし 3 画素の方がちらつきは 0.00 と小さく、腕が付いてきた割合も 0.99 と大きいのですが、どちらの数値も裂けを捉えません。裂けた画素も背景ではないので、腕は「付いてきた」と数えられます。本記事の数値は、目で見た裂けと組み合わせて読む必要があります。


人形に無い剣と、隠れていた所

ならし 20 画素でも、4 コマ目は壊れています。理由は 2 つです。

1 つ目は、人形に無い小物です。剣は人形に無いので、剣の画素は手と胴の流れを混ぜて動き、刃が曲がります。

2 つ目は、隠れていた所です。腕が離れた後の所には、1 枚目に描かれていない背景が必要です。歪めるだけでは新しい画素を作れないので、元の手を描いた画素が残ります。

2 コマ目は崩れていません。832 x 1216 の絵で、手の動きは 2 コマ目で約 40 画素、3 コマ目で約 75 画素、4 コマ目で約 115 画素です。3 コマ目では、手の跡が小さく出始めました。歪めて動かせるのは、隠れていた所が見えてこない程度の小さな動きまでです。


座標に UV を使う場合

表面に描く座標を展開図の座標(UV)に替える方法も試しました。この動きでは、ならし 3 画素では同じように裂けました。20 画素ではぎざぎざの裂けは消えましたが、腕の中に穴が空き、手が二重に残りました。ただし、ならし 20 画素のちらつきは 0.81〜0.90 で、Generated の 10 倍ほどあります。UV は展開図の継ぎ目で値が飛び、左右の手足で似た値が繰り返すので、止まっている所でも対応を取り違える画素が出ます。筆者が腰を回すコマで試した時は、UV で体が裂けました。座標には Generated を使う方が安全です。


動きの大きさで方法を選ぶ

ちらつき(横)と腕が付いてきた割合(縦)で、ここまでの結果を 1 枚にまとめました。左上ほど、ちらつかずに人形どおりに動いています。

散布図。横軸はちらつき、縦軸は腕が付いてきた割合。1 枚を歪めるは左上(約 0.1、0.97)。並べて 1 枚は上の中ほど(11〜13、約 0.9)。1 コマずつ描くは右上(17〜22、0.90〜0.96)。弱く描き直すは下に並び、0.45 が左下(3 前後、0 付近)、0.6 が 6〜7、0.75 が 9〜13 で、縦は 0.12 以下

ちらつきと、腕が付いてきた割合(各方法 3 seed。1 枚を歪めるは、ならし 20 画素)

弱く描き直す方法は、denoise をどこにしても下の段に並び、腕を動かす手段になりませんでした。残る 3 つは、どれも腕が付いてきます。この中で、ちらつきの小ささは「1 枚を歪める」が抜けていますが、使えるのは小さな動きだけです。

選び方は、動きの大きさで分けます。呼吸、髪や服の揺れ、腰の小さな回転のように、隠れていた所が見えてこない動きは、仕上げた 1 枚を人形の動きどおりに歪めます。描き込みはコマ間で完全に同じです。剣のような小物を持たせるなら、その小物も人形に持たせておきます。

腕を振り上げる、振り返るのように大きな動きは、描き直すしかありません。1 コマずつ描くとちらつくので、コマの数を減らし、決めのポーズを数枚だけ描いて長めに見せます。コマが少ないほど、コマ間の揺れは目立ちません。4 コマで服の形と色を揃えることが何より大事で、塗りの細かさを捨てられるなら、並べて 1 枚に描きます。ただし、本記事では剣が 3 seed とも消えたので、小物は 1 つずつ確認する必要があります。

筆者は、アクションのコマは数を限界まで減らしてカクカクと大きく動かし、揺れや呼吸は 1 枚を歪めて作る、と分けることにしました。どちらにも当てはまらない動きは、動画用に学習したモデルを試す段階です。