Spaces:
Running on Zero
Running on Zero
File size: 6,265 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 | """Stage-0 VAE diacritic gate — script form of notebooks/stage0_vae_gate.ipynb.
Round-trips line images through the 16-ch VAE (encode -> decode) and checks that the HTR Character
Error Rate of the reconstruction stays within ``cfg.vae.recon_cer_gate`` of the raw-image CER — i.e.
the VAE preserves å ä ö well enough to train on. The single most important check before training.
Reuses ``VAEWrapper.reconstruction_cer_gate`` + the eval ``HTRRecognizer``.
diffu-vae-gate --manifest data_out/val.jsonl --n 500 # on YOUR real lines
diffu-vae-gate --synthetic --n 16 --save-dir vae_check # self-test, no data needed
"""
from __future__ import annotations
import argparse
from pathlib import Path
import torch
import torch.nn.functional as F
from PIL import Image, ImageDraw, ImageFont
from torchvision.transforms import functional as TF
from .config import Config
from .data.dataset import load_manifest
from .eval import HTRRecognizer
from .model.vae import VAEWrapper
_SAMPLE_WORDS = ("Göteborg", "Smörgåsbord", "Råå församling", "Ängelholm", "väderöarna", "Åsa köper öl")
def _pad_batch(tensors: list[torch.Tensor], multiple: int = 16) -> torch.Tensor:
"""Right-pad ``[3,H,W]`` tensors to a common (rounded) width with white (1.0) and stack."""
target = max(t.shape[-1] for t in tensors)
target = ((target + multiple - 1) // multiple) * multiple
return torch.stack([F.pad(t, (0, target - t.shape[-1]), value=1.0) for t in tensors]) # [B,3,H,W]
def load_real_lines(manifest: str, n: int, cfg: Config) -> tuple[torch.Tensor, list[str]]:
"""Load up to ``n`` lines from a manifest -> padded ``[B,3,H,W]`` batch in ``[-1,1]`` + texts."""
rows = load_manifest(manifest)[:n]
height, max_width = cfg.data.line_height, cfg.data.max_line_width
tensors, texts = [], []
for r in rows:
with Image.open(r["image"]) as im:
img = im.convert("RGB")
w = min(max_width, max(8, round(img.width * height / img.height)))
img = img.resize((w, height), Image.LANCZOS)
tensors.append(TF.normalize(TF.to_tensor(img), [0.5, 0.5, 0.5], [0.5, 0.5, 0.5])) # [3,H,W] in [-1,1]
texts.append(r["text"])
return _pad_batch(tensors), texts
def render_synthetic(
words: tuple[str, ...], height: int = 64, font: str | None = None
) -> tuple[torch.Tensor, list[str]]:
"""Render sample Swedish words to ``[B,3,H,W]`` in ``[-1,1]`` (a data-free gate self-test)."""
size = int(height * 0.6)
fnt = ImageFont.truetype(font, size) if font else ImageFont.load_default(size)
tensors = []
for word in words:
w = max(height, int(len(word) * size * 0.6))
img = Image.new("RGB", (w, height), "white")
ImageDraw.Draw(img).text((4, (height - size) // 2), word, fill="black", font=fnt)
tensors.append(TF.normalize(TF.to_tensor(img), [0.5, 0.5, 0.5], [0.5, 0.5, 0.5]))
return _pad_batch(tensors), list(words)
def _save_pairs(originals: torch.Tensor, recon: torch.Tensor, save_dir: str, k: int = 8) -> None:
"""Write original vs round-trip PNGs (stacked vertically) for the first ``k`` lines."""
out = Path(save_dir)
out.mkdir(parents=True, exist_ok=True)
for i in range(min(k, originals.shape[0])):
pair = torch.cat([originals[i], recon[i]], dim=1) # stack original over recon -> [3,2H,W]
TF.to_pil_image(((pair.clamp(-1, 1) + 1) / 2).cpu()).save(out / f"pair_{i:02d}.png")
def main() -> None:
ap = argparse.ArgumentParser(description="Stage-0 VAE diacritic gate (round-trip recon CER).")
src = ap.add_mutually_exclusive_group(required=True)
src.add_argument("--manifest", help="jsonl of {image, text} real lines")
src.add_argument("--synthetic", action="store_true", help="render sample words instead (no data needed)")
ap.add_argument("--n", type=int, default=16, help="number of lines to test")
ap.add_argument("--htr", default="Riksarkivet/trocr-large-handwritten-hist-swe-3-char")
ap.add_argument("--font", default=None, help="TTF for --synthetic (default: PIL built-in)")
ap.add_argument("--save-dir", default=None, help="write original-vs-recon PNG pairs here")
ap.add_argument("--batch-size", type=int, default=8, help="VAE round-trip chunk size (bounds GPU memory)")
ap.add_argument("--track", action="store_true", help="log the Stage-0 gate result to trackio")
args = ap.parse_args()
cfg = Config()
device = "cuda" if torch.cuda.is_available() else "cpu"
if args.synthetic:
images, texts = render_synthetic(
_SAMPLE_WORDS[: args.n] or _SAMPLE_WORDS, cfg.data.line_height, args.font
)
else:
images, texts = load_real_lines(args.manifest, args.n, cfg)
images = images.to(device)
vae = VAEWrapper(cfg.vae).to(device).eval()
recognizer = HTRRecognizer(args.htr, device=device)
result = vae.reconstruction_cer_gate(images, texts, recognizer, batch_size=args.batch_size)
print(
f"raw CER {result['raw_cer']:.3f} | recon CER {result['recon_cer']:.3f} | "
f"gap {result['recon_cer'] - result['raw_cer']:+.3f} (threshold +{cfg.vae.recon_cer_gate})"
)
print(
"GATE:", "PASS — VAE preserves the text" if result["passed"] else "FAIL — fine-tune the VAE decoder"
)
if args.track: # record the gate alongside Stage-1/2 runs so trackio holds all three stages
import trackio
trackio.init(project="diffu", config={"stage": "stage0", "n": args.n, "htr": args.htr})
trackio.log(
{
"raw_cer": float(result["raw_cer"]),
"recon_cer": float(result["recon_cer"]),
"gap": float(result["recon_cer"]) - float(result["raw_cer"]),
"passed": int(bool(result["passed"])),
}
)
trackio.finish()
print("logged Stage-0 gate to trackio (project diffu, stage=stage0)")
if args.save_dir: # only the first 8 lines are saved, so don't re-decode all of them (OOM)
with torch.no_grad():
recon = vae.decode(vae.encode(images[:8]))
_save_pairs(images[:8], recon, args.save_dir)
print(f"wrote visual pairs -> {args.save_dir}")
if __name__ == "__main__":
main()
|