Spaces:
Running on Zero
Running on Zero
File size: 6,797 Bytes
333ff0e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | """16-ch f8 VAE wrapper (diffusers).
PICK = Qwen-Image VAE 1.0 (f8, 16-ch, text-rich decoder, Apache-2.0); FLUX.2 VAE is an Apache
fallback. Encoder frozen; decoder fine-tunable on Swedish ink. Latent normalization is read from
the loaded VAE's own config (scalar scaling/shift for FLUX/SD, per-channel mean/std for Qwen/Wan)
so we never hardcode the wrong constants. Includes the round-trip reconstruction-CER gate.
"""
from __future__ import annotations
from collections.abc import Callable
import torch
import torch.nn as nn
from diffusers import AutoencoderKL
from ..config import VAEConfig
try: # Qwen-Image uses a dedicated VAE class in recent diffusers
from diffusers import AutoencoderKLQwenImage
except ImportError: # older diffusers / not installed locally
AutoencoderKLQwenImage = None
def _load_vae(pretrained: str, subfolder: str) -> AutoencoderKL:
"""Load the latent VAE, falling back to the Qwen-Image (video-derived) VAE class.
``AutoencoderKL.from_pretrained`` fails on the Qwen-Image checkpoint because it declares a
different model class; we catch the load/config error and retry with the dedicated class.
Args:
pretrained: HF repo id (e.g. ``"Qwen/Qwen-Image"``).
subfolder: Subfolder holding the VAE weights (e.g. ``"vae"``).
Returns:
The instantiated diffusers VAE module.
"""
try:
return AutoencoderKL.from_pretrained(pretrained, subfolder=subfolder)
except (OSError, ValueError, KeyError):
# Class/config mismatch (Qwen is a Wan/video-derived VAE) or a load error -> retry.
if AutoencoderKLQwenImage is not None:
return AutoencoderKLQwenImage.from_pretrained(pretrained, subfolder=subfolder)
raise
class VAEWrapper(nn.Module):
"""Frozen-encoder / trainable-decoder VAE with latent (de)normalization and a recon-CER gate."""
def __init__(self, cfg: VAEConfig) -> None:
super().__init__()
self.cfg = cfg
self.vae = _load_vae(cfg.pretrained, cfg.subfolder)
self.latent_channels = cfg.latent_channels
self.video_vae = cfg.video_vae # Qwen/Wan VAE wants 5D [B, C, T, H, W]
c = self.vae.config
# Two normalization conventions across modern VAEs:
# FLUX/SD : (z - shift_factor) * scaling_factor (scalars)
# Qwen/Wan: (z - latents_mean) / latents_std (per-channel)
self._per_channel = getattr(c, "latents_mean", None) is not None
if self._per_channel:
self.register_buffer("lat_mean", torch.tensor(c.latents_mean).view(1, -1, 1, 1))
self.register_buffer("lat_std", torch.tensor(c.latents_std).view(1, -1, 1, 1))
else:
self.scaling = getattr(c, "scaling_factor", 1.0)
self.shift = getattr(c, "shift_factor", None) or 0.0
self.vae.requires_grad_(False)
if cfg.finetune_decoder_only:
self.vae.decoder.requires_grad_(True)
if getattr(self.vae, "post_quant_conv", None) is not None:
self.vae.post_quant_conv.requires_grad_(True)
def _normalize(self, z: torch.Tensor) -> torch.Tensor:
if self._per_channel:
return (z - self.lat_mean) / self.lat_std
return (z - self.shift) * self.scaling
def _denormalize(self, z: torch.Tensor) -> torch.Tensor:
if self._per_channel:
return z * self.lat_std + self.lat_mean
return z / self.scaling + self.shift
@torch.no_grad()
def encode(self, x: torch.Tensor, *, sample: bool = True) -> torch.Tensor:
"""Encode images to a normalized 4D latent.
Args:
x: Images ``[B, 3, H, W]`` in ``[-1, 1]``.
sample: Draw from the posterior (``True``, training default — one reparam sample) or return its
MODE/mean (``False``). Use ``sample=False`` to precompute a DETERMINISTIC latent cache
(data/latent_cache.py); the posterior std is tiny for an f8 VAE and flow matching injects its
own noise, so the mode is the conventional, behavior-safe cache choice.
Returns:
Normalized latent ``[B, C, H/8, W/8]`` (encoder frozen). For video VAEs (Qwen/Wan) a
``T=1`` frame dim is added then squeezed so the latent stays 4D for the DiT.
"""
if self.video_vae:
x = x.unsqueeze(2) # [B, 3, 1, H, W]
dist = self.vae.encode(x).latent_dist
z = dist.sample() if sample else dist.mode()
if self.video_vae:
z = z.squeeze(2) # back to 4D [B, C, h, w]
return self._normalize(z)
def decode(self, z: torch.Tensor) -> torch.Tensor:
"""Decode a normalized 4D latent to an image ``[B, 3, H, W]`` in ``[-1, 1]`` (decoder trainable)."""
z = self._denormalize(z)
if self.video_vae:
z = z.unsqueeze(2) # [B, C, 1, h, w]
img = self.vae.decode(z).sample
if self.video_vae:
img = img.squeeze(2) # back to [B, 3, H, W]
return img
@torch.no_grad()
def reconstruction_cer_gate(
self,
images: torch.Tensor,
transcriptions: list[str],
recognizer: Callable[[torch.Tensor], list[str]],
batch_size: int = 8,
) -> dict[str, float | bool]:
"""Round-trip real lines (encode -> decode) and measure HTR CER on the reconstruction.
Must be within ``cfg.recon_cer_gate`` of the raw-image CER before any diffusion training
begins (otherwise the latent is dropping diacritics).
Args:
images: Real line images ``[B, 3, H, W]`` in ``[-1, 1]``.
transcriptions: Ground-truth strings, one per image.
recognizer: Callable mapping a batch of images to predicted strings.
batch_size: Lines per VAE round-trip + recognizer call (chunked to bound GPU memory).
Returns:
``{"raw_cer", "recon_cer", "passed"}``.
"""
from .metrics import cer as _cer
# Chunked: the Qwen/Wan VAE's 3D convs allocate >80 GB if the whole set is round-tripped at
# once (the gate OOMed on 500 lines). Encode/decode + recognize in small batches instead.
raw_preds: list[str] = []
recon_preds: list[str] = []
for i in range(0, images.shape[0], batch_size):
chunk = images[i : i + batch_size]
recon = self.decode(self.encode(chunk))
raw_preds.extend(recognizer(chunk))
recon_preds.extend(recognizer(recon))
raw_cer = _cer(raw_preds, transcriptions)
recon_cer = _cer(recon_preds, transcriptions)
return {
"raw_cer": raw_cer,
"recon_cer": recon_cer,
"passed": (recon_cer - raw_cer) <= self.cfg.recon_cer_gate,
}
|