📑

Stable diffusion: できるだけ楽して一貫性のあるVideo-to-Videoを実現する

2024/02/19に公開

目的

Stable Diffusion (SD)は画像の加工ツールとして非常に優秀ですが、その延長で動画にも活用したい欲求を持っている人は多いでしょう。動画は静止画の集合ですから、パラパラ漫画の要領で一枚ずつ切り出して加工すれば動画の加工も可能です。ただこのアプローチでぶつかる大きなハードルが時間方向の一貫性 (time-consistency or coherence)です。動画から切り出した連続画像を別々に加工すると、SDがもたらすランダム性により仕上がりに違いが生まれます。それがたとえ微妙な差異でも、動画として再生してみると違和感満載になります。本記事ではそんな課題を克服するアプローチをまとめます。

前提と要件

  1. 動画の生成ではなく加工。元動画がある前提でその加工をする。つまりVideo-to-Videoを行います。
  2. 元の動画の大幅な修正はしないし、したくない。
  3. 追加でモデルを学習するような大掛かりな処理は避ける。モデルの外でできる範囲で頑張る
  4. 人物実写系のみで検証しています。

前提2にはご留意ください。この前提があるからこそできるアプローチもあります。

それから3の点に関してですが、モデルを追加学習する前提であれば可能な策が広がります。その中でも私が一番注目したのがControlVideoというアプローチです。https://arxiv.org/pdf/2305.17098.pdf 他にはこちらの動画で色々な方法が紹介されており非常に参考になります。https://www.youtube.com/watch?v=4XYJMhOIM9I 総じて、色んなアプローチがありますが時間方向へのAttentionを設けるのがポイントです。ただ今回はモデルの外でできることをテーマにするため触れません。

ということで、動画から画像を切り出して一枚ずつimage-to-imageで加工していきます。その過程においてできる工夫を以下にまとめます。

前処理工程でできること

フレームレートの低減と補間

まずは基本的なところから。処理する画像の量を減らすためにフレームレートを低減しましょう。FPS=30の動画が多いと思いますが、自分の経験からしてFPS=10ないし15が良いと思います。最後にフレーム補間で元に戻しましょう。フレーム補間ツールは割と多くの動画加工アプリにあると思います。
動画を所定のレートでリサンプリングしてフレーム毎に画像で書き出すコードです。

# input
cap = cv2.VideoCapture("/path/to/original-video.mp4")
orig_fps = cap.get(cv2.CAP_PROP_FPS)
start_at, end_at = 10, 20
sampling_fps = 10
# sampling_fps = 15
frame_skip = round(orig_fps / sampling_fps)

# output
sampled_dir = Path("/path/to/output-folder")
assert not sampled_dir.exists()
sampled_dir.mkdir(exist_ok=False)

frame_count = 0
sampling_count = 0
out_frame_count = 0
while True:
    ret, frame = cap.read()
    if not ret:
        break

    frame_count += 1
    if frame_count < int(start_at * orig_fps):
        continue
    if frame_count > int(end_at * orig_fps):
        break

    if sampling_count == 0:
        height, width, _ = frame.shape
        cv2.imwrite(str(sampled_dir / f"frame{out_frame_count}.jpg"), frame)
        out_frame_count += 1
        print(f"output {out_frame_count}-th frame")

    sampling_count = (sampling_count + 1) % frame_skip

L = out_frame_count
print(L, height, width)

print("finish sampling")
cap.release()

生成工程でできること

Controlnet + Canny

これも基本的アプローチですが、構図を精緻に保ったままimage-to-imageをするといえばControlnetが欠かせません。Cannyエッジなどで構図を指定しましょう。

Controlnet + Reference-only

ControlnetにはReference-onlyというモードもあります。指定の画像に注目を払って描いてくれるんですね。なので参考画像として元画像と同じものを与えれば元画像の構図を保つ効果があります。また、Cannyだと変えて欲しいところも変わらない一方で、Reference-onlyはモデルの裁量で良い感じにしてくれます。それから構図だけでなく色味やテクスチャも保たれる効果も魅力的です。

Control weightを最大の2.0にして、"controlnet is more important"で影響力MAXすると良いでしょう。

Controlnetは複数適用できるので、CannyとReference-onlyの両方適用してもいいですが、個人的にはReference-only単体が基本的にオススメで、構図が崩れやすい時はCannyも(影響力弱めの設定で)入れるのがいいかなと思います。

小さなDenoising strength

これが大事なパラメータで、デフォルトでは0.7くらいになっているでしょうか。これを下げます。0.4〜0.55くらいが良いと思います。大きいほど画像がしっかり変化し、小さいほど元の画像が保たれます。実はこのパラメータに応じて実質的サンプリング回数が変わる実装になっており、例えば所定サンプリング回数を30回に指定し、denoising strengthが0.5であれば30 x 0.5 = 15回でサンプリングを打ち止めるという実装になっています。サンプリングを控えることで元の画像も保たれやすいということになります。

Deterministicなサンプラー (DDIM)

意図は本記事末尾に書いた「おまけ: SDなど拡散モデルが画像を生成する過程のイメージ」を読んで頂きたいのですが、deterministic系サンプラーはランダム性を抑え一貫性を高める効果が期待できます。

Noise multiplierを小さくする

Settingsメニューにこっそりと「noise multiplier for img2img」というパラメータがあります。デフォルトで1.0になっていますが小さくします。0にしてもいいです。image-to-imageは一旦元画像にノイズを加えて汚しそのノイズを除去していくことで別の絵柄にするのが原理ですが、このパラメータを小さくすることで汚す量が減ります。よって、(元画像にはない)余計な物が描かれる確率も下がり、元の絵との一貫性も保たれやすいという理屈です。

ただ、ノイズを加えないということは元画像におけるノイズ的な部分がどんどん削られて(denoiseされて)シンプルな絵柄になっていくことも意味します。「元画像のノイズ的な部分」は例えば肌のシワだったり、服の模様だったりします。前者であれば肌がすべすべになるので嬉しいですが、後者は残念です。

後処理工程でできること

Color Correction (Equalization)

あまり知られていませんが、SD webuiのimg2imgにはColor correctionという機能があり、出来上がり画像の色分布を元の色分布に揃えてくれます。あくまで画像全体の色分布が揃うだけで、ピクセル単位で適切な色に置き換わるわけではないですが一定の効果が期待できます。
ただ、inpaintでは(なぜか)color correctionが機能しないような実装になっているように見え、実際inpaintではオンとオフで色の違いが見られません。そもそもあまりメンテナンスされていない機能なのかもしれません。

生成後の画像に対して自前でColor correctionするのが良いと思います。scikit-imageにあるhistgram equalizationを使えば簡単です。webuiの実装もこれと同じです。

from skimage import exposure

img_equalized = cv2.cvtColor(exposure.match_histograms(
    cv2.cvtColor(img_generated, cv2.COLOR_RGB2LAB),
    cv2.cvtColor(img_orig, cv2.COLOR_RGB2LAB), channel_axis=2), cv2.COLOR_LAB2RGB)

Image pyramid blending

Image pyramidは画像を様々な解像度(周波数)に分解する古典的な手法です。この手法を用い、高周波成分は加工後の画像、低周波成分は元画像で合成します。そうすると元画像への忠実性を増しつつもしっかり変化も見せることができます。

def laplacian_pyramid_blending(A, B, num_levels, altering_level):
    GA = (A / 255).astype(np.float32)
    GB = (B / 255).astype(np.float32)
    gpA = [GA]
    gpB = [GB]
    for i in range(num_levels):
        GA = cv2.pyrDown(GA)
        GB = cv2.pyrDown(GB)
        gpA.append(np.float32(GA))
        gpB.append(np.float32(GB))

    # generate Laplacian Pyramids for A,B and masks
    lpA = [gpA[num_levels - 1]]
    lpB = [gpB[num_levels - 1]]
    for i in range(num_levels - 1, 0, -1):
        LA = np.subtract(gpA[i - 1], cv2.pyrUp(gpA[i]))
        LB = np.subtract(gpB[i - 1], cv2.pyrUp(gpB[i]))
        lpA.append(LA)
        lpB.append(LB)

    # blend
    LS = []
    for i, (la, lb) in enumerate(zip(lpA, lpB)):
        if i < altering_level:
            ls = lb
        else:
            ls = la
        LS.append(ls)

    # now reconstruct
    ls = LS[0]
    for i in range(1, num_levels):
        ls = cv2.pyrUp(ls)
        ls = cv2.add(ls, LS[i])

    ls = np.clip(ls, 0, 1) * 255
    return ls.astype(np.uint8)

# 使用例
img_blended = laplacian_pyramid_blending(img_genereted, img_orig, 5, 1)

動画のノイズ除去ツールを使う

さて、様々なアプローチで一貫性を保ったまま加工するアプローチを紹介しましたが、それでも動画にしてみるとがチラツキやモヤツキが生じます。そこで、最後の仕上げとして動画のノイズ除去ツールを使いましょう。具体的にオススメのツールがあるわけじゃないですが、動画加工アプリの一機能として色々あると思います。

未検証のアプローチ

DDIM inversion

SDはランダムノイズから画像を生成しますが、逆に画像に対応するランダムノイズを求める手法がDDIM inversionです。DDIMはdeterministic (生成過程で乱数をサンプリングするという処理を行わない)ため、その特性からして起点となるランダムノイズを一意に求めることができます。(また、そのノイズを起点にDDIMで画像生成すれば完全に元画像になるはずです)。よって、このようにして求めたランダムノイズを起点として画像を生成すれば元画像に忠実性の高い出来上がりが期待できます。ただ、Stable diffusion WebUIにはDDIM inversionは実装されていないようです。diffusersでは実装があるので試せる人は試す価値あるかもしれません。

おまけ: SDなど拡散モデルが画像を生成する過程のイメージ

DDIM inversionの話になったので、拡散モデルが画像を生成する過程のイメージを絵にしてみました。SDを理論的に理解したい人はこのイメージを持っておくと良いと思います。

画像というのは多次元ベクトルで表せます。例えば100x100のRGB(3チャネル)画像だったら、そのサイズの画像は30000次元空間のどこか1つの点に対応しています。なので、乱数生成器で30000次元のベクトルを生成させれば、運が良ければ所望の絵が現れることもあります。当然雲を摑むような低い確率ですが。

# もしかしたらxは美しい絵になっているかもしれない
x = np.random.randint(0, 256, size=30000).reshape(100, 100, 3)

このような運頼りの画像生成は使い物になりません。そこで拡散モデルが登場しました。まずN次元空間でガウス分布の乱数を発生させます。その点をどんどん移動させいくのです。湖面に浮かんだ木の葉がフラフラと移動していく様子を想像するとよいでしょう。ただ、この移動させる力は決してランダムではなく、絵に見える座標を目指して徐々に動いていきます。プロンプトで「猫」が与えられていたら、「猫」に見えるN次元ベクトルの方面を目指して動いていきますし、「犬」なら「犬」に見えるN次元ベクトルの方面を目指して動いていきます。行き着く先はプロンプトにより大まかな指定はされるものの、1ステップごとにフラフラとランダムに動きます。このふらつき具合を制御するのがサンプラーですが、中には「フラフラせず一直線に目的座標に向かいます」っていうサンプラーもあります。それが、DDIMに代表されるdeterministicなサンプラー で、初期座標があたられたら行き着く先が一意に決まります。これは初期ノイズと画像を一対一に対応づけることを意味し、DDIM inversionという逆変換を可能にします。

こう説明するとdeterministicサンプラーでいいじゃないかと思うかもしれませんが、出来上がり画像の多様性が失われる点は欠点です。初期ノイズだけで行き着く先が決まるより、向かう過程にランダム性があったほうが色んなところにたどり着けますから。

Discussion