0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

手探りしてみる CV/ ML/ NN: 15日目 auto-encoderで自前のモデルを作る話4

Posted at

情報ボトルネック作戦は成功したけど・・・ 3x3の代償


前回までで、3x3の極小ボトルネックを用いたFaceSwapモデルの設計と思想について語りました。「情報を絞ることでIdentityを捨てさせる」という戦略は成功し、論理的にはFaceSwapが可能であることが実証されました。

latest_step_0100_global_100_.jpg

しかし、生成された画像を細かくチェックしていると、ある 「視覚的な違和感」 に気づきました。

今回は、論理(情報理論)ではなく、実装(レイヤー構造)に起因する画質の問題、「市松模様ノイズ(Checkerboard Artifacts)」 とその解決策について話します。


1. 成功の陰に潜む「網目」

3x3モデルで学習した顔画像の、特に「肌」や「背景」などの平坦な部分を拡大してみます。

遠目に見ると綺麗に再構成されているように見えますが、ズームインすると…

Screenshot 2025-12-15 at 22.32.39.png

Screenshot 2025-12-15 at 22.32.51.png

Screenshot 2025-12-15 at 22.33.05.png

まるで 「網戸越し」 に見ているような、微細な格子状のパターン(グリッド)が乗っていることに気づきます。

これは、学習不足やノイズではありません。Lossは十分に下がっています。

Loss_Total.jpg

これは、モデルの構造そのものが生み出しているアーティファクトです。


2. 犯人は ConvTranspose2d

このグリッドノイズの正体は、GANやAutoEncoder界隈では有名な Checkerboard Artifacts(市松模様ノイズ) です。

そして、その犯人はDecoderで多用している nn.ConvTranspose2d です。

2.1 ConvTranspose2dの仕組みと弱点: 「拡大」ではなく「ばら撒き」

この問題の根本原因を理解するには、ConvTranspose2dが何をしているかを正しくイメージする必要があります。
よくある誤解として、「小さな画像を拡大コピーして貼り付けている」と思われがちですが、実際の挙動は 「Splatting(ばら撒き)」 に近いです。

水やりのメタファー

イメージしてください。

  1. あなたは 幅3マスに広がるシャワー(Kernel=3) を持っています。
  2. このシャワーを出しながら、2マスずつ(Stride=2) 移動していきます。

すると、どうなるでしょうか?

Gemini_Generated_Image_p2ow0rp2ow0rp2ow.png

(図解: n+1マスの拡散をnマスおきに浴びせると、どうしても重なる部分が出てくる)

  • シャワーの広がり(3)移動距離(2)

この「歩幅よりも広くばら撒く」という性質上、前のシャワーの右端と、次のシャワーの左端が、**どうしても地面で重なってしまいます**。
*   重なった場所: シャワーを2回浴びるので、色が濃くなります(値が大きくなる)。
*   重ならない場所: シャワーを1回しか浴びないので、色は薄いままです。

結果として、「濃い・薄い・濃い・薄い」 というリズムが規則正しく刻まれます。
これが、出力画像に現れる グリッド(市松模様)の正体 です。

本来は、この「重なり」を利用して滑らかなグラデーション(補間)を作りたいのですが、重なり方が不均一だと、逆にそれが「模様」として残ってしまうのです。

2.2 なぜ3x3だと特に酷いのか?

「カーネルサイズ4、ストライド2、パディング1」の設定は、理論上は均一に重なる設定です。しかし、重みの初期化や学習の偏りによって、どうしても微細な模様が出やすくなります。

特に今回のモデルでは、3x3 という極小サイズからスタートしています。

  1. 3x3 → 8x8: ここでグリッドが発生すると、それは画像の「根本的な構造」として焼き付く
  2. 8x8 → 16x16: そのグリッドをさらに拡大・補間する
  3. ... → 256x256: 最終的には、画像全体を覆う微細なメッシュとして現れる

初期段階での小さな歪みが、度重なるアップサンプリングによって増幅され、逃れられない「呪い」のように全体を支配してしまうのです。

2.3 なぜ「高解像度」で急に目立つのか?

読者の中には疑問に思う方もいるかもしれません。
「256x256の時はそんなに気にならなかったのに、なぜ512x512にした途端に酷くなるのか?」

これには、「干渉」「解像度の皮肉」 という2つの理由があります。

① グリッドの多重干渉 (Interference)

ConvTranspose2dを重ねるということは、「前の層のグリッドの上に、新しい層のグリッドを書き足す」 行為です。

*   **256x256**: 6回の積み重ね
*   **512x512**: 7回の積み重ね

たった1回の差に見えますが、波と波が重なると**「干渉(モアレ)」**が起きます。
層が深くなるほど、異なる周期のグリッドが複雑に重なり合い、単なる「点」だったノイズが、目に見える「模様」へと増幅されてしまうのです。

checkerboard_diagram_clean.png

② 高解像度が「ノイズまでくっきり描いてしまう」

これは皮肉な話ですが、解像度が低い(256x256)ときは、画像の**「ボケ」**が味方をしていました。
ピクセルが粗いため、微細なグリッドノイズは潰れて見えなくなっていた(平滑化されていた)のです。

しかし、512x512になるとキャンバスの目が細かくなります。
モデルは肌のキメを描こうとしますが、その高い表現力が仇となり、「本来見えなくてよかった微細なグリッドノイズ」まで、くっきりと解像してモニターに映し出してしまうのです。

「画質を良くしようとしたら、アラまでよく見えるようになった」
これが高解像度化で直面するジレンマです。


3. 解決策: Resize + Convolution

この問題に対する古典的かつ最強の解決策は、2016年にOdenaらが提唱した "Resize-Convolution" アプローチです。

Deconvolution and Checkerboard Artifacts (Odena et al., 2016)
https://distill.pub/2016/deconv-checkerboard/

やり方は非常にシンプルです。
「拡大と畳み込みを分ける」 ことです。

Before: ConvTranspose2d

「拡大しながら畳み込む」を一発で行う。

# 1ステップで拡大と変換を行う(ムラが出やすい)
layer = nn.ConvTranspose2d(in_ch, out_ch, 4, 2, 1)

After: Resize + Conv2d

  1. Resize: 最近傍補間(Nearest Neighbor)などで、単純に画像を2倍に引き伸ばす。
  2. Conv2d: 拡大された画像に、通常の畳み込み(stride=1)をかけて整える。
# 2ステップに分ける
layer = nn.Sequential(
    nn.Upsample(scale_factor=2, mode='nearest'), # または 'bilinear'
    nn.Conv2d(in_ch, out_ch, kernel_size=3, stride=1, padding=1)
)

なぜこれで直るのか?

Upsample による拡大は、ピクセルを単純にコピー(または補間)するだけなので、重なりの不均一さが発生しません
その後の Conv2d (stride=1) は、すべてのピクセルに対して均等にカーネルを適用するため、新たなムラを生み出しません。

結果として、グリッドのない滑らかな拡大が可能になります。


4. 実装の修正

Decoderの定義を書き換えます。
これまでは ConvTranspose2d を積み重ねていましたが、これを UpSample + Conv2d のブロックに置き換えます。

class UpsampleBlock(nn.Module):
    def __init__(self, in_ch, out_ch):
        super().__init__()
        self.net = nn.Sequential(
            # 1. まず2倍に拡大 (Nearest Neighborがシャープさを保ちやすい)
            nn.Upsample(scale_factor=2, mode='nearest'),
            # 2. 畳み込みで特徴を整える
            nn.Conv2d(in_ch, out_ch, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(out_ch),
            nn.LeakyReLU(0.1, inplace=True)
        )

    def forward(self, x):
        return self.net(x)

Decoder全体もこれに合わせて修正します。

# 旧 Decoder (ConvTranspose2d)
# self.up1 = nn.ConvTranspose2d(1024, 512, 4, 2, 1)
# self.up2 = nn.ConvTranspose2d(512, 256, 4, 2, 1)
# ...

# 新 Decoder (Resize + Conv)
self.up1 = UpsampleBlock(1024, 512)
self.up2 = UpsampleBlock(512, 256)
# ...

5. 結果 - グリッドの消失

修正したモデルで学習を回し直すと、結果は劇的です。

Screenshot 2025-12-15 at 23.25.32.png

左がConvT、右がResize+Conv

*   ConvTranspose2d: 肌にザラザラしたデジタルな網目が見える。
*   Resize + Conv  : 網目が消え滑らかになった。

トレードオフ

ただし、すべてが良いことづくめではありません。

Resize + Convアプローチは、ConvTranspose2dに比べて 「画像が若干ソフト(ボケ気味)になる」 傾向があります。
特に mode='bilinear' を使うと顕著ですが、mode='nearest' でも、ConvTranspose2dのような「カチッとした」エッジは少し弱まることがあります。

しかし、「不自然なアーティファクト(グリッド)」よりは「自然な滑らかさ」の方が、人間の目には高画質に映ります
また、LPIPS Lossを入れているため、ある程度のシャープさは維持されます。

5.2 補足: もう一つの選択肢 PixelShuffle

この問題に対する別解として、PixelShuffle (Sub-Pixel Convolution) という手法も有名です。

これは、画像を拡大するのではなく、チャンネル数(奥行き)を増やして、それを空間(縦横)に並べ替える というアクロバティックな手法です。
(例: チャンネル数を4倍にして計算し、最後に 2x2 のマスに並べ替えて解像度を2倍にする)

*   **メリット**: 計算効率が良く、Resize+Convよりもシャープな画質が得られやすい。
*   **デメリット**: 実はこれもしっかりと対策(ICNR初期化など)をしないと、結局グリッドノイズが出ることがある。

今回は、3x3ボトルネックという非常に敏感な環境であるため、「確実にグリッドを消せる」 という安定性を取って Resize + Conv を採用しました。
もし計算速度を極限まで追求する場合は、PixelShuffleも有力な選択肢になります。


6. まとめ

3x3ボトルネックで「Identity分離」を達成し、Resize+Convで「グリッドノイズ」を排除しました。

これにより、このFaceSwapモデルは:

1.  情報ボトルネック理論に基づき、Identityを正しく扱える
2.  不自然なノイズのない、自然な画像を生成できる

を達成できました。



参考:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?