File size: 7,465 Bytes
cfa2b7d | 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 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | """Compare TAEF2 (or a fine-tuned copy) against the full FLUX 3 Klein VAE.
Reports reconstruction PSNR / LPIPS on a folder of images, latent agreement,
decode speed, parameter counts, and saves a side-by-side grid.
python eval_tiny_vae.py --images data/kodak --tiny weights/taef2.safetensors
"""
import argparse, glob, os, time
import lpips
import numpy as np
import safetensors.torch as stt
import torch
import torch.nn.functional as F
from PIL import Image
from diffusers import AutoencoderKLFlux2
from taesd import TAESD
def convert_diffusers_sd_to_taesd(sd):
# taef2.safetensors uses "encoder.layers.N" names; taesd.py uses "encoder.N" (decoder offset by 1)
out = {}
for k, v in sd.items():
if not k.startswith(("encoder.layers.", "decoder.layers.")):
out[k] = v
continue
encdec, _layers, index, *suffix = k.split(".")
offset = 1 if encdec == "decoder" else 0
out[".".join([encdec, str(int(index) + offset), *suffix])] = v
return out
def load_tiny(path, device, dtype=torch.float32):
m = TAESD(encoder_path=None, decoder_path=None, latent_channels=32, arch_variant="flux_2")
sd = stt.load_file(path)
if any(k.startswith(("encoder.layers.", "decoder.layers.")) for k in sd):
sd = convert_diffusers_sd_to_taesd(sd)
m.load_state_dict(sd)
return m.to(device, dtype).eval().requires_grad_(False)
def patchify(z):
b, c, h, w = z.shape
return z.view(b, c, h // 2, 2, w // 2, 2).permute(0, 1, 3, 5, 2, 4).reshape(b, c * 4, h // 2, w // 2)
def unpatchify(z):
b, c, h, w = z.shape
return z.reshape(b, c // 4, 2, 2, h, w).permute(0, 1, 4, 2, 5, 3).reshape(b, c // 4, h * 2, w * 2)
class LatentNorm:
"""Raw VAE latents <-> the BN-normalized space the FLUX transformer (and TAEF2) work in."""
def __init__(self, vae):
self.mean = vae.bn.running_mean.view(1, -1, 1, 1).float()
self.std = torch.sqrt(vae.bn.running_var.view(1, -1, 1, 1).float() + vae.config.batch_norm_eps)
def to_norm(self, z):
return unpatchify((patchify(z) - self.mean.to(z)) / self.std.to(z))
def to_raw(self, z):
return unpatchify(patchify(z) * self.std.to(z) + self.mean.to(z))
def load_images(folder, mult=16):
xs, names = [], []
for p in sorted(glob.glob(os.path.join(folder, "*"))):
if not p.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
continue
im = Image.open(p).convert("RGB")
w, h = im.size
im = im.crop((0, 0, w - w % mult, h - h % mult))
xs.append(torch.from_numpy(np.asarray(im)).permute(2, 0, 1).float() / 255.0)
names.append(os.path.basename(p))
return xs, names
def psnr(a, b):
return (10 * torch.log10(1.0 / F.mse_loss(a, b))).item()
def timed(fn, x, n=10):
fn(x); torch.cuda.synchronize()
t = time.perf_counter()
for _ in range(n):
fn(x)
torch.cuda.synchronize()
return (time.perf_counter() - t) / n * 1000
@torch.no_grad()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--images", default="data/kodak")
ap.add_argument("--vae", default="weights/flux3-klein-base/vae")
ap.add_argument("--tiny", nargs="+", default=["weights/taef2.safetensors"])
ap.add_argument("--grid", default="out/compare.jpg")
ap.add_argument("--latent_space", choices=["norm", "raw"], default="norm",
help="space the tiny AE reads/writes: BN-normalized (TAEF2) or raw VAE latents")
args = ap.parse_args()
dev = "cuda"
vae = AutoencoderKLFlux2.from_pretrained(args.vae, torch_dtype=torch.float32).to(dev).eval()
ln = LatentNorm(vae)
to_tiny = ln.to_norm if args.latent_space == "norm" else (lambda z: z)
from_tiny = ln.to_raw if args.latent_space == "norm" else (lambda z: z)
tinies = {os.path.basename(p): load_tiny(p, dev) for p in args.tiny}
lp = lpips.LPIPS(net="alex", verbose=False).to(dev)
nparam = lambda m: sum(p.numel() for p in m.parameters()) / 1e6
print(f"full VAE : {nparam(vae):7.2f}M params (enc {nparam(vae.encoder):.2f}M / dec {nparam(vae.decoder):.2f}M)")
for n, t in tinies.items():
print(f"{n:11s}: {nparam(t):7.2f}M params (enc {nparam(t.encoder):.2f}M / dec {nparam(t.decoder):.2f}M)")
xs, names = load_images(args.images)
res = {k: [] for k in ["full"] + [f"{n}|dec" for n in tinies] + [f"{n}|enc" for n in tinies] + [f"{n}|both" for n in tinies]}
lat_stats = {n: [] for n in tinies}
grid_rows = []
for i, x in enumerate(xs):
x = x[None].to(dev)
z = vae.encode(x * 2 - 1).latent_dist.mode() # raw 32-ch latent at /8, what the pipeline unpatchifies to
full = vae.decode(z).sample.add(1).div(2).clamp(0, 1)
res["full"].append((psnr(full, x), lp(full * 2 - 1, x * 2 - 1).item()))
row = [x, full]
for n, t in tinies.items():
zn = to_tiny(z)
d = t.decoder(zn).clamp(0, 1)
res[f"{n}|dec"].append((psnr(d, x), lp(d * 2 - 1, x * 2 - 1).item()))
zt = t.encoder(x)
e = vae.decode(from_tiny(zt)).sample.add(1).div(2).clamp(0, 1)
res[f"{n}|enc"].append((psnr(e, x), lp(e * 2 - 1, x * 2 - 1).item()))
b = t.decoder(zt).clamp(0, 1)
res[f"{n}|both"].append((psnr(b, x), lp(b * 2 - 1, x * 2 - 1).item()))
cos = F.cosine_similarity(zt.flatten(), zn.flatten(), dim=0).item()
lat_stats[n].append((F.mse_loss(zt, zn).item(), cos, zn.std().item(), zt.std().item()))
row.append(d)
if i < 4:
grid_rows.append(torch.cat([r[..., :512, :512] for r in row], -1))
print("\nreconstruction vs original image (mean over", len(xs), "images)")
print(f"{'path':34s} {'PSNR dB':>8s} {'LPIPS':>7s}")
for k, v in res.items():
v = np.array(v)
label = {"full": "full VAE enc -> full VAE dec"}.get(k, k.replace("|dec", ": full enc -> TINY dec").replace("|enc", ": TINY enc -> full dec").replace("|both", ": TINY enc -> TINY dec"))
print(f"{label:34s} {v[:,0].mean():8.2f} {v[:,1].mean():7.4f}")
for n, s in lat_stats.items():
s = np.array(s)
print(f"\n{n} encoder vs full encoder latents: MSE {s[:,0].mean():.4f} cosine {s[:,1].mean():.4f} std full {s[:,2].mean():.3f} / tiny {s[:,3].mean():.3f}")
print("\ndecode latency (bf16, batch 1)")
vae_bf = vae.to(torch.bfloat16)
for res_px in (1024, 2048):
z = torch.randn(1, 32, res_px // 8, res_px // 8, device=dev, dtype=torch.bfloat16)
torch.cuda.reset_peak_memory_stats()
tf = timed(lambda z: vae_bf.decode(z).sample, z, 5)
mf = torch.cuda.max_memory_allocated() / 2**30
line = f" {res_px}px full VAE {tf:8.1f} ms ({mf:.1f} GB peak)"
for n, t in tinies.items():
tb = t.to(torch.bfloat16)
torch.cuda.reset_peak_memory_stats()
tt = timed(tb.decoder, z, 10)
mt = torch.cuda.max_memory_allocated() / 2**30
line += f" | {n} {tt:6.1f} ms ({mt:.1f} GB) -> {tf/tt:.1f}x faster"
t.float()
print(line)
os.makedirs(os.path.dirname(args.grid) or ".", exist_ok=True)
g = torch.cat(grid_rows, -2)[0].permute(1, 2, 0).float().cpu().numpy()
Image.fromarray((g * 255).round().astype(np.uint8)).save(args.grid, quality=92)
print("\ngrid columns: original | full VAE |", " | ".join(tinies), "->", args.grid)
if __name__ == "__main__":
main()
|