Gabriel's picture
Diffu Studio ZeroGPU Space: app.py + vendored packages + aokit AoT + redesigned panel
333ff0e verified
Raw
History Blame Contribute Delete
14.8 kB
"""``diffu-page`` — synthesize full document pages from generated (or placeholder) lines.
# placeholder ink (no model/GPU) — proves the layout + compositor:
diffu-page --template multi_column --n 3
# the whole run from a YAML config (page geometry + renderer + every scan knob):
diffu-page --config run.yaml
# real handwriting via a Diffu checkpoint (batched), overriding a couple of fields:
diffu-page --renderer diffu --ckpt run/final/model.safetensors --style ref_line.png --bed-prob 0.5
The run is a :class:`~diffu_page.config.GenConfig`; ``--config`` loads it from YAML and the flags below
override common fields (CLI > YAML > default). Each page writes ``page_XXXX.png`` + one label file per
format (json / page / alto).
"""
import random
from collections.abc import Callable
from pathlib import Path
import cv2
import numpy as np
import typer
from rich.console import Console
from .compose import compose_page, compose_page_from_inks
from .config import GenConfig, Renderer, Template, load_gen_config
from .dataset import CropRecord, PageRecord, assign_split, export_line_crops, write_manifest
from .labels import EXTENSIONS, LineLabel, PageLabels, export
from .layout import Layout, PageSpec, build_layout, load_xml_layout
from .render import (
BatchedDiffuRenderer,
DiffuRenderer,
ErukuRenderer,
LineRenderer,
PlaceholderRenderer,
render_layouts,
)
console = Console()
# A little public-domain-ish Swedish filler so placeholder pages look like text. Swap via --text-file.
_SAMPLE = [
"Göteborgs poliskammare den fjärde maj artonhundra åttiotvå.",
"Anhållen för fylleri och förargelseväckande beteende å allmän gata.",
"Mannen uppgav sig vara arbetare och boende i Haga församling.",
"Förd till häktet och förhörd av vakthavande konstapel om natten.",
"Smörgåsbord, kärlek och ärlighet varar längst sade den gamle mannen.",
]
def _paragraphs(text_file: Path | None) -> list[str]:
if text_file:
return [ln for ln in text_file.read_text(encoding="utf-8").splitlines() if ln.strip()]
return _SAMPLE * 12 # repeat so columns/tables fill
# Plausible archival furniture, auto-generated per page when GenConfig.furniture_prob fires. The exact
# wording is irrelevant to the labels — what matters is that the page carries a header / marginalia /
# page number, like a real protocol, instead of being a bare body.
_HEADERS = [
["Göteborgs Poliskammare.", "Protokoll d. {d} Maj 1882."],
["Rådhusrätten i Göteborg.", "d. {d} Mars 1884."],
["Kongl. Maj:ts Befallningshafvande."],
["Till Magistraten.", "Inkommet d. {d} Juni."],
]
_MARGINALIA = [
"Fylleri.",
"Erkännande.",
"Vittnen.",
"Till domstol.",
"Anhållan.",
"Utslag.",
"Käranden.",
"Svaranden.",
"Afslag.",
"Bifall.",
"Böter.",
"Frikänd.",
]
def _furniture(rng: random.Random) -> tuple[list[str], list[str], str]:
"""Auto-generate a header block, a few marginalia notes and a page number for one page."""
header = [h.replace("{d}", str(rng.randint(1, 28))) for h in rng.choice(_HEADERS)]
marginalia = rng.sample(_MARGINALIA, k=rng.randint(2, 4))
return header, marginalia, f"{rng.randint(1, 400)}."
def _with_overrides(cfg: GenConfig, **flags: object) -> GenConfig:
"""Apply the CLI flags that were actually set (non-None) over ``cfg`` — CLI > YAML > default."""
aug = {k: flags.pop(k) for k in ("bed_prob", "clasp_prob") if flags.get(k) is not None}
top = {k: v for k, v in flags.items() if v is not None}
if aug:
top["augment"] = cfg.augment.model_copy(update=aug)
return cfg.model_copy(update=top)
def _build_renderer(cfg: GenConfig) -> LineRenderer | BatchedDiffuRenderer:
"""The renderer for ``cfg.renderer``: a batched Diffu renderer (one GPU pass for the whole run), Eruku,
or the no-model placeholder."""
if cfg.renderer is Renderer.diffu:
if not (cfg.ckpt and (cfg.style or cfg.styles)):
raise typer.BadParameter(
"renderer=diffu requires ckpt and a style/styles (in the config or via --ckpt/--style)"
)
if cfg.fast: # batched: faster for big runs, but a touch softer (wider canvas spreads strokes)
return BatchedDiffuRenderer(str(cfg.ckpt), num_steps=cfg.steps, cfg_scale=cfg.cfg_scale)
style = cfg.style or cfg.styles[0] # per-line path is single-style; the batched path mixes hands
return DiffuRenderer(str(cfg.ckpt), str(style), num_steps=cfg.steps, cfg_scale=cfg.cfg_scale)
if cfg.renderer is Renderer.eruku:
if not cfg.style:
raise typer.BadParameter("renderer=eruku requires style")
return ErukuRenderer(str(cfg.style), cfg_scale=cfg.eruku_cfg)
return PlaceholderRenderer()
def _page_box_styles(layouts: list[Layout], cfg: GenConfig, *, seed: int) -> list[list[str]]:
"""Per-box writer-style path for each page. A page samples one MAIN hand from the style pool; when the
pool has more than one, marginalia get a second hand so a page can mix writers (main scribe + annotator).
Pure + deterministic given ``seed`` so a corpus is reproducible. Empty for the no-style renderers."""
pool = [str(p) for p in cfg.styles] or ([str(cfg.style)] if cfg.style else [])
if not pool:
return [["" for _ in layout.boxes] for layout in layouts]
out: list[list[str]] = []
for i, layout in enumerate(layouts):
rng = random.Random(seed + i)
main = rng.choice(pool)
alt = rng.choice(pool) if len(pool) > 1 else main
out.append([alt if box.region == "marginalia" else main for box in layout.boxes])
return out
def _load_specs(path: Path) -> list[PageSpec]:
"""Load the FULL control surface from a JSON file — one ``PageSpec`` (object) or many (array), so an
agent or user can drive every knob (margin_keys, drop_cap, catchword, table columns, ...) without code.
Pydantic validates each; an out-of-range or unknown field fails fast with a clear error."""
import json
data = json.loads(path.read_text(encoding="utf-8"))
items = data if isinstance(data, list) else [data]
return [PageSpec.model_validate(item) for item in items]
def _build_layouts(
cfg: GenConfig,
*,
n: int,
seed: int,
paragraphs: list[str],
xml: Path | None,
measure: Callable[[str, int], int] | None,
specs: list[PageSpec] | None = None,
) -> list[Layout]:
"""Lay out all ``n`` pages up-front (so the Diffu path can batch every line of every page at once).
With ``specs`` (from ``--spec``), each page uses a full caller-supplied :class:`PageSpec` verbatim
(cycled if fewer than ``n``); otherwise a spec is built from ``cfg`` and a ``furniture_prob`` fraction of
pages get an auto-generated header + marginalia + page number.
"""
layouts: list[Layout] = []
for i in range(n):
if specs is not None: # full user/agent-authored spec — respected verbatim, no cfg furniture override
spec = specs[i % len(specs)]
layouts.append(
load_xml_layout(str(xml), spec)
if xml
else build_layout(spec, paragraphs, seed=seed + i, measure=measure)
)
continue
page_rng = random.Random(seed + i)
header: list[str] = []
marginalia: list[str] = []
page_number: str | None = None
if page_rng.random() < cfg.furniture_prob:
header, marginalia, page_number = _furniture(page_rng)
spec = PageSpec(
width=cfg.width,
height=cfg.height,
template=cfg.template.value,
n_columns=cfg.n_columns,
line_height=cfg.line_height,
line_gap=cfg.line_gap,
para_gap=cfg.para_gap,
# mix bound/unbound across the corpus; a spread has its own central gutter, not a single spine
binding=cfg.template is not Template.spread and page_rng.random() < cfg.binding_prob,
recto=page_rng.random() < 0.5, # random page side -> spine + page-no on the matching edge
binding_ratio=round(page_rng.uniform(0.025, 0.075), 3), # vary how close text runs to the gutter
header_lines=header,
marginalia=marginalia,
page_number=page_number,
insertion_prob=cfg.insertion_prob,
deletion_prob=cfg.deletion_prob,
)
layouts.append(
load_xml_layout(str(xml), spec)
if xml
else build_layout(spec, paragraphs, seed=seed + i, measure=measure)
)
return layouts
def _render_pages(
renderer: LineRenderer | BatchedDiffuRenderer,
layouts: list[Layout],
cfg: GenConfig,
*,
seed: int,
box_styles: list[list[str]],
) -> list[tuple[np.ndarray, list[LineLabel]]]:
"""Render + compose every page. The batched Diffu renderer goes through one pass over all lines (mixing
hands per ``box_styles``); the placeholder / Eruku renderers compose page-by-page."""
paper = str(cfg.paper_dir) if cfg.paper_dir else None
if isinstance(renderer, BatchedDiffuRenderer):
inks_per_page = render_layouts(renderer, layouts, box_styles)
return [
compose_page_from_inks(lay, inks, paper_dir=paper, seed=seed + i, aug=cfg.augment)
for i, (lay, inks) in enumerate(zip(layouts, inks_per_page, strict=True))
]
return [
compose_page(lay, renderer, paper_dir=paper, seed=seed + i, aug=cfg.augment)
for i, lay in enumerate(layouts)
]
def generate(
config: Path | None = typer.Option(
None, "--config", help="YAML GenConfig (full control); flags override"
),
out: Path = typer.Option(Path("out/pages"), help="output directory"),
n: int = typer.Option(4, help="number of pages"),
seed: int = typer.Option(0),
text_file: Path | None = typer.Option(None, "--text-file", help="one paragraph per line"),
xml: Path | None = typer.Option(None, help="replay layout from an ALTO/PAGE XML instead of a template"),
spec: Path | None = typer.Option(
None, "--spec", help="full PageSpec JSON (one object or an array, cycled) — the whole control surface"
),
renderer: Renderer | None = typer.Option(None, help="override: line generator"),
ckpt: Path | None = typer.Option(None, help="override: Diffu checkpoint"),
style: Path | None = typer.Option(None, help="override: writer style reference line"),
paper_dir: Path | None = typer.Option(None, "--paper-dir", help="override: blank paper scans"),
binding_prob: float | None = typer.Option(None, "--binding-prob", min=0.0, max=1.0, help="override"),
bed_prob: float | None = typer.Option(
None, "--bed-prob", min=0.0, max=1.0, help="override: scanner-bed fraction"
),
clasp_prob: float | None = typer.Option(
None, "--clasp-prob", min=0.0, max=1.0, help="override: clasp fraction"
),
fast: bool | None = typer.Option(
None, "--fast/--no-fast", help="override: batched diffu rendering (faster, slightly softer strokes)"
),
furniture_prob: float | None = typer.Option(
None, "--furniture-prob", min=0.0, max=1.0, help="override: pages with header + marginalia + page no."
),
crops: bool | None = typer.Option(
None, "--crops/--no-crops", help="override: also export per-line image+text crops + lines.jsonl"
),
width: int | None = typer.Option(None, help="override: page width in px"),
height: int | None = typer.Option(None, help="override: page height in px"),
template: Template | None = typer.Option(
None, help="override: layout (single_column / multi_column / semi_table / columns / key_value)"
),
) -> None:
"""Synthesize full handwritten document pages + ground-truth labels.
Pass ``--config run.yaml`` for full control over geometry, renderer and every scan knob; the flags
above override common fields (CLI > YAML > default).
"""
cfg = _with_overrides(
load_gen_config(config),
renderer=renderer,
ckpt=ckpt,
style=style,
paper_dir=paper_dir,
binding_prob=binding_prob,
bed_prob=bed_prob,
clasp_prob=clasp_prob,
fast=fast,
furniture_prob=furniture_prob,
crop_lines=crops,
width=width,
height=height,
template=template,
)
out.mkdir(parents=True, exist_ok=True)
line_renderer = _build_renderer(cfg)
measure = getattr(line_renderer, "measure_width", None) # wrap by real width when available
specs = _load_specs(spec) if spec else None # --spec: the full control surface, verbatim per page
layouts = _build_layouts(
cfg, n=n, seed=seed, paragraphs=_paragraphs(text_file), xml=xml, measure=measure, specs=specs
)
box_styles = _page_box_styles(layouts, cfg, seed=seed)
pages: list[PageRecord] = []
crops: list[CropRecord] = []
rendered = _render_pages(line_renderer, layouts, cfg, seed=seed, box_styles=box_styles)
for i, (page, lines) in enumerate(rendered):
name = f"page_{i:04d}"
split = assign_split(i, val_frac=cfg.val_frac, test_frac=cfg.test_frac)
ph, pw = page.shape[:2] # real shape (xml replay / bed mount can differ from the requested size)
cv2.imwrite(str(out / f"{name}.png"), page)
labels = PageLabels(width=pw, height=ph, image=f"{name}.png", lines=lines)
for f in cfg.formats:
(out / f"{name}{EXTENSIONS[f.value]}").write_text(export(labels, f.value), encoding="utf-8")
if cfg.crop_lines:
crops.extend(export_line_crops(page, lines, out_dir=out, page_name=name, split=split))
page_spec = layouts[i].spec
pages.append(
PageRecord(
image=f"{name}.png",
width=pw,
height=ph,
template=page_spec.template, # the page's real template (may come from --spec, not cfg)
seed=seed + i,
binding=page_spec.binding,
recto=page_spec.recto,
n_lines=len(lines),
split=split,
styles=sorted({s for s in box_styles[i] if s}),
)
)
console.print(
f"[green]✓[/green] {name}.png ({len(lines)} lines, {split}) "
f"+ {', '.join(f.value for f in cfg.formats)}"
)
write_manifest(out, pages, crops)
tail = f" + lines.jsonl ({len(crops)} crops)" if crops else ""
console.print(f"[green]✓[/green] manifest.jsonl ({len(pages)} pages){tail}")
def main() -> None:
typer.run(generate)
if __name__ == "__main__":
main()