Klaus commited on
SPRIG v0.1 — research preview release
Browse files- .gitattributes +3 -0
- DESIGN.md +105 -0
- README.md +122 -0
- config.json +23 -0
- inference.py +83 -0
- metrics.json +78 -0
- parses.png +0 -0
- requirements.txt +6 -0
- samples.jpg +3 -0
- samples_prompt_bank.jpg +3 -0
- sprig-v0.1.safetensors +3 -0
- sprig/__init__.py +0 -0
- sprig/data/__init__.py +0 -0
- sprig/data/clevr/__init__.py +0 -0
- sprig/data/clevr/prep.py +229 -0
- sprig/data/dataset.py +336 -0
- sprig/data/embed_t5.py +303 -0
- sprig/data/procgen/__init__.py +0 -0
- sprig/data/procgen/captions.py +219 -0
- sprig/data/procgen/render.py +111 -0
- sprig/data/procgen/sampler.py +476 -0
- sprig/data/procgen/vocab.py +72 -0
- sprig/data/procgen/writer.py +193 -0
- sprig/dp/__init__.py +0 -0
- sprig/dp/inside.py +604 -0
- sprig/dp/lattice.py +286 -0
- sprig/eval/__init__.py +0 -0
- sprig/eval/baseline_pixmix.py +88 -0
- sprig/eval/color_checks.py +189 -0
- sprig/eval/monitors.py +102 -0
- sprig/eval/probe.py +150 -0
- sprig/eval/prompts.py +141 -0
- sprig/eval/report.py +657 -0
- sprig/eval/tree_metrics.py +230 -0
- sprig/model/__init__.py +0 -0
- sprig/model/atlas.py +291 -0
- sprig/model/dl.py +161 -0
- sprig/model/gmt.py +139 -0
- sprig/model/sprig.py +593 -0
- texel_atlas.png +3 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
samples.jpg filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
samples_prompt_bank.jpg filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
texel_atlas.png filter=lfs diff=lfs merge=lfs -text
|
DESIGN.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPRIG v0.1 — Design & Contracts (single source of truth)
|
| 2 |
+
|
| 3 |
+
Reference docs: full architecture spec `~/workspace/SPRIG-C-architecture.md`; project plan `~/.claude/plans/ok-now-i-ltierally-calm-sifakis.md` (data/eval/infra details).
|
| 4 |
+
v0.1 = finite-lattice Stage A at 64x64. No continuous cuts, no morphogen, no occlusion masks, no DINO channel.
|
| 5 |
+
|
| 6 |
+
## 1. Repo layout & file ownership
|
| 7 |
+
|
| 8 |
+
```
|
| 9 |
+
sprig/data/procgen/{vocab,sampler,captions,render,writer}.py # owner: PROCGEN agent
|
| 10 |
+
sprig/data/{dataset,embed_t5}.py, sprig/data/clevr/prep.py # owner: DATA agent
|
| 11 |
+
sprig/dp/{lattice,inside}.py # owner: DP agent
|
| 12 |
+
sprig/model/{gmt,atlas,dl,sprig}.py # owner: MODEL agent
|
| 13 |
+
sprig/eval/{baseline_pixmix,tree_metrics,color_checks,probe,prompts,report,monitors}.py # owner: EVAL agent
|
| 14 |
+
train.py, configs/*.yaml, infra/*.sh # owner: HARNESS agent
|
| 15 |
+
tests/test_<module>_*.py # each agent writes tests for its own modules
|
| 16 |
+
```
|
| 17 |
+
Python 3.12, torch. No deps beyond: torch, numpy, pillow, transformers, sentencepiece, protobuf, tensorboard, matplotlib, pyyaml, tqdm, einops, pytest. Everything must run CPU-only for tests (no `.cuda()` hardcoded; device from config).
|
| 18 |
+
|
| 19 |
+
## 2. Global config (configs/main64.yaml values)
|
| 20 |
+
|
| 21 |
+
- Canvas 64x64 RGB. Grid stride g=8 px → 8x8 cells.
|
| 22 |
+
- S=1024 nonterminal symbols, R=64 rule components, T_v=256 texels, d=384 model width.
|
| 23 |
+
- Caption: precomputed T5-base token embeddings [L,768], L≤64.
|
| 24 |
+
- Leaf-eligible regions: both sides ≤ 16 px. Regions with any side > 16 px MUST expand. 8x8 regions MUST terminate.
|
| 25 |
+
- Batch 256. AdamW: lr 3e-4 for embedding tables (E_N, E_T, V, W, P_T, cut-type tables), 1e-4 for GMT + atlas renderer + Φ; betas (0.9, 0.95); cosine decay to 10% over 250k steps, 2k warmup; grad clip 1.0; EMA 0.9999 (eval only).
|
| 26 |
+
- Rule-logit temperature τ_ann: 2.0 → 1.0 linearly over first 50k steps (divide rule/termination logits by τ_ann).
|
| 27 |
+
- 10% of steps: caption replaced by null embedding (dataloader-level).
|
| 28 |
+
- bf16 autocast for matmuls; ALL logsumexp/log-domain accumulation in fp32. Emission DL log-scales clamped [-7, 2].
|
| 29 |
+
|
| 30 |
+
## 3. Lattice (sprig/dp/lattice.py) — DP agent
|
| 31 |
+
|
| 32 |
+
Region = axis-aligned rect with corners on the 8-px grid, i.e. cell-interval pair. All (C(9,2))²=1296 rects are regions. Precompute once (pure function of canvas/grid config, cache to `.pt`):
|
| 33 |
+
- `regions: int32 [N_reg, 4]` (x0,y0,x1,y1 in px), `region_id` lookup dict.
|
| 34 |
+
- Cuts: for each region, every interior grid line on each axis is a valid cut → children are regions. Global **cut-type vocabulary** for parameter tying: (axis ∈ {H,V}) × (relative offset bucket ∈ 7 buckets, nearest of {1/8..7/8}) = 14 cut types. Per region: `cut_list` of (cut_type_id, child_lo_id, child_hi_id). child_lo = lesser-coordinate child (left for V, top for H) — this ordering is what lets the grammar express left/right, above/below.
|
| 35 |
+
- Level order: by cell area ascending (levels 1..64). Per level: flattened index tensors `parent_ids [M]`, `cut_type [M]`, `child_lo [M]`, `child_hi [M]` for vectorized DP.
|
| 36 |
+
- `leaf_mask [N_reg]` (both sides ≤16), `must_terminate [N_reg]` (8x8), `must_expand [N_reg]` (any side >16), `phi_geom [N_reg, 64]` Fourier features of (log-area, log-aspect, center-x, center-y).
|
| 37 |
+
- Leaf shape groups: leaf regions grouped by (h,w) ∈ {8,16}² for batched emission scoring.
|
| 38 |
+
|
| 39 |
+
## 4. Model (sprig/model/) — MODEL agent
|
| 40 |
+
|
| 41 |
+
**gmt.py — GrammarModulationTransformer.** Symbol embeddings E_N [S,d] (queries; no symbol-symbol self-attention). 4 blocks: {cross-attn to projected caption tokens (768→d, key_padding_mask from emb_len), FFN d→4d→d, pre-LN}. Output H [B,S,d]. Heads:
|
| 42 |
+
- `U = H @ W_u` → p(k|A,c) logits [B,S,R].
|
| 43 |
+
- Termination: MLP([H_A ; phi_geom(r)]) → logit per (B, region, S) — computed lazily as MLP over H then dot with a geometry projection: implement as `term_logit[b,r,A] = MLP_h(H)[b,A,:] · MLP_g(phi_geom)[r,:] + bias_A` (factorized, cheap).
|
| 44 |
+
- Cut-type distribution p(s|k,c): component embeddings e_k [R,d] cross-attend once to caption → logits [B,R,14]; per region, mask to valid cut types and renormalize (log-softmax over the masked set). NOTE: multiple concrete cuts in a region can share a cut type — split p(s|k,c) mass uniformly across same-type concrete cuts (add -log(count) correction, precomputed per region).
|
| 45 |
+
- Terminal texel prior: p(T|A,c) = Σ_k p(k|A,c) softmax(P_T[k]) with static P_T [R,T_v].
|
| 46 |
+
- Children: static V, W [R,S]: p(B|k)=softmax(V[k]), p(C|k)=softmax(W[k]). (v0.1: no q(B) coupling.)
|
| 47 |
+
- Illumination field Φ(c): mean-pooled caption → MLP → deconv to [B,8,16,16], bilinear-resampled at leaf positions, FiLM (scale,shift per DL-mean channel group) on emission means.
|
| 48 |
+
|
| 49 |
+
**atlas.py — TexelAtlas.** E_T [T_v,d]. Renderer: per texel, cross-attn(E_T row → caption, 2 blocks width 256) → seed [256,4,4] → 2× (conv + 2x nearest-upsample) → atlas [B,T_v,40,16,16]. 40 ch = 4 DL components × (1 weight + 3 means + 3 log-scales + 3 channel-coupling coeffs). Per-texel additive **bias grid** [T_v,40,16,16] (trainable table, no renderer) — the resurrection-writable parameterization (F3.3/M1.2).
|
| 50 |
+
- Emission scoring: for each leaf shape group (h,w): resample atlas 16x16 → (h,w) via adaptive avg pool; FiLM by Φ at leaf position; score pixels under 4-comp discretized logistic with RGB coupling (means: μ_R; μ_G+α·R; μ_B+β·R+γ·G), quantized to 256 bins → `ell [B, n_leaf_in_group, T_v]` summed over pixels. fp32 result.
|
| 51 |
+
|
| 52 |
+
**dl.py** — discretized logistic mixture log-prob (PixelCNN++ style, vectorized, fp32-safe).
|
| 53 |
+
|
| 54 |
+
**sprig.py — SPRIGModel** (implements contracts):
|
| 55 |
+
- `log_marginal(image_u8, emb, emb_len) -> logZ [B]` — full inside DP (tempered during training via `self.eta`; an `eta=0` flag for reported numbers).
|
| 56 |
+
- `loss(batch) -> (loss, metrics_dict)` — see §6.
|
| 57 |
+
- `map_parse(image, emb, emb_len) -> list[ParseNode]` — Viterbi (max-semiring) + backtrace. `ParseNode{rect, axis, cut_px, symbol, texel, children}` (dataclass in sprig/model/sprig.py; texel/leaf fields None for internal nodes).
|
| 58 |
+
- `posterior_usage(image, emb, emb_len) -> dict(symbol_usage [S], texel_usage [T_v], node_entropy, emit_mag, rule_mag, mean_depth, mean_leaves)` — expected counts via the autograd identity: make per-(region,symbol) termination potentials and per-(region,cut) potentials require grad, grad of logZ w.r.t. them = posterior marginals; node entropy = occupancy-weighted entropy of conditional split posteriors (good enough for the PI controller).
|
| 59 |
+
- `sample(emb, emb_len, seed_struct, seed_material, n) -> (images_u8 [n,64,64,3], trees)` — ancestral, breadth-parallel over the frontier; two `torch.Generator`s: structural draws (term/k/s/B/C) from seed_struct, material draws (texel choice + pixel sampling; pixels = DL means for v0.1 crispness, texel choice sampled) from seed_material. Best-of-K: `sample_bestof(emb, K)` reranks K derivations by joint log p(τ, x̂|c).
|
| 60 |
+
|
| 61 |
+
## 5. Inside DP (sprig/dp/inside.py) — DP agent
|
| 62 |
+
|
| 63 |
+
`inside(ell_leaf, term_logits, cut_logits, U_logmix, logV, logW, lattice, temper_kappa) -> beta [B,N_reg,S] fp32, logZ [B]`
|
| 64 |
+
- β(r,A) for leaf-eligible r: logaddexp( log p_term + logsumexp_T(log p(T|A,c) + ell(r,T)/κ(r)), log(1−p_term) + expand_term(r,A) ) with must_terminate/must_expand masks (∓inf).
|
| 65 |
+
- expand_term via level-synchronous sweep, area ascending. Per level, with index tensors:
|
| 66 |
+
`Bhat[m,k] = logbmm(beta[:, child_lo[m], :], logV.T)`; same for Chat with logW; `comb[m,k] = logsumexp over cuts grouped by parent of (log p(s=cut_type|k,c) − log count_correction + Bhat + Chat)`; `expand[parent,A] = logbmm(comb, U_log.T)` where U_log = log-softmax(U/τ_ann).
|
| 67 |
+
Group-by-parent logsumexp via `torch.segment_reduce` or index_put with scatter-logsumexp helper (write one: max-shift per segment, scatter_add of exp, log). All fp32.
|
| 68 |
+
- `logbmm(x_log [.., K], w_log [K, M])`: max-shifted: `x_log.max(-1)` shift, exp, matmul (bf16 ok), log, add shifts. Provide exact fp32 fallback for tests.
|
| 69 |
+
- Tempering: κ(r) = max(1, area_px(r)^η), η a module buffer. PI controller (in train.py, every 2k steps): target node entropy band [0.5, 3.0] nats; if H < 0.5: η += 0.05 + 0.1·(0.5−H); if H > 3.0: η −= 0.05; clamp [0, 1.5]. Anneal η→0 linearly over final 20k steps; all REPORTED bpd at η=0.
|
| 70 |
+
- Viterbi: same sweep, max instead of logsumexp, argmax backtrace tables per level.
|
| 71 |
+
- CORRECTNESS TEST (blocking): tiny lattice (16x16 canvas, 8px grid → 9 regions), tiny config (S=3, R=2, T_v=2, random weights), brute-force enumerate ALL derivation trees (recursive over cuts/symbols/texels), sum exact joint probs, compare to inside logZ within 1e-4. Also: Viterbi tree's joint prob ≤ logZ; posterior marginals from autograd sum to expected node counts.
|
| 72 |
+
|
| 73 |
+
## 6. Loss & regularizers
|
| 74 |
+
|
| 75 |
+
```
|
| 76 |
+
L = mean_b( −logZ_tempered / (3·64·64) ) # nats/subpixel
|
| 77 |
+
+ 1.0 · Σ_T max(0, 1/(4·T_v) − texel_usage_T) # texel under-use hinge (F3.2)
|
| 78 |
+
+ 0.5 · Σ_A max(0, 1/(4·S) − symbol_usage_A) # symbol under-use hinge (M1-style)
|
| 79 |
+
```
|
| 80 |
+
usage vectors = batch-mean posterior expected counts, normalized to sum 1 (from `posterior_usage` computed on the training batch — reuse the same graph's grads: compute via one extra backward-free trick or just take grads of logZ before the optimizer backward; simplest correct: compute usage with `torch.autograd.grad(logZ.sum(), potentials, retain_graph=True)`).
|
| 81 |
+
InfoNCE caption-contrast: DEFERRED; add only if caption-swap margin is flat by 50k steps.
|
| 82 |
+
Dead-texel resurrection (train.py, every 2k steps): texels with usage < 0.1/T_v → overwrite bias grid with (a random training-image 16x16 crop converted to DL-mean params, small noise on other channels), perturb E_T row ±ε. Log resurrection count.
|
| 83 |
+
|
| 84 |
+
## 7. Training curriculum (procedural main run)
|
| 85 |
+
|
| 86 |
+
- Steps 0–200k on proc2d with tier reweighting: 0–20k tiers (0:.55,1:.35,2:.10,3:0); 20k–60k (.25,.35,.30,.10); >60k (.10,.30,.40,.20) [target mix].
|
| 87 |
+
- η per PI controller; τ_ann 2→1 over 50k; report-eta-0 bpd from 180k; final 20k η→0 anneal.
|
| 88 |
+
- CLEVR fine-tune (separate config): init from main ckpt, lr×0.3, 30–50k steps, 20% proc2d replay.
|
| 89 |
+
- Smoke config (configs/smoke.yaml): S=128, R=16, T_v=32, d=128, batch 32, 200 steps, val_fast 64 — must run CPU-only on Mac AND on klaus-1 GPU.
|
| 90 |
+
|
| 91 |
+
## 8. Contracts (C1–C4) — binding for all agents
|
| 92 |
+
|
| 93 |
+
**C1 batch** (from `sprig/data/dataset.py` collate): `{image: u8 [B,64,64,3], emb: f16 [B,L,768], emb_len: i32 [B], tier: i8 [B], idx: i64 [B]}`. Null-caption substitution (10%, train only) in the dataset/loader using `~/data/sprig/t5/null.f16`.
|
| 94 |
+
**C2** `model.log_marginal(image, emb, emb_len) -> logZ [B]` (η=0 honored when `model.eval()` + `report_mode=True`).
|
| 95 |
+
**C3** `model.map_parse(...)`, `model.posterior_usage(...)` as §4.
|
| 96 |
+
**C4** `model.sample(emb, emb_len, seed_struct, seed_material, n)`, `model.sample_bestof(emb, emb_len, K, seed)`.
|
| 97 |
+
Data formats, eval metrics, prompt bank, run infra: per the plan file §Part 1/3/4 (memmaps, `meta.jsonl` GT-tree schema, 32-prompt bank, `scalars.jsonl`, atomic checkpoints, run_forever.sh) — copy schemas exactly from there.
|
| 98 |
+
|
| 99 |
+
## 9. Milestone gates (in order, each blocking)
|
| 100 |
+
|
| 101 |
+
- **G0**: full pytest suite green on Mac CPU (incl. DP brute-force test, holdout scan test, determinism hashes, tree-metric self-test).
|
| 102 |
+
- **G1**: overfit 1 image (script `scripts/overfit1.py`): 2k steps tiny config → bpd < 1.0, MAP parse stable across steps.
|
| 103 |
+
- **G2**: overfit 100 images with captions: Δ_c > 0 (caption info gain), texels alive > 50%.
|
| 104 |
+
- **G3**: klaus-1 smoke (30 min real config): >2 steps/s at batch 256 (else profile before main run), dataloader ≥95% synthetic throughput, ckpt kill/resume works, eval callback produces grids/overlays.
|
| 105 |
+
- **G4**: main run launch. Twice-daily monitor checks per plan alarms.
|
README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- text-to-image
|
| 5 |
+
- scene-grammar
|
| 6 |
+
- probabilistic-grammar
|
| 7 |
+
- research-preview
|
| 8 |
+
- novel-architecture
|
| 9 |
+
library_name: sprig
|
| 10 |
+
pipeline_tag: text-to-image
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# SPRIG v0.1 — a text-to-image model where images are *derived*, not denoised
|
| 14 |
+
|
| 15 |
+
**Research preview.** SPRIG (Stochastic Production-Rule Image Grammar) is a
|
| 16 |
+
from-scratch generative architecture that is **not** a diffusion model, not
|
| 17 |
+
autoregressive, not a GAN, not a VAE. A caption modulates the production
|
| 18 |
+
probabilities of a learned **probabilistic scene grammar**; an image is produced
|
| 19 |
+
by a single top-down **derivation** that recursively splits the canvas into
|
| 20 |
+
typed regions, each painted by a learned "texel" material. Training is **exact
|
| 21 |
+
maximum likelihood** — the marginal over *all* derivation trees, computed by an
|
| 22 |
+
inside dynamic program (log-semiring DP). No noise process, no adversary, no
|
| 23 |
+
ELBO, no token ordering.
|
| 24 |
+
|
| 25 |
+
This is **v0.1 at 64×64**: a proof-of-concept for the mechanism. It is honest
|
| 26 |
+
about what works and what does not (see the scorecard). ~16M trainable
|
| 27 |
+
parameters on top of a frozen T5-base caption encoder.
|
| 28 |
+
|
| 29 |
+
<p align="center"><img src="samples.jpg" width="360" alt="SPRIG v0.1 samples"></p>
|
| 30 |
+
|
| 31 |
+
## What SPRIG does differently
|
| 32 |
+
|
| 33 |
+
| | Diffusion / Flow | Autoregressive | **SPRIG** |
|
| 34 |
+
|---|---|---|---|
|
| 35 |
+
| Generative act | denoise a fixed grid over many steps | predict tokens in an order | **derive a tree**: recursively split the canvas, commit each node once |
|
| 36 |
+
| Latent | noisy image | token prefix | an *unobserved random tree* summed out |
|
| 37 |
+
| Training | denoising / score matching | next-token likelihood | **exact marginal likelihood** via inside DP |
|
| 38 |
+
| Free bonus | — | — | a real **likelihood** + an interpretable **parse** of any image |
|
| 39 |
+
|
| 40 |
+
Because analysis and synthesis are the *same* grammar run in two directions, the
|
| 41 |
+
model can also **parse** a real image (infer its most likely derivation) — see
|
| 42 |
+
`parses.png`. This is the strongest, most novel capability and it works well.
|
| 43 |
+
|
| 44 |
+
## Honest scorecard (v0.1, 50k steps, held-out procedural scenes)
|
| 45 |
+
|
| 46 |
+
Success criteria were fixed in advance. **Passes 1 of 5** — but the failures are
|
| 47 |
+
localized and understood, not diffuse.
|
| 48 |
+
|
| 49 |
+
| Gate | Target | Result | |
|
| 50 |
+
|---|---|---|---|
|
| 51 |
+
| Likelihood vs. no-grammar baseline | beat by ≥0.15 bpd | **2.66 vs 6.28 bpd** | ✅ crushes it |
|
| 52 |
+
| Caption information gain Δc | ≥ 0.05 | **0.248** | ✅ 5× |
|
| 53 |
+
| Visible-cut parse F1 | ≥ 0.6 | **0.765** | ✅ parsing works |
|
| 54 |
+
| Object-cell parse recall (tier1/2) | ≥ 0.70 / 0.50 | 0.20 / 0.22 | ❌ scenes too busy |
|
| 55 |
+
| Prompt-swap attribute control | ≥ 0.80 | 0.37 | ❌ partial |
|
| 56 |
+
| — size attribute specifically | — | **1.00** | ✅ size binds perfectly |
|
| 57 |
+
| Spatial-relation accuracy | ≥ 0.70 | 0.00 | ❌ |
|
| 58 |
+
| Compositional holdout (unseen combos) | ≥ 0.60 | 0.01 | ❌ |
|
| 59 |
+
| Grammar health (S_eff / alive texels) | ≥256 / ≥50% | 968 / 43% | ⚠️ texels over-pruned |
|
| 60 |
+
|
| 61 |
+
**What this means.** The architecture's structural claims are proven: it models
|
| 62 |
+
data far better than a no-grammar baseline, routes caption information, recovers
|
| 63 |
+
scene structure by parsing, and (after a targeted fix) paints real objects. The
|
| 64 |
+
open problem is **caption→object binding**: the model can draw objects and binds
|
| 65 |
+
*size* perfectly, but does not yet reliably paint the *specific* object a prompt
|
| 66 |
+
asks for, and places too many per scene. That is a conditioning/architecture
|
| 67 |
+
issue targeted by v0.2 — not a matter of more training. The full story
|
| 68 |
+
(including a failure diagnosis that traced an earlier version's blank object
|
| 69 |
+
vocabulary to an emission-weighting/tempering interaction) is in the code repo.
|
| 70 |
+
|
| 71 |
+
## Usage
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
pip install torch safetensors transformers pillow
|
| 75 |
+
# get the `sprig` package + this file from the code repo, then:
|
| 76 |
+
python inference.py --prompt "a red circle on a white background" --out out.png
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
```python
|
| 80 |
+
from inference import load_sprig, sample
|
| 81 |
+
model = load_sprig("sprig-v0.1.safetensors", "config.json") # ~16M params, CPU-friendly
|
| 82 |
+
img = sample(model, "a green triangle", seed=0) # PIL.Image, 64x64
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
The model outputs native **64×64** images (upscale with nearest-neighbor to
|
| 86 |
+
view). It also returns the derivation tree, so you can inspect *why* each region
|
| 87 |
+
was drawn.
|
| 88 |
+
|
| 89 |
+
## Files
|
| 90 |
+
|
| 91 |
+
- `sprig-v0.1.safetensors` — EMA-merged inference weights (60.8 MB, fp32, 15.9M params)
|
| 92 |
+
- `config.json` — architecture config + release metadata
|
| 93 |
+
- `inference.py` — minimal load + sample + T5 caption encoding
|
| 94 |
+
- `metrics.json` — full evaluation numbers
|
| 95 |
+
- `samples.jpg`, `texel_atlas.png`, `parses.png` — qualitative outputs
|
| 96 |
+
- `DESIGN.md` — the concrete v0.1 architecture specification
|
| 97 |
+
|
| 98 |
+
## Training
|
| 99 |
+
|
| 100 |
+
64×64, 2M procedural compositional scenes (colored shapes with attributes and
|
| 101 |
+
spatial relations, templated dense captions, held-out attribute combinations),
|
| 102 |
+
frozen T5-base captions precomputed. Exact-likelihood objective + closed-form
|
| 103 |
+
grammar-health regularizers. One RTX PRO 6000 Blackwell GPU, ~50k steps.
|
| 104 |
+
Generator: see the companion dataset repo (seeded, deterministic).
|
| 105 |
+
|
| 106 |
+
## Limitations & intended use
|
| 107 |
+
|
| 108 |
+
Research artifact for studying grammar-based generation and exact-likelihood
|
| 109 |
+
text-to-image. **Not** a production image generator: 64×64, synthetic domain,
|
| 110 |
+
object binding incomplete. Samples are blocky by construction (axis-aligned
|
| 111 |
+
region splits). MIT licensed — build on it.
|
| 112 |
+
|
| 113 |
+
## Citation
|
| 114 |
+
|
| 115 |
+
```bibtex
|
| 116 |
+
@software{sprig_v0_1_2026,
|
| 117 |
+
title = {SPRIG: Text-to-Image by a Stochastic Production-Rule Image Grammar (v0.1)},
|
| 118 |
+
year = {2026},
|
| 119 |
+
note = {Research preview. Images are derived by a probabilistic scene grammar
|
| 120 |
+
trained by exact marginal likelihood, not denoised.}
|
| 121 |
+
}
|
| 122 |
+
```
|
config.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architecture": "SPRIG",
|
| 3 |
+
"version": "0.1",
|
| 4 |
+
"weights_file": "sprig-v0.1.safetensors",
|
| 5 |
+
"weights_merged": "ema",
|
| 6 |
+
"trained_steps": 50000,
|
| 7 |
+
"canvas_px": 64,
|
| 8 |
+
"grid_px": 8,
|
| 9 |
+
"model": {
|
| 10 |
+
"S": 1024,
|
| 11 |
+
"R": 64,
|
| 12 |
+
"T_v": 256,
|
| 13 |
+
"d": 384,
|
| 14 |
+
"canvas": 64,
|
| 15 |
+
"grid": 8,
|
| 16 |
+
"emb_dim": 768,
|
| 17 |
+
"L_max": 64,
|
| 18 |
+
"leaf_chunk": 8,
|
| 19 |
+
"texel_hinge_weight": 0.5,
|
| 20 |
+
"emission_obj_weight": 12.0
|
| 21 |
+
},
|
| 22 |
+
"note": "Inference-only export: EMA-merged weights, buffers eta/tau are re-initialised at load (tau=1.0, eta=0.0)."
|
| 23 |
+
}
|
inference.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal inference for SPRIG v0.1 — load the released safetensors and sample.
|
| 2 |
+
|
| 3 |
+
Requires the `sprig` package (https://github.com/ -- or the code repo bundled
|
| 4 |
+
with this release) plus torch, safetensors, transformers (for the T5 caption
|
| 5 |
+
encoder). The model itself is ~16M params and runs on CPU.
|
| 6 |
+
|
| 7 |
+
python inference.py --weights sprig-v0.1.safetensors --config config.json \
|
| 8 |
+
--prompt "a red circle on a white background" --out out.png
|
| 9 |
+
|
| 10 |
+
Programmatic:
|
| 11 |
+
|
| 12 |
+
from inference import load_sprig, sample
|
| 13 |
+
model = load_sprig("sprig-v0.1.safetensors", "config.json")
|
| 14 |
+
img = sample(model, "a green triangle", seed=0) # PIL.Image, 64x64
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import json
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
from PIL import Image
|
| 24 |
+
from safetensors.torch import load_file
|
| 25 |
+
|
| 26 |
+
_T5 = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _t5_embed(prompt: str, device: str = "cpu"):
|
| 30 |
+
"""Encode a caption with frozen T5-base -> (emb [1,L,768] f16, len [1] i32)."""
|
| 31 |
+
global _T5
|
| 32 |
+
if _T5 is None:
|
| 33 |
+
from transformers import T5EncoderModel, T5TokenizerFast
|
| 34 |
+
tok = T5TokenizerFast.from_pretrained("google-t5/t5-base")
|
| 35 |
+
enc = T5EncoderModel.from_pretrained("google-t5/t5-base").eval().to(device)
|
| 36 |
+
_T5 = (tok, enc)
|
| 37 |
+
tok, enc = _T5
|
| 38 |
+
ids = tok(prompt, return_tensors="pt", truncation=True, max_length=64).to(device)
|
| 39 |
+
with torch.no_grad():
|
| 40 |
+
h = enc(**ids).last_hidden_state # [1, L, 768]
|
| 41 |
+
n = int(ids["attention_mask"].sum())
|
| 42 |
+
return h[:, :n].to(torch.float16), torch.tensor([n], dtype=torch.int32, device=device)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def load_sprig(weights: str, config: str, device: str = "cpu"):
|
| 46 |
+
from sprig.model.sprig import SPRIGModel, SPRIGConfig
|
| 47 |
+
meta = json.loads(Path(config).read_text())
|
| 48 |
+
fields = set(SPRIGConfig.__dataclass_fields__)
|
| 49 |
+
cfg = SPRIGConfig(**{k: v for k, v in meta.get("model", {}).items() if k in fields})
|
| 50 |
+
model = SPRIGModel(cfg)
|
| 51 |
+
model.load_state_dict(load_file(weights), strict=False)
|
| 52 |
+
model.tau.fill_(1.0) # deployment temperature
|
| 53 |
+
model.eta.fill_(0.0) # untempered (exact) emissions
|
| 54 |
+
return model.eval().to(device)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def sample(model, prompt: str, seed: int = 0, device: str = "cpu") -> Image.Image:
|
| 58 |
+
emb, ln = _t5_embed(prompt, device)
|
| 59 |
+
with torch.no_grad():
|
| 60 |
+
imgs, _trees = model.sample(emb, ln, seed_struct=seed, seed_material=seed, n=1)
|
| 61 |
+
return Image.fromarray(imgs[0].cpu().numpy().astype("uint8"))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def main() -> None:
|
| 65 |
+
ap = argparse.ArgumentParser()
|
| 66 |
+
ap.add_argument("--weights", default="sprig-v0.1.safetensors")
|
| 67 |
+
ap.add_argument("--config", default="config.json")
|
| 68 |
+
ap.add_argument("--prompt", required=True)
|
| 69 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 70 |
+
ap.add_argument("--out", default="out.png")
|
| 71 |
+
ap.add_argument("--device", default="cpu")
|
| 72 |
+
ap.add_argument("--upscale", type=int, default=6)
|
| 73 |
+
args = ap.parse_args()
|
| 74 |
+
model = load_sprig(args.weights, args.config, args.device)
|
| 75 |
+
img = sample(model, args.prompt, args.seed, args.device)
|
| 76 |
+
if args.upscale > 1:
|
| 77 |
+
img = img.resize((64 * args.upscale, 64 * args.upscale), Image.NEAREST)
|
| 78 |
+
img.save(args.out)
|
| 79 |
+
print("saved", args.out)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
main()
|
metrics.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"metrics": {
|
| 3 |
+
"bpd_val": 2.6107561223037,
|
| 4 |
+
"bpd_per_tier": {
|
| 5 |
+
"0": 2.2038232899707055,
|
| 6 |
+
"1": 2.3735981695328294,
|
| 7 |
+
"2": 2.6973096096747566,
|
| 8 |
+
"3": 3.006890315533517
|
| 9 |
+
},
|
| 10 |
+
"bpd_tier_ge1": 2.656977891677827,
|
| 11 |
+
"n": 2000,
|
| 12 |
+
"delta_c": 0.24781480995112007,
|
| 13 |
+
"caption_swap_win_frac": 0.25,
|
| 14 |
+
"b0_bpd": 6.28435796590519,
|
| 15 |
+
"tree": {
|
| 16 |
+
"visible_cut_f1": 0.7648812746568768,
|
| 17 |
+
"leaf_ari": 0.5688396790527814,
|
| 18 |
+
"recall_per_tier": {
|
| 19 |
+
"0": 0.19008264462809918,
|
| 20 |
+
"3": 0.4357142857142857,
|
| 21 |
+
"1": 0.20238095238095238,
|
| 22 |
+
"2": 0.22413333333333335
|
| 23 |
+
},
|
| 24 |
+
"recall_tier1": 0.20238095238095238,
|
| 25 |
+
"recall_tier2": 0.22413333333333335
|
| 26 |
+
},
|
| 27 |
+
"prompt_swap": {
|
| 28 |
+
"per_pair": {
|
| 29 |
+
"a red circle -> a blue circle": 0.0625,
|
| 30 |
+
"a green square -> a purple square": 0.015625,
|
| 31 |
+
"a yellow diamond -> a cyan diamond": 0.921875,
|
| 32 |
+
"an orange ring -> a magenta ring": 0.078125,
|
| 33 |
+
"a red circle to the left of a blue square -> a red circle to the right of a blue square": 0.0,
|
| 34 |
+
"a green circle above a yellow square -> a green circle below a yellow square": 0.0,
|
| 35 |
+
"a small purple square -> a large purple square": 1.0,
|
| 36 |
+
"a red circle -> a red square": 0.15625
|
| 37 |
+
},
|
| 38 |
+
"attribute_move": 0.3723958333333333,
|
| 39 |
+
"relation_accuracy": 0.0
|
| 40 |
+
},
|
| 41 |
+
"holdout_probe_acc": 0.01171875,
|
| 42 |
+
"heldout_per_combo": {
|
| 43 |
+
"a blue triangle": 0.03125,
|
| 44 |
+
"a red ring": 0.015625,
|
| 45 |
+
"a green star": 0.0,
|
| 46 |
+
"a yellow cross": 0.0
|
| 47 |
+
},
|
| 48 |
+
"health": {
|
| 49 |
+
"S": 1024,
|
| 50 |
+
"T_v": 256,
|
| 51 |
+
"s_eff": 968.4937738012005,
|
| 52 |
+
"alive_texel_frac": 0.4296875,
|
| 53 |
+
"node_entropy": 0.13297630846500397
|
| 54 |
+
}
|
| 55 |
+
},
|
| 56 |
+
"gates": {
|
| 57 |
+
"1_likelihood": {
|
| 58 |
+
"status": "PASS",
|
| 59 |
+
"detail": "bpd(tier>=1)=2.657 vs B0=6.284 (need margin >= 0.15); delta_c=0.248 (need >= 0.05)"
|
| 60 |
+
},
|
| 61 |
+
"2_parses": {
|
| 62 |
+
"status": "FAIL",
|
| 63 |
+
"detail": "recall t1=0.20 (>= 0.7), t2=0.22 (>= 0.5), cut F1=0.76 (>= 0.6)"
|
| 64 |
+
},
|
| 65 |
+
"3_prompt_control": {
|
| 66 |
+
"status": "FAIL",
|
| 67 |
+
"detail": "attribute-move=0.37 (>= 0.8), relation=0.00 (>= 0.7)"
|
| 68 |
+
},
|
| 69 |
+
"4_compositional": {
|
| 70 |
+
"status": "FAIL",
|
| 71 |
+
"detail": "held-out combo probe acc=0.01 (>= 0.6)"
|
| 72 |
+
},
|
| 73 |
+
"5_health": {
|
| 74 |
+
"status": "FAIL",
|
| 75 |
+
"detail": "S_eff=968 (>= 256), alive texels=43% (>= 50%)"
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
}
|
parses.png
ADDED
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch
|
| 2 |
+
safetensors
|
| 3 |
+
transformers
|
| 4 |
+
pillow
|
| 5 |
+
numpy
|
| 6 |
+
einops
|
samples.jpg
ADDED
|
Git LFS Details
|
samples_prompt_bank.jpg
ADDED
|
Git LFS Details
|
sprig-v0.1.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b7ec1c3e062a9769be6dc0d28a2e078b2cf30bc2a4f351bc350a0d191a30a5f0
|
| 3 |
+
size 63701808
|
sprig/__init__.py
ADDED
|
File without changes
|
sprig/data/__init__.py
ADDED
|
File without changes
|
sprig/data/clevr/__init__.py
ADDED
|
File without changes
|
sprig/data/clevr/prep.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLEVR v1.0 -> SPRIG memmap preprocessing.
|
| 2 |
+
|
| 3 |
+
CLI:
|
| 4 |
+
|
| 5 |
+
python -m sprig.data.clevr.prep --clevr-root ~/data/CLEVR_v1.0 \
|
| 6 |
+
--split train --out ~/data/sprig/clevr/train [--limit N] [--seed 0]
|
| 7 |
+
|
| 8 |
+
Reads `<clevr-root>/scenes/CLEVR_<split>_scenes.json` and
|
| 9 |
+
`<clevr-root>/images/<split>/<image_filename>` (480x320 renders). Each image
|
| 10 |
+
is center-cropped to x in [80, 400) (full height) -> 320x320, LANCZOS-resized
|
| 11 |
+
to 64x64, and written to the standard SPRIG memmap layout (see
|
| 12 |
+
`sprig/data/dataset.py`). Objects whose `pixel_coords` x-center falls outside
|
| 13 |
+
the crop are dropped from the caption pool; the dropped fraction is logged.
|
| 14 |
+
|
| 15 |
+
Three caption variants are synthesized per image from the scene graph
|
| 16 |
+
(deterministic per (seed, idx)):
|
| 17 |
+
|
| 18 |
+
0. pairwise relation read off pixel_coords of two sampled visible objects
|
| 19 |
+
("a large red rubber cube to the left of a small blue metal sphere";
|
| 20 |
+
left/right from pixel x, in front of/behind from pixel y when the
|
| 21 |
+
x-separation is small);
|
| 22 |
+
1. partial enumeration capped at 4 objects, order shuffled
|
| 23 |
+
("a scene with a ..., a ..., a ... and a ...");
|
| 24 |
+
2. count + existence ("a scene with five objects, including a gray cube").
|
| 25 |
+
|
| 26 |
+
Output files: images.u8 [N,64,64,3], meta.jsonl (records with
|
| 27 |
+
"captions": [c0, c1, c2], visible objects, drop counts), meta_offsets.i64
|
| 28 |
+
[N+1] byte offsets, tier_idx/tier0.i64 (CLEVR has no tiers; everything is
|
| 29 |
+
tier 0). Embeddings are computed afterwards by
|
| 30 |
+
`python -m sprig.data.embed_t5 --data-dir <out>`, which detects the
|
| 31 |
+
multi-caption meta and writes emb0/1/2.f16 with their own offsets; the
|
| 32 |
+
dataset then picks one variant per visit (uniform in training).
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import argparse
|
| 38 |
+
import json
|
| 39 |
+
import os
|
| 40 |
+
from typing import Dict, List, Optional, Tuple
|
| 41 |
+
|
| 42 |
+
import numpy as np
|
| 43 |
+
from PIL import Image
|
| 44 |
+
|
| 45 |
+
CROP_X0 = 80
|
| 46 |
+
CROP_X1 = 400
|
| 47 |
+
IMG_SIZE = 64
|
| 48 |
+
N_CAPTIONS = 3
|
| 49 |
+
# Min horizontal pixel separation (in original 480-px coords) for a
|
| 50 |
+
# left/right relation; below this we use depth-axis (pixel y) instead.
|
| 51 |
+
X_REL_THRESH = 24.0
|
| 52 |
+
|
| 53 |
+
NUM_WORDS = [
|
| 54 |
+
"zero", "one", "two", "three", "four", "five",
|
| 55 |
+
"six", "seven", "eight", "nine", "ten",
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def num_word(n: int) -> str:
|
| 60 |
+
return NUM_WORDS[n] if 0 <= n < len(NUM_WORDS) else str(n)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def obj_phrase(o: Dict) -> str:
|
| 64 |
+
return "a %s %s %s %s" % (o["size"], o["color"], o["material"], o["shape"])
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def crop_resize(img: Image.Image) -> np.ndarray:
|
| 68 |
+
"""480x320 CLEVR render -> center crop x in [80,400) -> 64x64 u8 RGB."""
|
| 69 |
+
img = img.convert("RGB").crop((CROP_X0, 0, CROP_X1, img.height))
|
| 70 |
+
img = img.resize((IMG_SIZE, IMG_SIZE), Image.LANCZOS)
|
| 71 |
+
return np.asarray(img, dtype=np.uint8)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def visible_objects(scene: Dict) -> Tuple[List[Dict], int]:
|
| 75 |
+
"""Objects whose pixel-x center lies inside the crop; also #dropped."""
|
| 76 |
+
kept: List[Dict] = []
|
| 77 |
+
dropped = 0
|
| 78 |
+
for o in scene["objects"]:
|
| 79 |
+
x = float(o["pixel_coords"][0])
|
| 80 |
+
if CROP_X0 <= x < CROP_X1:
|
| 81 |
+
kept.append(o)
|
| 82 |
+
else:
|
| 83 |
+
dropped += 1
|
| 84 |
+
return kept, dropped
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _relation_caption(objects: List[Dict], rng: np.random.Generator) -> str:
|
| 88 |
+
if not objects:
|
| 89 |
+
return "an empty scene"
|
| 90 |
+
if len(objects) == 1:
|
| 91 |
+
return obj_phrase(objects[0])
|
| 92 |
+
i, j = rng.choice(len(objects), size=2, replace=False)
|
| 93 |
+
a, b = objects[int(i)], objects[int(j)]
|
| 94 |
+
ax, ay = float(a["pixel_coords"][0]), float(a["pixel_coords"][1])
|
| 95 |
+
bx, by = float(b["pixel_coords"][0]), float(b["pixel_coords"][1])
|
| 96 |
+
if abs(ax - bx) >= X_REL_THRESH:
|
| 97 |
+
rel = "to the left of" if ax < bx else "to the right of"
|
| 98 |
+
else:
|
| 99 |
+
# Larger pixel y = lower in frame = closer to the camera.
|
| 100 |
+
rel = "in front of" if ay > by else "behind"
|
| 101 |
+
return "%s %s %s" % (obj_phrase(a), rel, obj_phrase(b))
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _enumeration_caption(objects: List[Dict], rng: np.random.Generator) -> str:
|
| 105 |
+
if not objects:
|
| 106 |
+
return "an empty scene"
|
| 107 |
+
order = rng.permutation(len(objects))[:4]
|
| 108 |
+
phrases = [obj_phrase(objects[int(k)]) for k in order]
|
| 109 |
+
if len(phrases) == 1:
|
| 110 |
+
listed = phrases[0]
|
| 111 |
+
else:
|
| 112 |
+
listed = ", ".join(phrases[:-1]) + " and " + phrases[-1]
|
| 113 |
+
return "a scene with " + listed
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _count_caption(objects: List[Dict], rng: np.random.Generator) -> str:
|
| 117 |
+
n = len(objects)
|
| 118 |
+
if n == 0:
|
| 119 |
+
return "an empty scene"
|
| 120 |
+
o = objects[int(rng.integers(n))]
|
| 121 |
+
plural = "object" if n == 1 else "objects"
|
| 122 |
+
return "a scene with %s %s, including %s" % (num_word(n), plural, obj_phrase(o))
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def synth_captions(objects: List[Dict], rng: np.random.Generator) -> List[str]:
|
| 126 |
+
"""The three caption variants (relation, enumeration, count/existence)."""
|
| 127 |
+
return [
|
| 128 |
+
_relation_caption(objects, rng),
|
| 129 |
+
_enumeration_caption(objects, rng),
|
| 130 |
+
_count_caption(objects, rng),
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def prep_split(
|
| 135 |
+
clevr_root: str,
|
| 136 |
+
split: str,
|
| 137 |
+
out_dir: str,
|
| 138 |
+
limit: Optional[int] = None,
|
| 139 |
+
seed: int = 0,
|
| 140 |
+
) -> Dict[str, float]:
|
| 141 |
+
scenes_path = os.path.join(clevr_root, "scenes", "CLEVR_%s_scenes.json" % split)
|
| 142 |
+
with open(scenes_path, "r") as f:
|
| 143 |
+
scenes = json.load(f)["scenes"]
|
| 144 |
+
if limit is not None:
|
| 145 |
+
scenes = scenes[: int(limit)]
|
| 146 |
+
n = len(scenes)
|
| 147 |
+
if n == 0:
|
| 148 |
+
raise ValueError("no scenes found in %s" % scenes_path)
|
| 149 |
+
|
| 150 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 151 |
+
os.makedirs(os.path.join(out_dir, "tier_idx"), exist_ok=True)
|
| 152 |
+
|
| 153 |
+
images = np.memmap(
|
| 154 |
+
os.path.join(out_dir, "images.u8"),
|
| 155 |
+
dtype=np.uint8,
|
| 156 |
+
mode="w+",
|
| 157 |
+
shape=(n, IMG_SIZE, IMG_SIZE, 3),
|
| 158 |
+
)
|
| 159 |
+
meta_offsets = np.zeros(n + 1, dtype=np.int64)
|
| 160 |
+
total_objects = 0
|
| 161 |
+
total_dropped = 0
|
| 162 |
+
|
| 163 |
+
with open(os.path.join(out_dir, "meta.jsonl"), "wb") as meta_f:
|
| 164 |
+
for idx, scene in enumerate(scenes):
|
| 165 |
+
img_path = os.path.join(clevr_root, "images", split, scene["image_filename"])
|
| 166 |
+
with Image.open(img_path) as img:
|
| 167 |
+
images[idx] = crop_resize(img)
|
| 168 |
+
|
| 169 |
+
kept, dropped = visible_objects(scene)
|
| 170 |
+
total_objects += len(scene["objects"])
|
| 171 |
+
total_dropped += dropped
|
| 172 |
+
rng = np.random.Generator(np.random.PCG64(np.random.SeedSequence([seed, idx])))
|
| 173 |
+
captions = synth_captions(kept, rng)
|
| 174 |
+
|
| 175 |
+
record = {
|
| 176 |
+
"idx": idx,
|
| 177 |
+
"image_filename": scene["image_filename"],
|
| 178 |
+
"tier": 0,
|
| 179 |
+
"captions": captions,
|
| 180 |
+
"n_objects": len(kept),
|
| 181 |
+
"n_dropped": dropped,
|
| 182 |
+
"objects": [
|
| 183 |
+
{
|
| 184 |
+
"shape": o["shape"],
|
| 185 |
+
"color": o["color"],
|
| 186 |
+
"size": o["size"],
|
| 187 |
+
"material": o["material"],
|
| 188 |
+
"pixel_coords": o["pixel_coords"],
|
| 189 |
+
}
|
| 190 |
+
for o in kept
|
| 191 |
+
],
|
| 192 |
+
}
|
| 193 |
+
line = (json.dumps(record) + "\n").encode("utf-8")
|
| 194 |
+
meta_f.write(line)
|
| 195 |
+
meta_offsets[idx + 1] = meta_offsets[idx] + len(line)
|
| 196 |
+
|
| 197 |
+
images.flush()
|
| 198 |
+
del images
|
| 199 |
+
meta_offsets.tofile(os.path.join(out_dir, "meta_offsets.i64"))
|
| 200 |
+
np.arange(n, dtype=np.int64).tofile(os.path.join(out_dir, "tier_idx", "tier0.i64"))
|
| 201 |
+
|
| 202 |
+
drop_frac = total_dropped / max(1, total_objects)
|
| 203 |
+
stats = {
|
| 204 |
+
"n_images": float(n),
|
| 205 |
+
"total_objects": float(total_objects),
|
| 206 |
+
"total_dropped": float(total_dropped),
|
| 207 |
+
"drop_frac": drop_frac,
|
| 208 |
+
}
|
| 209 |
+
print(
|
| 210 |
+
"prep %s: %d images; dropped %d/%d objects outside crop (%.2f%%)"
|
| 211 |
+
% (split, n, total_dropped, total_objects, 100.0 * drop_frac),
|
| 212 |
+
flush=True,
|
| 213 |
+
)
|
| 214 |
+
return stats
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def main(argv: Optional[List[str]] = None) -> None:
|
| 218 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 219 |
+
ap.add_argument("--clevr-root", required=True, help="CLEVR_v1.0 directory")
|
| 220 |
+
ap.add_argument("--split", choices=["train", "val"], default="train")
|
| 221 |
+
ap.add_argument("--out", required=True, help="output dataset directory")
|
| 222 |
+
ap.add_argument("--limit", type=int, default=None)
|
| 223 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 224 |
+
args = ap.parse_args(argv)
|
| 225 |
+
prep_split(args.clevr_root, args.split, args.out, args.limit, args.seed)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
if __name__ == "__main__":
|
| 229 |
+
main()
|
sprig/data/dataset.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SPRIG dataset over precomputed memmap directories (contract C1).
|
| 2 |
+
|
| 3 |
+
On-disk layout of a dataset directory (one directory per split), written by
|
| 4 |
+
the procgen writer (`sprig/data/procgen/writer.py`) or by CLEVR prep
|
| 5 |
+
(`sprig/data/clevr/prep.py`) + `sprig/data/embed_t5.py`:
|
| 6 |
+
|
| 7 |
+
images.u8 uint8 raw memmap, [N, 64, 64, 3]
|
| 8 |
+
emb.f16 float16 packed-ragged token embeddings, [total_tokens, 768]
|
| 9 |
+
emb_offsets.i64 int64 [N+1]; caption i occupies rows offsets[i]:offsets[i+1]
|
| 10 |
+
meta.jsonl one JSON object per sample (caption(s), tier, objects, GT tree, ...)
|
| 11 |
+
meta_offsets.i64 int64 [N+1] byte offsets of line starts into meta.jsonl
|
| 12 |
+
(a trailing entry equal to the file size; an [N]-shaped
|
| 13 |
+
file of line starts is also accepted)
|
| 14 |
+
tier_idx/tier{t}.i64 int64 sample indices belonging to tier t (t = 0..3)
|
| 15 |
+
|
| 16 |
+
Multi-caption variant (CLEVR: 3 synthesized captions per image): instead of a
|
| 17 |
+
single `emb.f16`/`emb_offsets.i64` pair, the directory holds one ragged pair
|
| 18 |
+
per caption variant:
|
| 19 |
+
|
| 20 |
+
emb0.f16 / emb0_offsets.i64
|
| 21 |
+
emb1.f16 / emb1_offsets.i64
|
| 22 |
+
emb2.f16 / emb2_offsets.i64
|
| 23 |
+
|
| 24 |
+
and each meta line stores `"captions": [c0, c1, c2]`. The dataset picks a
|
| 25 |
+
variant uniformly at random per visit when `train=True`, and deterministically
|
| 26 |
+
as `(idx + epoch) % n_variants` when `train=False` (use `set_epoch` to rotate).
|
| 27 |
+
|
| 28 |
+
Batch contract C1 (produced by `collate`):
|
| 29 |
+
{image: u8 [B,64,64,3], emb: f16 [B,Lmax,768] zero-padded,
|
| 30 |
+
emb_len: i32 [B], tier: i8 [B], idx: i64 [B]}
|
| 31 |
+
|
| 32 |
+
Null-caption substitution: with probability `p_null` (train only) the caption
|
| 33 |
+
embedding is replaced by the precomputed empty-string embedding loaded from
|
| 34 |
+
`null_emb_path` (packed f16, shape [L0, 768], written by
|
| 35 |
+
`embed_t5.py --null-out`).
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
from __future__ import annotations
|
| 39 |
+
|
| 40 |
+
import json
|
| 41 |
+
import math
|
| 42 |
+
import os
|
| 43 |
+
from typing import Dict, Iterator, List, Optional, Sequence, Tuple
|
| 44 |
+
|
| 45 |
+
import numpy as np
|
| 46 |
+
import torch
|
| 47 |
+
from torch.utils.data import Dataset, Sampler
|
| 48 |
+
|
| 49 |
+
EMB_DIM = 768
|
| 50 |
+
IMG_SIZE = 64
|
| 51 |
+
MAX_TIERS = 4
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def load_null_emb(path: str) -> np.ndarray:
|
| 55 |
+
"""Load the packed-f16 null (empty caption) embedding -> [L0, 768]."""
|
| 56 |
+
arr = np.fromfile(path, dtype=np.float16)
|
| 57 |
+
if arr.size == 0 or arr.size % EMB_DIM != 0:
|
| 58 |
+
raise ValueError("null embedding file %r has invalid size %d" % (path, arr.size))
|
| 59 |
+
return arr.reshape(-1, EMB_DIM)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def load_tier_indices(root: str, n: Optional[int] = None) -> List[np.ndarray]:
|
| 63 |
+
"""Load per-tier index arrays from root/tier_idx/tier{t}.i64.
|
| 64 |
+
|
| 65 |
+
If the directory is absent, returns a single tier containing all indices
|
| 66 |
+
(requires `n`).
|
| 67 |
+
"""
|
| 68 |
+
tier_dir = os.path.join(root, "tier_idx")
|
| 69 |
+
if not os.path.isdir(tier_dir):
|
| 70 |
+
if n is None:
|
| 71 |
+
raise FileNotFoundError("no tier_idx/ in %r and n not given" % root)
|
| 72 |
+
return [np.arange(n, dtype=np.int64)]
|
| 73 |
+
tiers: List[np.ndarray] = []
|
| 74 |
+
for t in range(MAX_TIERS):
|
| 75 |
+
p = os.path.join(tier_dir, "tier%d.i64" % t)
|
| 76 |
+
if os.path.exists(p):
|
| 77 |
+
tiers.append(np.fromfile(p, dtype=np.int64))
|
| 78 |
+
elif t == 0:
|
| 79 |
+
tiers.append(np.zeros(0, dtype=np.int64))
|
| 80 |
+
else:
|
| 81 |
+
break
|
| 82 |
+
return tiers
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def read_meta(root: str, idx: int) -> Dict:
|
| 86 |
+
"""Random-access read of one meta.jsonl record using meta_offsets.i64."""
|
| 87 |
+
offsets = np.fromfile(os.path.join(root, "meta_offsets.i64"), dtype=np.int64)
|
| 88 |
+
with open(os.path.join(root, "meta.jsonl"), "rb") as f:
|
| 89 |
+
f.seek(int(offsets[idx]))
|
| 90 |
+
line = f.readline()
|
| 91 |
+
return json.loads(line)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class SprigDataset(Dataset):
|
| 95 |
+
"""Dataset over a memmap directory; see module docstring for the layout."""
|
| 96 |
+
|
| 97 |
+
def __init__(
|
| 98 |
+
self,
|
| 99 |
+
root: str,
|
| 100 |
+
p_null: float = 0.0,
|
| 101 |
+
null_emb_path: Optional[str] = None,
|
| 102 |
+
train: bool = True,
|
| 103 |
+
seed: int = 0,
|
| 104 |
+
emit_obj_mask: bool = False,
|
| 105 |
+
) -> None:
|
| 106 |
+
self.root = root
|
| 107 |
+
self.train = train
|
| 108 |
+
self.p_null = float(p_null)
|
| 109 |
+
self.seed = seed
|
| 110 |
+
# Object-pixel masks from GT bboxes in meta.jsonl (for the weighted
|
| 111 |
+
# emission loss / object-crop resurrection). Offsets loaded eagerly;
|
| 112 |
+
# the meta file handle is opened lazily per worker process.
|
| 113 |
+
self.emit_obj_mask = bool(emit_obj_mask)
|
| 114 |
+
self._meta_offsets: Optional[np.ndarray] = None
|
| 115 |
+
self._meta_file = None
|
| 116 |
+
if self.emit_obj_mask:
|
| 117 |
+
self._meta_offsets = np.fromfile(
|
| 118 |
+
os.path.join(root, "meta_offsets.i64"), dtype=np.int64)
|
| 119 |
+
|
| 120 |
+
flat = np.memmap(os.path.join(root, "images.u8"), dtype=np.uint8, mode="r")
|
| 121 |
+
if flat.size % (IMG_SIZE * IMG_SIZE * 3) != 0:
|
| 122 |
+
raise ValueError("images.u8 in %r has size not divisible by 64*64*3" % root)
|
| 123 |
+
self.images = flat.reshape(-1, IMG_SIZE, IMG_SIZE, 3)
|
| 124 |
+
self.n = self.images.shape[0]
|
| 125 |
+
|
| 126 |
+
# Embedding variants: single (emb.f16) or multi (emb0.f16, emb1.f16, ...).
|
| 127 |
+
pairs: List[Tuple[str, str]] = []
|
| 128 |
+
if os.path.exists(os.path.join(root, "emb.f16")):
|
| 129 |
+
pairs.append(("emb.f16", "emb_offsets.i64"))
|
| 130 |
+
else:
|
| 131 |
+
v = 0
|
| 132 |
+
while os.path.exists(os.path.join(root, "emb%d.f16" % v)):
|
| 133 |
+
pairs.append(("emb%d.f16" % v, "emb%d_offsets.i64" % v))
|
| 134 |
+
v += 1
|
| 135 |
+
if not pairs:
|
| 136 |
+
raise FileNotFoundError("no emb.f16 or emb0.f16 found in %r" % root)
|
| 137 |
+
self.emb: List[np.ndarray] = []
|
| 138 |
+
self.emb_offsets: List[np.ndarray] = []
|
| 139 |
+
for emb_name, off_name in pairs:
|
| 140 |
+
off = np.fromfile(os.path.join(root, off_name), dtype=np.int64)
|
| 141 |
+
if off.shape[0] != self.n + 1:
|
| 142 |
+
raise ValueError(
|
| 143 |
+
"%s has %d entries, expected N+1=%d" % (off_name, off.shape[0], self.n + 1)
|
| 144 |
+
)
|
| 145 |
+
emb = np.memmap(os.path.join(root, emb_name), dtype=np.float16, mode="r")
|
| 146 |
+
emb = emb.reshape(-1, EMB_DIM)
|
| 147 |
+
if emb.shape[0] != int(off[-1]):
|
| 148 |
+
raise ValueError("%s row count %d != offsets[-1]=%d" % (emb_name, emb.shape[0], int(off[-1])))
|
| 149 |
+
self.emb.append(emb)
|
| 150 |
+
self.emb_offsets.append(off)
|
| 151 |
+
self.n_variants = len(self.emb)
|
| 152 |
+
|
| 153 |
+
# Per-sample tier from the tier index arrays (default tier 0).
|
| 154 |
+
self.tier = np.zeros(self.n, dtype=np.int8)
|
| 155 |
+
tier_dir = os.path.join(root, "tier_idx")
|
| 156 |
+
if os.path.isdir(tier_dir):
|
| 157 |
+
for t in range(MAX_TIERS):
|
| 158 |
+
p = os.path.join(tier_dir, "tier%d.i64" % t)
|
| 159 |
+
if os.path.exists(p):
|
| 160 |
+
idxs = np.fromfile(p, dtype=np.int64)
|
| 161 |
+
self.tier[idxs] = t
|
| 162 |
+
|
| 163 |
+
self.null_emb: Optional[np.ndarray] = None
|
| 164 |
+
if null_emb_path is not None:
|
| 165 |
+
self.null_emb = load_null_emb(null_emb_path)
|
| 166 |
+
if self.p_null > 0.0 and self.null_emb is None:
|
| 167 |
+
raise ValueError("p_null > 0 requires null_emb_path")
|
| 168 |
+
|
| 169 |
+
self._epoch = 0
|
| 170 |
+
self._rng: Optional[np.random.Generator] = None
|
| 171 |
+
|
| 172 |
+
def set_epoch(self, epoch: int) -> None:
|
| 173 |
+
"""Rotate the deterministic caption-variant choice (eval mode)."""
|
| 174 |
+
self._epoch = int(epoch)
|
| 175 |
+
|
| 176 |
+
def _get_rng(self) -> np.random.Generator:
|
| 177 |
+
# Created lazily per worker process so forked workers do not share a
|
| 178 |
+
# RNG state. Null substitution / variant choice are i.i.d. per visit
|
| 179 |
+
# and are not part of the checkpointable state (the sampler is).
|
| 180 |
+
if self._rng is None:
|
| 181 |
+
info = torch.utils.data.get_worker_info()
|
| 182 |
+
wid = 0 if info is None else info.id
|
| 183 |
+
ss = np.random.SeedSequence([self.seed, wid, os.getpid()])
|
| 184 |
+
self._rng = np.random.Generator(np.random.PCG64(ss))
|
| 185 |
+
return self._rng
|
| 186 |
+
|
| 187 |
+
def __len__(self) -> int:
|
| 188 |
+
return self.n
|
| 189 |
+
|
| 190 |
+
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
|
| 191 |
+
idx = int(idx)
|
| 192 |
+
image = torch.from_numpy(np.array(self.images[idx])) # copy off the memmap
|
| 193 |
+
|
| 194 |
+
use_null = False
|
| 195 |
+
if self.train and self.p_null > 0.0:
|
| 196 |
+
use_null = bool(self._get_rng().random() < self.p_null)
|
| 197 |
+
|
| 198 |
+
if use_null:
|
| 199 |
+
emb_np = self.null_emb
|
| 200 |
+
else:
|
| 201 |
+
if self.n_variants == 1:
|
| 202 |
+
v = 0
|
| 203 |
+
elif self.train:
|
| 204 |
+
v = int(self._get_rng().integers(self.n_variants))
|
| 205 |
+
else:
|
| 206 |
+
v = (idx + self._epoch) % self.n_variants
|
| 207 |
+
off = self.emb_offsets[v]
|
| 208 |
+
lo, hi = int(off[idx]), int(off[idx + 1])
|
| 209 |
+
emb_np = self.emb[v][lo:hi]
|
| 210 |
+
emb = torch.from_numpy(np.array(emb_np, dtype=np.float16)) # copy off the memmap
|
| 211 |
+
|
| 212 |
+
out = {
|
| 213 |
+
"image": image,
|
| 214 |
+
"emb": emb,
|
| 215 |
+
"emb_len": torch.tensor(emb.shape[0], dtype=torch.int32),
|
| 216 |
+
"tier": torch.tensor(int(self.tier[idx]), dtype=torch.int8),
|
| 217 |
+
"idx": torch.tensor(idx, dtype=torch.int64),
|
| 218 |
+
}
|
| 219 |
+
if self.emit_obj_mask:
|
| 220 |
+
out["objmask"] = self._obj_mask(idx)
|
| 221 |
+
return out
|
| 222 |
+
|
| 223 |
+
def _obj_mask(self, idx: int) -> torch.Tensor:
|
| 224 |
+
"""u8 [IMG_SIZE, IMG_SIZE]: 1 inside any GT object bbox, else 0."""
|
| 225 |
+
if self._meta_file is None:
|
| 226 |
+
self._meta_file = open(os.path.join(self.root, "meta.jsonl"), "rb")
|
| 227 |
+
self._meta_file.seek(int(self._meta_offsets[idx]))
|
| 228 |
+
m = json.loads(self._meta_file.readline())
|
| 229 |
+
mask = torch.zeros(IMG_SIZE, IMG_SIZE, dtype=torch.uint8)
|
| 230 |
+
for o in m.get("objects") or []:
|
| 231 |
+
bb = o.get("bbox") or o.get("cell")
|
| 232 |
+
if not bb or len(bb) != 4:
|
| 233 |
+
continue
|
| 234 |
+
x0 = max(0, int(math.floor(float(bb[0]))))
|
| 235 |
+
y0 = max(0, int(math.floor(float(bb[1]))))
|
| 236 |
+
x1 = min(IMG_SIZE, int(math.ceil(float(bb[2]))))
|
| 237 |
+
y1 = min(IMG_SIZE, int(math.ceil(float(bb[3]))))
|
| 238 |
+
if x1 > x0 and y1 > y0:
|
| 239 |
+
mask[y0:y1, x0:x1] = 1
|
| 240 |
+
return mask
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def collate(items: Sequence[Dict[str, torch.Tensor]]) -> Dict[str, torch.Tensor]:
|
| 244 |
+
"""Collate to contract C1; ragged embeddings zero-padded to the batch max."""
|
| 245 |
+
b = len(items)
|
| 246 |
+
lens = [int(it["emb_len"]) for it in items]
|
| 247 |
+
lmax = max(1, max(lens))
|
| 248 |
+
emb = torch.zeros(b, lmax, EMB_DIM, dtype=torch.float16)
|
| 249 |
+
for i, it in enumerate(items):
|
| 250 |
+
li = lens[i]
|
| 251 |
+
if li > 0:
|
| 252 |
+
emb[i, :li] = it["emb"]
|
| 253 |
+
out = {
|
| 254 |
+
"image": torch.stack([it["image"] for it in items]),
|
| 255 |
+
"emb": emb,
|
| 256 |
+
"emb_len": torch.tensor(lens, dtype=torch.int32),
|
| 257 |
+
"tier": torch.stack([it["tier"] for it in items]),
|
| 258 |
+
"idx": torch.stack([it["idx"] for it in items]),
|
| 259 |
+
}
|
| 260 |
+
if "objmask" in items[0]:
|
| 261 |
+
out["objmask"] = torch.stack([it["objmask"] for it in items])
|
| 262 |
+
return out
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
class TierCurriculumSampler(Sampler):
|
| 266 |
+
"""Infinite sampler drawing tiers according to a stepwise weight schedule.
|
| 267 |
+
|
| 268 |
+
schedule: list of (step_start, [w0..w_{T-1}]) pairs; the entry with the
|
| 269 |
+
largest step_start <= current training step is active. `batch_size`
|
| 270 |
+
converts sample draws into training steps (step = draws // batch_size), so
|
| 271 |
+
schedules can be written in optimizer steps as in DESIGN.md section 7.
|
| 272 |
+
|
| 273 |
+
Checkpointable via state_dict/load_state_dict (RNG state + draw counter).
|
| 274 |
+
Iterate it from the main process (the default for torch DataLoader
|
| 275 |
+
samplers) so the state advances where checkpoints are taken; with
|
| 276 |
+
num_workers > 0 the prefetch queue makes resumes approximate by up to
|
| 277 |
+
(num_workers * prefetch_factor) batches, which is acceptable.
|
| 278 |
+
"""
|
| 279 |
+
|
| 280 |
+
def __init__(
|
| 281 |
+
self,
|
| 282 |
+
tier_indices: Sequence[np.ndarray],
|
| 283 |
+
schedule: Sequence[Tuple[int, Sequence[float]]],
|
| 284 |
+
batch_size: int = 1,
|
| 285 |
+
seed: int = 0,
|
| 286 |
+
) -> None:
|
| 287 |
+
self.tier_indices = [np.asarray(a, dtype=np.int64) for a in tier_indices]
|
| 288 |
+
if not self.tier_indices:
|
| 289 |
+
raise ValueError("tier_indices is empty")
|
| 290 |
+
sched = sorted(((int(s), list(map(float, w))) for s, w in schedule), key=lambda x: x[0])
|
| 291 |
+
if not sched or sched[0][0] != 0:
|
| 292 |
+
raise ValueError("schedule must start at step 0")
|
| 293 |
+
for _, w in sched:
|
| 294 |
+
if len(w) != len(self.tier_indices):
|
| 295 |
+
raise ValueError("schedule weight vectors must have one entry per tier")
|
| 296 |
+
self.schedule = sched
|
| 297 |
+
self.batch_size = int(batch_size)
|
| 298 |
+
self.seed = seed
|
| 299 |
+
self._draws = 0
|
| 300 |
+
self._rng = np.random.Generator(np.random.PCG64(seed))
|
| 301 |
+
|
| 302 |
+
def _weights_at(self, step: int) -> np.ndarray:
|
| 303 |
+
w = self.schedule[0][1]
|
| 304 |
+
for s0, wi in self.schedule:
|
| 305 |
+
if s0 <= step:
|
| 306 |
+
w = wi
|
| 307 |
+
else:
|
| 308 |
+
break
|
| 309 |
+
w = np.asarray(w, dtype=np.float64)
|
| 310 |
+
# Zero out empty tiers so we never draw from them.
|
| 311 |
+
for t, arr in enumerate(self.tier_indices):
|
| 312 |
+
if arr.size == 0:
|
| 313 |
+
w[t] = 0.0
|
| 314 |
+
total = w.sum()
|
| 315 |
+
if total <= 0.0:
|
| 316 |
+
raise ValueError("all active tiers at step %d are empty or zero-weight" % step)
|
| 317 |
+
return w / total
|
| 318 |
+
|
| 319 |
+
def __iter__(self) -> Iterator[int]:
|
| 320 |
+
n_tiers = len(self.tier_indices)
|
| 321 |
+
while True:
|
| 322 |
+
step = self._draws // self.batch_size
|
| 323 |
+
w = self._weights_at(step)
|
| 324 |
+
t = int(self._rng.choice(n_tiers, p=w))
|
| 325 |
+
arr = self.tier_indices[t]
|
| 326 |
+
i = int(arr[int(self._rng.integers(arr.size))])
|
| 327 |
+
self._draws += 1
|
| 328 |
+
yield i
|
| 329 |
+
|
| 330 |
+
def state_dict(self) -> Dict:
|
| 331 |
+
return {"draws": self._draws, "rng_state": self._rng.bit_generator.state}
|
| 332 |
+
|
| 333 |
+
def load_state_dict(self, state: Dict) -> None:
|
| 334 |
+
self._draws = int(state["draws"])
|
| 335 |
+
self._rng = np.random.Generator(np.random.PCG64(self.seed))
|
| 336 |
+
self._rng.bit_generator.state = state["rng_state"]
|
sprig/data/embed_t5.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Precompute frozen T5-base caption embeddings for SPRIG datasets.
|
| 2 |
+
|
| 3 |
+
CLI (run once per dataset directory; the encoder never runs during training):
|
| 4 |
+
|
| 5 |
+
python -m sprig.data.embed_t5 --data-dir ~/data/sprig/proc2d/train
|
| 6 |
+
python -m sprig.data.embed_t5 --null-out ~/data/sprig/t5/null.f16
|
| 7 |
+
python -m sprig.data.embed_t5 --prompts-out ~/data/sprig/t5/promptbank.npz \
|
| 8 |
+
[--prompts-file prompts.json]
|
| 9 |
+
|
| 10 |
+
--data-dir reads captions from <dir>/meta.jsonl in order and writes
|
| 11 |
+
packed-ragged fp16 token embeddings (valid tokens only, per the tokenizer
|
| 12 |
+
attention mask) plus int64 offsets [N+1]:
|
| 13 |
+
|
| 14 |
+
* single-caption meta ({"caption": ...}) -> emb.f16 / emb_offsets.i64
|
| 15 |
+
* multi-caption meta ({"captions": [c0..ck]}) -> emb{v}.f16 / emb{v}_offsets.i64
|
| 16 |
+
for every variant v (or just one with --variant V).
|
| 17 |
+
|
| 18 |
+
Captions are tokenized with truncation to --max-len (64) tokens,
|
| 19 |
+
length-bucketed within --chunk-size (100k) caption chunks, encoded in batches
|
| 20 |
+
of --batch-size (512), and written back in the original caption order.
|
| 21 |
+
|
| 22 |
+
--null-out embeds the empty string (a single </s> token embedding) to a
|
| 23 |
+
packed f16 file — the null-caption substitute used by the dataloader (C1).
|
| 24 |
+
|
| 25 |
+
--prompts-out embeds a JSON list of prompts (from --prompts-file, else from
|
| 26 |
+
`sprig.eval.prompts.PROMPTS`, imported lazily) into an npz with keys
|
| 27 |
+
{emb: f16 [P, Lmax, 768] zero-padded, len: i32 [P], prompts: str array}.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import argparse
|
| 33 |
+
import json
|
| 34 |
+
import os
|
| 35 |
+
from typing import Callable, Iterator, List, Optional, Sequence, Tuple
|
| 36 |
+
|
| 37 |
+
import numpy as np
|
| 38 |
+
|
| 39 |
+
EMB_DIM = 768
|
| 40 |
+
DEFAULT_MODEL = "google-t5/t5-base"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def read_meta_captions(meta_path: str) -> Tuple[List[str], int]:
|
| 44 |
+
"""Read captions from meta.jsonl.
|
| 45 |
+
|
| 46 |
+
Returns (flat_captions, n_variants): for single-caption meta the flat list
|
| 47 |
+
is the N captions and n_variants == 1; for multi-caption meta the list is
|
| 48 |
+
variant-major, i.e. [all c0, then all c1, ...], length N * n_variants.
|
| 49 |
+
"""
|
| 50 |
+
singles: List[str] = []
|
| 51 |
+
multis: List[List[str]] = []
|
| 52 |
+
with open(meta_path, "r") as f:
|
| 53 |
+
for line in f:
|
| 54 |
+
line = line.strip()
|
| 55 |
+
if not line:
|
| 56 |
+
continue
|
| 57 |
+
d = json.loads(line)
|
| 58 |
+
if "captions" in d:
|
| 59 |
+
multis.append(list(d["captions"]))
|
| 60 |
+
else:
|
| 61 |
+
singles.append(d["caption"])
|
| 62 |
+
if singles and multis:
|
| 63 |
+
raise ValueError("meta.jsonl mixes 'caption' and 'captions' records")
|
| 64 |
+
if singles:
|
| 65 |
+
return singles, 1
|
| 66 |
+
if not multis:
|
| 67 |
+
return [], 1
|
| 68 |
+
n_var = len(multis[0])
|
| 69 |
+
for caps in multis:
|
| 70 |
+
if len(caps) != n_var:
|
| 71 |
+
raise ValueError("inconsistent number of caption variants in meta.jsonl")
|
| 72 |
+
flat: List[str] = []
|
| 73 |
+
for v in range(n_var):
|
| 74 |
+
flat.extend(caps[v] for caps in multis)
|
| 75 |
+
return flat, n_var
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def embed_corpus(
|
| 79 |
+
captions: Sequence[str],
|
| 80 |
+
encode_batch: Callable[[List[str]], List[np.ndarray]],
|
| 81 |
+
token_len: Callable[[str], int],
|
| 82 |
+
batch_size: int = 512,
|
| 83 |
+
chunk_size: int = 100000,
|
| 84 |
+
) -> Iterator[np.ndarray]:
|
| 85 |
+
"""Yield one [L_i, 768] f16 array per caption, in the original order.
|
| 86 |
+
|
| 87 |
+
Within each chunk of `chunk_size` captions, indices are sorted by token
|
| 88 |
+
length so batches are near-uniform in length (minimal padding waste), then
|
| 89 |
+
the results are restored to the original order before yielding.
|
| 90 |
+
"""
|
| 91 |
+
n = len(captions)
|
| 92 |
+
for c0 in range(0, n, chunk_size):
|
| 93 |
+
chunk = list(captions[c0 : c0 + chunk_size])
|
| 94 |
+
lens = np.asarray([token_len(c) for c in chunk], dtype=np.int64)
|
| 95 |
+
order = np.argsort(lens, kind="stable")
|
| 96 |
+
results: List[Optional[np.ndarray]] = [None] * len(chunk)
|
| 97 |
+
for b0 in range(0, len(order), batch_size):
|
| 98 |
+
batch_idx = order[b0 : b0 + batch_size]
|
| 99 |
+
embs = encode_batch([chunk[int(j)] for j in batch_idx])
|
| 100 |
+
if len(embs) != len(batch_idx):
|
| 101 |
+
raise RuntimeError("encode_batch returned wrong number of embeddings")
|
| 102 |
+
for j, e in zip(batch_idx, embs):
|
| 103 |
+
results[int(j)] = np.asarray(e, dtype=np.float16)
|
| 104 |
+
for r in results:
|
| 105 |
+
assert r is not None
|
| 106 |
+
if r.ndim != 2 or r.shape[1] != EMB_DIM:
|
| 107 |
+
raise RuntimeError("embedding has shape %r, expected [L, %d]" % (r.shape, EMB_DIM))
|
| 108 |
+
yield r
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def write_packed(
|
| 112 |
+
arrays: Iterator[np.ndarray], n: int, emb_path: str, offsets_path: str
|
| 113 |
+
) -> np.ndarray:
|
| 114 |
+
"""Stream n ragged [L,768] f16 arrays to a packed file + [N+1] offsets."""
|
| 115 |
+
offsets = np.zeros(n + 1, dtype=np.int64)
|
| 116 |
+
tmp = emb_path + ".tmp"
|
| 117 |
+
count = 0
|
| 118 |
+
with open(tmp, "wb") as f:
|
| 119 |
+
for i, a in enumerate(arrays):
|
| 120 |
+
if i >= n:
|
| 121 |
+
raise RuntimeError("more arrays than expected (n=%d)" % n)
|
| 122 |
+
a.astype(np.float16).tofile(f)
|
| 123 |
+
offsets[i + 1] = offsets[i] + a.shape[0]
|
| 124 |
+
count += 1
|
| 125 |
+
if count != n:
|
| 126 |
+
os.remove(tmp)
|
| 127 |
+
raise RuntimeError("expected %d arrays, got %d" % (n, count))
|
| 128 |
+
os.replace(tmp, emb_path)
|
| 129 |
+
offsets.tofile(offsets_path)
|
| 130 |
+
return offsets
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _load_t5_encoder_nommap(model_name: str, dtype):
|
| 134 |
+
"""Load T5EncoderModel with weights read via plain file reads (no mmap).
|
| 135 |
+
|
| 136 |
+
transformers/safetensors normally mmap the checkpoint and materialize
|
| 137 |
+
weight pages lazily at forward time; on memory-constrained hosts (e.g. an
|
| 138 |
+
8 GB Mac under pressure) those page-ins can SIGBUS the process. Reading
|
| 139 |
+
the bytes up front and deserializing in memory avoids the mmap entirely.
|
| 140 |
+
Only handles single-file safetensors checkpoints; callers fall back to
|
| 141 |
+
from_pretrained otherwise.
|
| 142 |
+
"""
|
| 143 |
+
import torch
|
| 144 |
+
from huggingface_hub import hf_hub_download
|
| 145 |
+
from safetensors.torch import load as st_load
|
| 146 |
+
from transformers import AutoConfig, T5EncoderModel
|
| 147 |
+
|
| 148 |
+
cfg = AutoConfig.from_pretrained(model_name)
|
| 149 |
+
path = hf_hub_download(model_name, "model.safetensors")
|
| 150 |
+
with open(path, "rb") as f:
|
| 151 |
+
data = f.read()
|
| 152 |
+
sd = st_load(data)
|
| 153 |
+
del data
|
| 154 |
+
model = T5EncoderModel(cfg)
|
| 155 |
+
# The checkpoint carries the full T5; keep encoder weights only
|
| 156 |
+
# (embed_tokens is tied to `shared` at construction).
|
| 157 |
+
model.load_state_dict(sd, strict=False)
|
| 158 |
+
model.tie_weights()
|
| 159 |
+
return model.to(dtype)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def make_t5_encoder(
|
| 163 |
+
model_name: str = DEFAULT_MODEL, device: str = "cpu", max_len: int = 64
|
| 164 |
+
) -> Tuple[Callable[[str], int], Callable[[List[str]], List[np.ndarray]]]:
|
| 165 |
+
"""Build (token_len, encode_batch) over a frozen T5 encoder.
|
| 166 |
+
|
| 167 |
+
bf16 on cuda, fp32 on cpu; outputs are cast to fp16 numpy arrays holding
|
| 168 |
+
valid (attention-masked) token embeddings only.
|
| 169 |
+
"""
|
| 170 |
+
import torch
|
| 171 |
+
from transformers import AutoTokenizer, T5EncoderModel
|
| 172 |
+
|
| 173 |
+
tok = AutoTokenizer.from_pretrained(model_name)
|
| 174 |
+
dtype = torch.bfloat16 if device.startswith("cuda") else torch.float32
|
| 175 |
+
try:
|
| 176 |
+
model = _load_t5_encoder_nommap(model_name, dtype)
|
| 177 |
+
except Exception:
|
| 178 |
+
model = T5EncoderModel.from_pretrained(model_name, dtype=dtype)
|
| 179 |
+
model = model.to(device).eval()
|
| 180 |
+
|
| 181 |
+
def token_len(text: str) -> int:
|
| 182 |
+
return len(tok(text, truncation=True, max_length=max_len).input_ids)
|
| 183 |
+
|
| 184 |
+
def encode_batch(texts: List[str]) -> List[np.ndarray]:
|
| 185 |
+
enc = tok(
|
| 186 |
+
texts,
|
| 187 |
+
truncation=True,
|
| 188 |
+
max_length=max_len,
|
| 189 |
+
padding=True,
|
| 190 |
+
return_tensors="pt",
|
| 191 |
+
).to(device)
|
| 192 |
+
with torch.no_grad():
|
| 193 |
+
out = model(**enc).last_hidden_state # [B, L, 768]
|
| 194 |
+
mask = enc.attention_mask.bool()
|
| 195 |
+
return [
|
| 196 |
+
out[i, mask[i]].to(torch.float16).cpu().numpy() for i in range(len(texts))
|
| 197 |
+
]
|
| 198 |
+
|
| 199 |
+
return token_len, encode_batch
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _load_prompts(prompts_file: Optional[str]) -> List[str]:
|
| 203 |
+
if prompts_file is not None:
|
| 204 |
+
with open(prompts_file, "r") as f:
|
| 205 |
+
prompts = json.load(f)
|
| 206 |
+
if not isinstance(prompts, list):
|
| 207 |
+
raise ValueError("--prompts-file must contain a JSON list of strings")
|
| 208 |
+
return [str(p) for p in prompts]
|
| 209 |
+
from sprig.eval.prompts import PROMPTS # lazy: eval module may not exist yet
|
| 210 |
+
|
| 211 |
+
return list(PROMPTS)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _embed_data_dir(
|
| 215 |
+
data_dir: str,
|
| 216 |
+
variant: Optional[int],
|
| 217 |
+
token_len: Callable[[str], int],
|
| 218 |
+
encode_batch: Callable[[List[str]], List[np.ndarray]],
|
| 219 |
+
batch_size: int,
|
| 220 |
+
chunk_size: int,
|
| 221 |
+
) -> None:
|
| 222 |
+
captions, n_var = read_meta_captions(os.path.join(data_dir, "meta.jsonl"))
|
| 223 |
+
n = len(captions) // n_var
|
| 224 |
+
variants = range(n_var) if variant is None else [variant]
|
| 225 |
+
for v in variants:
|
| 226 |
+
if not (0 <= v < n_var):
|
| 227 |
+
raise ValueError("--variant %d out of range (meta has %d variants)" % (v, n_var))
|
| 228 |
+
caps_v = captions[v * n : (v + 1) * n]
|
| 229 |
+
if n_var == 1:
|
| 230 |
+
emb_path = os.path.join(data_dir, "emb.f16")
|
| 231 |
+
off_path = os.path.join(data_dir, "emb_offsets.i64")
|
| 232 |
+
else:
|
| 233 |
+
emb_path = os.path.join(data_dir, "emb%d.f16" % v)
|
| 234 |
+
off_path = os.path.join(data_dir, "emb%d_offsets.i64" % v)
|
| 235 |
+
arrays = embed_corpus(caps_v, encode_batch, token_len, batch_size, chunk_size)
|
| 236 |
+
offsets = write_packed(arrays, n, emb_path, off_path)
|
| 237 |
+
print(
|
| 238 |
+
"wrote %s: %d captions, %d tokens" % (emb_path, n, int(offsets[-1])),
|
| 239 |
+
flush=True,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def main(argv: Optional[List[str]] = None) -> None:
|
| 244 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 245 |
+
ap.add_argument("--data-dir", default=None, help="dataset dir with meta.jsonl")
|
| 246 |
+
ap.add_argument(
|
| 247 |
+
"--variant",
|
| 248 |
+
type=int,
|
| 249 |
+
default=None,
|
| 250 |
+
help="embed only this caption variant (default: all variants in meta)",
|
| 251 |
+
)
|
| 252 |
+
ap.add_argument("--model", default=DEFAULT_MODEL)
|
| 253 |
+
ap.add_argument("--device", default=None, help="cpu/cuda (default: auto)")
|
| 254 |
+
ap.add_argument("--batch-size", type=int, default=512)
|
| 255 |
+
ap.add_argument("--max-len", type=int, default=64)
|
| 256 |
+
ap.add_argument("--chunk-size", type=int, default=100000)
|
| 257 |
+
ap.add_argument("--null-out", default=None, help="write empty-caption embedding here")
|
| 258 |
+
ap.add_argument("--prompts-out", default=None, help="write promptbank.npz here")
|
| 259 |
+
ap.add_argument("--prompts-file", default=None, help="JSON list of prompts")
|
| 260 |
+
args = ap.parse_args(argv)
|
| 261 |
+
|
| 262 |
+
if args.data_dir is None and args.null_out is None and args.prompts_out is None:
|
| 263 |
+
ap.error("nothing to do: give --data-dir, --null-out, and/or --prompts-out")
|
| 264 |
+
|
| 265 |
+
device = args.device
|
| 266 |
+
if device is None:
|
| 267 |
+
import torch
|
| 268 |
+
|
| 269 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 270 |
+
|
| 271 |
+
token_len, encode_batch = make_t5_encoder(args.model, device, args.max_len)
|
| 272 |
+
|
| 273 |
+
if args.data_dir is not None:
|
| 274 |
+
_embed_data_dir(
|
| 275 |
+
args.data_dir,
|
| 276 |
+
args.variant,
|
| 277 |
+
token_len,
|
| 278 |
+
encode_batch,
|
| 279 |
+
args.batch_size,
|
| 280 |
+
args.chunk_size,
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
if args.null_out is not None:
|
| 284 |
+
null = encode_batch([""])[0]
|
| 285 |
+
os.makedirs(os.path.dirname(os.path.abspath(args.null_out)), exist_ok=True)
|
| 286 |
+
null.astype(np.float16).tofile(args.null_out)
|
| 287 |
+
print("wrote %s: [%d, %d]" % (args.null_out, null.shape[0], null.shape[1]))
|
| 288 |
+
|
| 289 |
+
if args.prompts_out is not None:
|
| 290 |
+
prompts = _load_prompts(args.prompts_file)
|
| 291 |
+
embs = encode_batch(prompts) if prompts else []
|
| 292 |
+
lens = np.asarray([e.shape[0] for e in embs], dtype=np.int32)
|
| 293 |
+
lmax = int(lens.max()) if len(embs) else 1
|
| 294 |
+
emb = np.zeros((len(prompts), lmax, EMB_DIM), dtype=np.float16)
|
| 295 |
+
for i, e in enumerate(embs):
|
| 296 |
+
emb[i, : e.shape[0]] = e
|
| 297 |
+
os.makedirs(os.path.dirname(os.path.abspath(args.prompts_out)), exist_ok=True)
|
| 298 |
+
np.savez(args.prompts_out, emb=emb, len=lens, prompts=np.array(prompts))
|
| 299 |
+
print("wrote %s: %d prompts, Lmax=%d" % (args.prompts_out, len(prompts), lmax))
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
if __name__ == "__main__":
|
| 303 |
+
main()
|
sprig/data/procgen/__init__.py
ADDED
|
File without changes
|
sprig/data/procgen/captions.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Templated dense captions for procedural scenes.
|
| 2 |
+
|
| 3 |
+
10 training templates (T1..T10) + 2 held-out eval templates (E1, E2 — never
|
| 4 |
+
sampled in training mode). 15% of training captions are partial (attribute or
|
| 5 |
+
object dropping) and flagged `partial=True` to teach marginalization.
|
| 6 |
+
|
| 7 |
+
Template inventory (tiers they apply to):
|
| 8 |
+
T1 attribute "a small striped red circle" (0)
|
| 9 |
+
T2 relation, subject-first "a X to the left of a Y" (1)
|
| 10 |
+
T3 attribute + background "a X on a gray background" (0)
|
| 11 |
+
T4 enumerative "a scene with a X, a Y, and a Z" (1,2)
|
| 12 |
+
T5 relation, INVERTED mention order "a Y to the right of a X" (1)
|
| 13 |
+
T6 containment "a X inside a blue frame" (3)
|
| 14 |
+
T7 count + background "a scene with three shapes on ..." (1,2)
|
| 15 |
+
T8 background-first relation "on a gray background, a X above a Y" (1)
|
| 16 |
+
T9 count + enumeration "three shapes: a X, a Y, and a Z" (2)
|
| 17 |
+
T10 containment, frame-first "a blue frame containing a X" (3)
|
| 18 |
+
E1 eval rephrase "the image shows ..." (all)
|
| 19 |
+
E2 eval rephrase "there is ... in the picture" (all)
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
from dataclasses import dataclass
|
| 24 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
|
| 28 |
+
from .sampler import Scene
|
| 29 |
+
from .vocab import TEXTURE_ADJ
|
| 30 |
+
|
| 31 |
+
TRAIN_TEMPLATE_IDS: Tuple[str, ...] = (
|
| 32 |
+
"T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", "T10",
|
| 33 |
+
)
|
| 34 |
+
EVAL_TEMPLATE_IDS: Tuple[str, ...] = ("E1", "E2")
|
| 35 |
+
|
| 36 |
+
_TIER_TEMPLATES: Dict[int, Tuple[str, ...]] = {
|
| 37 |
+
0: ("T1", "T3"),
|
| 38 |
+
1: ("T2", "T5", "T4", "T7", "T8"),
|
| 39 |
+
2: ("T4", "T7", "T9"),
|
| 40 |
+
3: ("T6", "T10"),
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
_NUM_WORDS = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five"}
|
| 44 |
+
# relation type -> (forward phrase for A rel B, inverted phrase for B rel A)
|
| 45 |
+
_REL = {"left": ("to the left of", "to the right of"), "above": ("above", "below")}
|
| 46 |
+
|
| 47 |
+
PARTIAL_RATE = 0.15
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class Caption:
|
| 52 |
+
text: str
|
| 53 |
+
template_id: str
|
| 54 |
+
partial: bool
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _article(phrase: str) -> str:
|
| 58 |
+
return "an" if phrase and phrase[0] in "aeiou" else "a"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _obj_phrase(obj: Dict[str, Any], drops: Sequence[str] = ()) -> str:
|
| 62 |
+
words: List[str] = []
|
| 63 |
+
if "size" not in drops:
|
| 64 |
+
words.append(obj["size"])
|
| 65 |
+
adj = TEXTURE_ADJ[obj["texture"]]
|
| 66 |
+
if adj and "texture" not in drops:
|
| 67 |
+
words.append(adj)
|
| 68 |
+
if "color" not in drops:
|
| 69 |
+
words.append(obj["color"])
|
| 70 |
+
words.append(obj["shape"])
|
| 71 |
+
core = " ".join(words)
|
| 72 |
+
return "{} {}".format(_article(core), core)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _list_phrase(phrases: Sequence[str]) -> str:
|
| 76 |
+
if len(phrases) == 1:
|
| 77 |
+
return phrases[0]
|
| 78 |
+
if len(phrases) == 2:
|
| 79 |
+
return "{} and {}".format(phrases[0], phrases[1])
|
| 80 |
+
return "{}, and {}".format(", ".join(phrases[:-1]), phrases[-1])
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _bg_phrase(scene: Scene) -> str:
|
| 84 |
+
names = scene.background.split("|")
|
| 85 |
+
if len(names) == 2:
|
| 86 |
+
return "a {} and {} background".format(names[0], names[1])
|
| 87 |
+
return "a {} background".format(names[0])
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _relation_parts(
|
| 91 |
+
scene: Scene, drops: Sequence[str]
|
| 92 |
+
) -> Tuple[str, str, str, str]:
|
| 93 |
+
rel = scene.relation
|
| 94 |
+
assert rel is not None, "relation template on a scene without a relation"
|
| 95 |
+
fwd, inv = _REL[rel["type"]]
|
| 96 |
+
pa = _obj_phrase(scene.objects[rel["a"]], drops)
|
| 97 |
+
pb = _obj_phrase(scene.objects[rel["b"]], drops)
|
| 98 |
+
return pa, pb, fwd, inv
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def render_template(
|
| 102 |
+
scene: Scene,
|
| 103 |
+
template_id: str,
|
| 104 |
+
drops: Sequence[str] = (),
|
| 105 |
+
obj_order: Optional[Sequence[int]] = None,
|
| 106 |
+
) -> str:
|
| 107 |
+
"""Realize `template_id` for `scene`.
|
| 108 |
+
|
| 109 |
+
`drops`: subset of {"size","texture","color","bg"} removed from the text
|
| 110 |
+
(partial captions). `obj_order`: object index order/subset for the
|
| 111 |
+
enumerative templates T4/T9/E1/E2.
|
| 112 |
+
"""
|
| 113 |
+
objs = scene.objects
|
| 114 |
+
order = list(obj_order) if obj_order is not None else list(range(len(objs)))
|
| 115 |
+
phrases = [_obj_phrase(objs[i], drops) for i in order]
|
| 116 |
+
|
| 117 |
+
if template_id == "T1":
|
| 118 |
+
return phrases[0]
|
| 119 |
+
if template_id == "T2":
|
| 120 |
+
pa, pb, fwd, _ = _relation_parts(scene, drops)
|
| 121 |
+
return "{} {} {}".format(pa, fwd, pb)
|
| 122 |
+
if template_id == "T3":
|
| 123 |
+
if "bg" in drops:
|
| 124 |
+
return phrases[0]
|
| 125 |
+
return "{} on {}".format(phrases[0], _bg_phrase(scene))
|
| 126 |
+
if template_id == "T4":
|
| 127 |
+
return "a scene with {}".format(_list_phrase(phrases))
|
| 128 |
+
if template_id == "T5":
|
| 129 |
+
pa, pb, _, inv = _relation_parts(scene, drops)
|
| 130 |
+
return "{} {} {}".format(pb, inv, pa)
|
| 131 |
+
if template_id == "T6":
|
| 132 |
+
assert scene.frame is not None
|
| 133 |
+
fc = scene.frame["color"]
|
| 134 |
+
return "{} inside {} {} frame".format(phrases[0], _article(fc), fc)
|
| 135 |
+
if template_id == "T7":
|
| 136 |
+
n = _NUM_WORDS[len(objs)]
|
| 137 |
+
noun = "shape" if len(objs) == 1 else "shapes"
|
| 138 |
+
if "bg" in drops:
|
| 139 |
+
return "a scene with {} {}".format(n, noun)
|
| 140 |
+
return "a scene with {} {} on {}".format(n, noun, _bg_phrase(scene))
|
| 141 |
+
if template_id == "T8":
|
| 142 |
+
pa, pb, fwd, _ = _relation_parts(scene, drops)
|
| 143 |
+
if "bg" in drops:
|
| 144 |
+
return "{} {} {}".format(pa, fwd, pb)
|
| 145 |
+
return "on {}, {} {} {}".format(_bg_phrase(scene), pa, fwd, pb)
|
| 146 |
+
if template_id == "T9":
|
| 147 |
+
n = _NUM_WORDS[len(order)]
|
| 148 |
+
noun = "shape" if len(order) == 1 else "shapes"
|
| 149 |
+
return "{} {}: {}".format(n, noun, _list_phrase(phrases))
|
| 150 |
+
if template_id == "T10":
|
| 151 |
+
assert scene.frame is not None
|
| 152 |
+
fc = scene.frame["color"]
|
| 153 |
+
return "{} {} frame containing {}".format(_article(fc), fc, phrases[0])
|
| 154 |
+
if template_id in ("E1", "E2"):
|
| 155 |
+
content = _eval_content(scene, phrases)
|
| 156 |
+
if template_id == "E1":
|
| 157 |
+
return "the image shows {}".format(content)
|
| 158 |
+
verb = "are" if len(objs) > 1 else "is"
|
| 159 |
+
return "there {} {} in the picture".format(verb, content)
|
| 160 |
+
raise ValueError("unknown template id: {}".format(template_id))
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _eval_content(scene: Scene, phrases: Sequence[str]) -> str:
|
| 164 |
+
if scene.tier == 1 and scene.relation is not None:
|
| 165 |
+
pa, pb, fwd, _ = _relation_parts(scene, ())
|
| 166 |
+
return "{} {} {}".format(pa, fwd, pb)
|
| 167 |
+
if scene.tier == 3 and scene.frame is not None:
|
| 168 |
+
fc = scene.frame["color"]
|
| 169 |
+
return "{} inside {} {} frame".format(phrases[0], _article(fc), fc)
|
| 170 |
+
return _list_phrase(phrases)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _drop_candidates(scene: Scene, template_id: str) -> List[str]:
|
| 174 |
+
cands: List[str] = []
|
| 175 |
+
if template_id != "T7": # T7 mentions no object attributes
|
| 176 |
+
cands.extend(["size", "color"])
|
| 177 |
+
if any(TEXTURE_ADJ[o["texture"]] for o in scene.objects):
|
| 178 |
+
cands.append("texture")
|
| 179 |
+
if template_id in ("T3", "T7", "T8"):
|
| 180 |
+
cands.append("bg")
|
| 181 |
+
return cands
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def sample_caption(
|
| 185 |
+
scene: Scene, rng: np.random.Generator, mode: str = "train"
|
| 186 |
+
) -> Caption:
|
| 187 |
+
"""Sample a caption for `scene`.
|
| 188 |
+
|
| 189 |
+
mode="train": tier-appropriate training templates, 15% partial captions.
|
| 190 |
+
mode="eval": held-out E1/E2 templates only, never partial.
|
| 191 |
+
"""
|
| 192 |
+
if mode == "eval":
|
| 193 |
+
tid = EVAL_TEMPLATE_IDS[int(rng.integers(len(EVAL_TEMPLATE_IDS)))]
|
| 194 |
+
return Caption(render_template(scene, tid), tid, False)
|
| 195 |
+
assert mode == "train", "mode must be 'train' or 'eval'"
|
| 196 |
+
|
| 197 |
+
pool = _TIER_TEMPLATES[scene.tier]
|
| 198 |
+
tid = pool[int(rng.integers(len(pool)))]
|
| 199 |
+
|
| 200 |
+
obj_order: Optional[List[int]] = None
|
| 201 |
+
if tid in ("T4", "T9"):
|
| 202 |
+
obj_order = [int(i) for i in rng.permutation(len(scene.objects))]
|
| 203 |
+
|
| 204 |
+
partial = bool(rng.random() < PARTIAL_RATE)
|
| 205 |
+
drops: Tuple[str, ...] = ()
|
| 206 |
+
if partial:
|
| 207 |
+
if tid == "T4" and len(scene.objects) >= 2 and rng.random() < 0.5:
|
| 208 |
+
# object dropping: keep a strict nonempty subset
|
| 209 |
+
keep = 1 + int(rng.integers(len(scene.objects) - 1))
|
| 210 |
+
assert obj_order is not None
|
| 211 |
+
obj_order = obj_order[:keep]
|
| 212 |
+
else:
|
| 213 |
+
cands = _drop_candidates(scene, tid)
|
| 214 |
+
k = 1 if (len(cands) == 1 or rng.random() < 0.5) else 2
|
| 215 |
+
picked = rng.permutation(len(cands))[:k]
|
| 216 |
+
drops = tuple(cands[int(i)] for i in picked)
|
| 217 |
+
|
| 218 |
+
text = render_template(scene, tid, drops=drops, obj_order=obj_order)
|
| 219 |
+
return Caption(text, tid, partial)
|
sprig/data/procgen/render.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic rasterizer for procedural scenes.
|
| 2 |
+
|
| 3 |
+
Draws at 256x256 (4x supersample) with numpy + PIL, then BOX-downsamples to
|
| 4 |
+
64x64. `render_scene` is a pure function of the Scene, and the Scene is a pure
|
| 5 |
+
function of (global_seed, idx) via SeedSequence([global_seed, idx]) — so the
|
| 6 |
+
same idx always yields bit-identical pixels (regression-tested by hash).
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
from PIL import Image, ImageDraw
|
| 15 |
+
|
| 16 |
+
from .sampler import DEFAULT_TIER_MIX, Scene, iter_leaves, sample_scene
|
| 17 |
+
from .vocab import CANVAS, SUPERSAMPLE
|
| 18 |
+
|
| 19 |
+
_S = SUPERSAMPLE
|
| 20 |
+
_HI = CANVAS * _S
|
| 21 |
+
_BOX = getattr(getattr(Image, "Resampling", Image), "BOX")
|
| 22 |
+
|
| 23 |
+
_texture_cache: Dict[str, np.ndarray] = {}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _texture_mask(name: str) -> np.ndarray:
|
| 27 |
+
"""Boolean [256,256] pattern mask in canvas-aligned hi-res coordinates."""
|
| 28 |
+
if name in _texture_cache:
|
| 29 |
+
return _texture_cache[name]
|
| 30 |
+
yy, xx = np.mgrid[0:_HI, 0:_HI]
|
| 31 |
+
if name == "solid":
|
| 32 |
+
m = np.ones((_HI, _HI), dtype=bool)
|
| 33 |
+
elif name == "striped": # diagonal stripes, 2 canvas px on / 2 off
|
| 34 |
+
m = ((xx + yy) // (2 * _S)) % 2 == 0
|
| 35 |
+
elif name == "checker": # 2x2 canvas px checkerboard
|
| 36 |
+
m = ((xx // (2 * _S)) + (yy // (2 * _S))) % 2 == 0
|
| 37 |
+
elif name == "dotted": # dot grid, period 4 canvas px, dot radius ~1.3 px
|
| 38 |
+
p = 4 * _S
|
| 39 |
+
dx = (xx % p) - p / 2 + 0.5
|
| 40 |
+
dy = (yy % p) - p / 2 + 0.5
|
| 41 |
+
m = dx * dx + dy * dy <= (1.3 * _S) ** 2
|
| 42 |
+
else:
|
| 43 |
+
raise ValueError("unknown texture: {}".format(name))
|
| 44 |
+
_texture_cache[name] = m
|
| 45 |
+
return m
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _shape_mask(shape: str, bbox: Sequence[float]) -> np.ndarray:
|
| 49 |
+
"""Boolean [256,256] mask of `shape` drawn inside hi-res bbox."""
|
| 50 |
+
x0, y0, x1, y1 = (v * _S for v in bbox)
|
| 51 |
+
w, h = x1 - x0, y1 - y0
|
| 52 |
+
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
|
| 53 |
+
im = Image.new("L", (_HI, _HI), 0)
|
| 54 |
+
d = ImageDraw.Draw(im)
|
| 55 |
+
if shape == "circle":
|
| 56 |
+
d.ellipse([x0, y0, x1, y1], fill=255)
|
| 57 |
+
elif shape in ("square", "rectangle"):
|
| 58 |
+
d.rectangle([x0, y0, x1, y1], fill=255)
|
| 59 |
+
elif shape == "triangle":
|
| 60 |
+
d.polygon([(cx, y0), (x1, y1), (x0, y1)], fill=255)
|
| 61 |
+
elif shape == "diamond":
|
| 62 |
+
d.polygon([(cx, y0), (x1, cy), (cx, y1), (x0, cy)], fill=255)
|
| 63 |
+
elif shape == "star":
|
| 64 |
+
pts: List[Tuple[float, float]] = []
|
| 65 |
+
for k in range(10):
|
| 66 |
+
ang = -math.pi / 2 + k * math.pi / 5
|
| 67 |
+
r = 1.0 if k % 2 == 0 else 0.42
|
| 68 |
+
pts.append((cx + math.cos(ang) * r * w / 2, cy + math.sin(ang) * r * h / 2))
|
| 69 |
+
d.polygon(pts, fill=255)
|
| 70 |
+
elif shape == "cross":
|
| 71 |
+
tx, ty = w * 0.34, h * 0.34
|
| 72 |
+
d.rectangle([cx - tx / 2, y0, cx + tx / 2, y1], fill=255)
|
| 73 |
+
d.rectangle([x0, cy - ty / 2, x1, cy + ty / 2], fill=255)
|
| 74 |
+
elif shape == "ring":
|
| 75 |
+
d.ellipse([x0, y0, x1, y1], fill=255)
|
| 76 |
+
inset_x, inset_y = w * 0.275, h * 0.275
|
| 77 |
+
d.ellipse([x0 + inset_x, y0 + inset_y, x1 - inset_x, y1 - inset_y], fill=0)
|
| 78 |
+
else:
|
| 79 |
+
raise ValueError("unknown shape: {}".format(shape))
|
| 80 |
+
return np.asarray(im) > 0
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def render_scene(scene: Scene) -> np.ndarray:
|
| 84 |
+
"""Render a Scene to a [64,64,3] uint8 array."""
|
| 85 |
+
hi = np.empty((_HI, _HI, 3), dtype=np.uint8)
|
| 86 |
+
for leaf in iter_leaves(scene.tree):
|
| 87 |
+
x0, y0, x1, y1 = leaf["rect"]
|
| 88 |
+
hi[y0 * _S : y1 * _S, x0 * _S : x1 * _S] = np.asarray(
|
| 89 |
+
leaf["fill"], dtype=np.uint8
|
| 90 |
+
)
|
| 91 |
+
for obj in scene.objects:
|
| 92 |
+
mask = _shape_mask(obj["shape"], obj["bbox"])
|
| 93 |
+
tex = _texture_mask(obj["texture"])
|
| 94 |
+
rgb = np.asarray(obj["rgb"], dtype=np.float64)
|
| 95 |
+
main = rgb.astype(np.uint8)
|
| 96 |
+
dark = (rgb * 0.45).astype(np.uint8) # texture gaps: darker shade
|
| 97 |
+
hi[mask & tex] = main
|
| 98 |
+
hi[mask & ~tex] = dark
|
| 99 |
+
lo = Image.fromarray(hi).resize((CANVAS, CANVAS), resample=_BOX)
|
| 100 |
+
return np.asarray(lo, dtype=np.uint8)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def generate(
|
| 104 |
+
global_seed: int,
|
| 105 |
+
idx: int,
|
| 106 |
+
tier: Optional[int] = None,
|
| 107 |
+
tier_mix: Sequence[float] = DEFAULT_TIER_MIX,
|
| 108 |
+
) -> Tuple[Scene, np.ndarray]:
|
| 109 |
+
"""Convenience: sample scene `idx` and render it. Deterministic per idx."""
|
| 110 |
+
scene = sample_scene(global_seed, idx, tier=tier, tier_mix=tier_mix)
|
| 111 |
+
return scene, render_scene(scene)
|
sprig/data/procgen/sampler.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ground-truth BSP scene sampler.
|
| 2 |
+
|
| 3 |
+
Scenes are generated BY sampling a BSP region tree first (the tree is layout
|
| 4 |
+
sampler, renderer input, and parse-diagnostic label at once), then decorating
|
| 5 |
+
leaves with backgrounds / objects. The tree is constrained to the model
|
| 6 |
+
support of DESIGN.md §3:
|
| 7 |
+
|
| 8 |
+
- every cut lands on the 8-px grid with relative offset in [0.3, 0.7];
|
| 9 |
+
- leaf regions have both sides in {8, 16} px (any side > 16 must expand,
|
| 10 |
+
8x8 must terminate);
|
| 11 |
+
- object cells are 16x16 leaves holding exactly one shape, fully inside the
|
| 12 |
+
cell with >= 2 px margin — no overlap, no occlusion.
|
| 13 |
+
|
| 14 |
+
Tree JSON schema (serialized verbatim into meta.jsonl):
|
| 15 |
+
internal node: {"rect": [x0,y0,x1,y1], "axis": "V"|"H", "cut": px,
|
| 16 |
+
"children": [lo, hi]} # lo = left/top child
|
| 17 |
+
leaf node: {"rect": [x0,y0,x1,y1], "leaf": true, "obj": int|null,
|
| 18 |
+
"fill": [r,g,b]} # optional "frame": true
|
| 19 |
+
Tier-3 frame region nodes additionally carry {"frame_color": name}.
|
| 20 |
+
|
| 21 |
+
Object schema: {"shape","color","size","texture": str, "rgb": [r,g,b],
|
| 22 |
+
"cell": [x0,y0,x1,y1], "bbox": [x0,y0,x1,y1]}.
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
from dataclasses import dataclass, field
|
| 27 |
+
from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
|
| 31 |
+
from .vocab import (
|
| 32 |
+
BACKGROUNDS,
|
| 33 |
+
BACKGROUND_NAMES,
|
| 34 |
+
BG_JITTER,
|
| 35 |
+
CANVAS,
|
| 36 |
+
COLOR_JITTER,
|
| 37 |
+
COLOR_NAMES,
|
| 38 |
+
COLORS,
|
| 39 |
+
GRID,
|
| 40 |
+
HOLDOUT_COMBOS,
|
| 41 |
+
MAX_LEAF,
|
| 42 |
+
MIN_MARGIN,
|
| 43 |
+
OFFSET_HI,
|
| 44 |
+
OFFSET_LO,
|
| 45 |
+
SHAPES,
|
| 46 |
+
SIZE_NAMES,
|
| 47 |
+
SIZES,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
DEFAULT_TIER_MIX: Tuple[float, float, float, float] = (0.10, 0.30, 0.40, 0.20)
|
| 51 |
+
|
| 52 |
+
# termination probability for leaf-eligible regions in the generic grower
|
| 53 |
+
_TERM_P: Dict[Tuple[int, int], float] = {(16, 16): 0.75, (8, 16): 0.7, (16, 8): 0.7}
|
| 54 |
+
_MAX_TRIES = 200
|
| 55 |
+
_EPS = 1e-9
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass
|
| 59 |
+
class Scene:
|
| 60 |
+
"""One procedural scene. All fields are JSON-able plain python."""
|
| 61 |
+
|
| 62 |
+
idx: int
|
| 63 |
+
tier: int
|
| 64 |
+
tree: Dict[str, Any]
|
| 65 |
+
objects: List[Dict[str, Any]]
|
| 66 |
+
background: str # background name; "sky|sand" for tier-2 sky/ground scenes
|
| 67 |
+
relation: Optional[Dict[str, Any]] = None # tier 1: {"type","a","b"}
|
| 68 |
+
frame: Optional[Dict[str, Any]] = None # tier 3: {"color","rgb","rect","inner"}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# --------------------------------------------------------------------------
|
| 72 |
+
# rng helpers
|
| 73 |
+
# --------------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
+
def scene_rng(global_seed: int, idx: int) -> np.random.Generator:
|
| 76 |
+
"""The canonical per-sample rng stream: SeedSequence([global_seed, idx])."""
|
| 77 |
+
return np.random.default_rng(np.random.SeedSequence([int(global_seed), int(idx)]))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def caption_rng(global_seed: int, idx: int) -> np.random.Generator:
|
| 81 |
+
"""Independent per-sample stream for caption template draws."""
|
| 82 |
+
return np.random.default_rng(
|
| 83 |
+
np.random.SeedSequence([int(global_seed), int(idx), 1])
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# --------------------------------------------------------------------------
|
| 88 |
+
# tree construction
|
| 89 |
+
# --------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
def _mk_leaf(rect: Sequence[int]) -> Dict[str, Any]:
|
| 92 |
+
return {"rect": [int(v) for v in rect], "leaf": True, "obj": None, "fill": None}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _mk_node(
|
| 96 |
+
rect: Sequence[int], axis: str, cut: int, lo: Dict[str, Any], hi: Dict[str, Any]
|
| 97 |
+
) -> Dict[str, Any]:
|
| 98 |
+
return {
|
| 99 |
+
"rect": [int(v) for v in rect],
|
| 100 |
+
"axis": axis,
|
| 101 |
+
"cut": int(cut),
|
| 102 |
+
"children": [lo, hi],
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _split_rect(
|
| 107 |
+
rect: Sequence[int], axis: str, cut: int
|
| 108 |
+
) -> Tuple[Tuple[int, int, int, int], Tuple[int, int, int, int]]:
|
| 109 |
+
x0, y0, x1, y1 = rect
|
| 110 |
+
if axis == "V":
|
| 111 |
+
return (x0, y0, cut, y1), (cut, y0, x1, y1)
|
| 112 |
+
return (x0, y0, x1, cut), (x0, cut, x1, y1)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _valid_cuts(lo: int, hi: int) -> List[int]:
|
| 116 |
+
"""Grid cuts of interval [lo,hi) with relative offset in [0.3, 0.7]."""
|
| 117 |
+
length = hi - lo
|
| 118 |
+
out = []
|
| 119 |
+
for j in range(1, length // GRID):
|
| 120 |
+
t = j * GRID / length
|
| 121 |
+
if OFFSET_LO - _EPS <= t <= OFFSET_HI + _EPS:
|
| 122 |
+
out.append(lo + j * GRID)
|
| 123 |
+
return out
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _grow(
|
| 127 |
+
rng: np.random.Generator,
|
| 128 |
+
rect: Tuple[int, int, int, int],
|
| 129 |
+
force_axis: Optional[str] = None,
|
| 130 |
+
) -> Dict[str, Any]:
|
| 131 |
+
"""Sample a random in-support BSP subtree over `rect`.
|
| 132 |
+
|
| 133 |
+
`force_axis` forces the root of this subtree to split on that axis
|
| 134 |
+
(used for tier-2 sky/ground scenes).
|
| 135 |
+
"""
|
| 136 |
+
x0, y0, x1, y1 = rect
|
| 137 |
+
w, h = x1 - x0, y1 - y0
|
| 138 |
+
leaf_ok = w <= MAX_LEAF and h <= MAX_LEAF
|
| 139 |
+
if leaf_ok and force_axis is None:
|
| 140 |
+
if (w == GRID and h == GRID) or rng.random() < _TERM_P[(w, h)]:
|
| 141 |
+
return _mk_leaf(rect)
|
| 142 |
+
axes = []
|
| 143 |
+
if _valid_cuts(x0, x1):
|
| 144 |
+
axes.append("V")
|
| 145 |
+
if _valid_cuts(y0, y1):
|
| 146 |
+
axes.append("H")
|
| 147 |
+
if force_axis is not None:
|
| 148 |
+
assert force_axis in axes, "forced axis has no valid cut"
|
| 149 |
+
axes = [force_axis]
|
| 150 |
+
axis = axes[int(rng.integers(len(axes)))]
|
| 151 |
+
cuts = _valid_cuts(x0, x1) if axis == "V" else _valid_cuts(y0, y1)
|
| 152 |
+
cut = cuts[int(rng.integers(len(cuts)))]
|
| 153 |
+
rlo, rhi = _split_rect(rect, axis, cut)
|
| 154 |
+
return _mk_node(rect, axis, cut, _grow(rng, rlo), _grow(rng, rhi))
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def iter_leaves(node: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
|
| 158 |
+
"""Yield leaf node dicts (mutable references) in depth-first lo-hi order."""
|
| 159 |
+
if node.get("leaf"):
|
| 160 |
+
yield node
|
| 161 |
+
else:
|
| 162 |
+
for child in node["children"]:
|
| 163 |
+
for leaf in iter_leaves(child):
|
| 164 |
+
yield leaf
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _object_cells(node: Dict[str, Any]) -> List[Dict[str, Any]]:
|
| 168 |
+
"""Leaves big enough to hold an object (16x16, not frame cells)."""
|
| 169 |
+
out = []
|
| 170 |
+
for leaf in iter_leaves(node):
|
| 171 |
+
x0, y0, x1, y1 = leaf["rect"]
|
| 172 |
+
if x1 - x0 == MAX_LEAF and y1 - y0 == MAX_LEAF and not leaf.get("frame"):
|
| 173 |
+
out.append(leaf)
|
| 174 |
+
return out
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# --------------------------------------------------------------------------
|
| 178 |
+
# attribute / placement helpers
|
| 179 |
+
# --------------------------------------------------------------------------
|
| 180 |
+
|
| 181 |
+
def _jitter(rgb: Sequence[int], rng: np.random.Generator, amt: int) -> List[int]:
|
| 182 |
+
out = []
|
| 183 |
+
for v in rgb:
|
| 184 |
+
out.append(int(np.clip(int(v) + int(rng.integers(-amt, amt + 1)), 0, 255)))
|
| 185 |
+
return out
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _sample_attrs(
|
| 189 |
+
rng: np.random.Generator, exclude: Optional[set] = None
|
| 190 |
+
) -> Dict[str, str]:
|
| 191 |
+
"""Rejection-resample attributes so no holdout (color, shape) combo appears."""
|
| 192 |
+
exclude = exclude or set()
|
| 193 |
+
for _ in range(_MAX_TRIES):
|
| 194 |
+
color = COLOR_NAMES[int(rng.integers(len(COLOR_NAMES)))]
|
| 195 |
+
shape = SHAPES[int(rng.integers(len(SHAPES)))]
|
| 196 |
+
if (color, shape) in HOLDOUT_COMBOS or (color, shape) in exclude:
|
| 197 |
+
continue
|
| 198 |
+
size = SIZE_NAMES[int(rng.integers(len(SIZE_NAMES)))]
|
| 199 |
+
texture = "solid" if rng.random() < 0.4 else (
|
| 200 |
+
("striped", "dotted", "checker")[int(rng.integers(3))]
|
| 201 |
+
)
|
| 202 |
+
return {"shape": shape, "color": color, "size": size, "texture": texture}
|
| 203 |
+
raise RuntimeError("attribute rejection loop exhausted")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _place_object(
|
| 207 |
+
rng: np.random.Generator,
|
| 208 |
+
cell: Sequence[int],
|
| 209 |
+
attrs: Dict[str, str],
|
| 210 |
+
centered: bool = False,
|
| 211 |
+
) -> Dict[str, Any]:
|
| 212 |
+
x0, y0, x1, y1 = (float(v) for v in cell)
|
| 213 |
+
s = SIZES[attrs["size"]]
|
| 214 |
+
w = s
|
| 215 |
+
h = round(s * 0.62, 2) if attrs["shape"] == "rectangle" else s
|
| 216 |
+
slack_x = (x1 - x0 - w) / 2.0 - MIN_MARGIN
|
| 217 |
+
slack_y = (y1 - y0 - h) / 2.0 - MIN_MARGIN
|
| 218 |
+
assert slack_x >= -_EPS and slack_y >= -_EPS, "object does not fit cell"
|
| 219 |
+
cx = (x0 + x1) / 2.0
|
| 220 |
+
cy = (y0 + y1) / 2.0
|
| 221 |
+
if not centered:
|
| 222 |
+
cx += float(rng.uniform(-max(slack_x, 0.0), max(slack_x, 0.0)))
|
| 223 |
+
cy += float(rng.uniform(-max(slack_y, 0.0), max(slack_y, 0.0)))
|
| 224 |
+
bbox = [
|
| 225 |
+
round(cx - w / 2.0, 2),
|
| 226 |
+
round(cy - h / 2.0, 2),
|
| 227 |
+
round(cx + w / 2.0, 2),
|
| 228 |
+
round(cy + h / 2.0, 2),
|
| 229 |
+
]
|
| 230 |
+
return {
|
| 231 |
+
"shape": attrs["shape"],
|
| 232 |
+
"color": attrs["color"],
|
| 233 |
+
"size": attrs["size"],
|
| 234 |
+
"texture": attrs["texture"],
|
| 235 |
+
"rgb": _jitter(COLORS[attrs["color"]], rng, COLOR_JITTER),
|
| 236 |
+
"cell": [int(v) for v in cell],
|
| 237 |
+
"bbox": bbox,
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _apply_bg(tree: Dict[str, Any], bg_rgb: Sequence[int]) -> None:
|
| 242 |
+
for leaf in iter_leaves(tree):
|
| 243 |
+
if leaf["fill"] is None:
|
| 244 |
+
leaf["fill"] = list(bg_rgb)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _pick_bg(rng: np.random.Generator) -> Tuple[str, List[int]]:
|
| 248 |
+
name = BACKGROUND_NAMES[int(rng.integers(len(BACKGROUND_NAMES)))]
|
| 249 |
+
return name, _jitter(BACKGROUNDS[name], rng, BG_JITTER)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# --------------------------------------------------------------------------
|
| 253 |
+
# tier builders
|
| 254 |
+
# --------------------------------------------------------------------------
|
| 255 |
+
|
| 256 |
+
def _build_tier0(rng: np.random.Generator, idx: int) -> Scene:
|
| 257 |
+
for _ in range(_MAX_TRIES):
|
| 258 |
+
tree = _grow(rng, (0, 0, CANVAS, CANVAS))
|
| 259 |
+
cells = _object_cells(tree)
|
| 260 |
+
if cells:
|
| 261 |
+
break
|
| 262 |
+
else: # pragma: no cover
|
| 263 |
+
raise RuntimeError("tier0: no object cell after retries")
|
| 264 |
+
c = CANVAS / 2.0
|
| 265 |
+
cell = min(
|
| 266 |
+
cells,
|
| 267 |
+
key=lambda l: ((l["rect"][0] + l["rect"][2]) / 2 - c) ** 2
|
| 268 |
+
+ ((l["rect"][1] + l["rect"][3]) / 2 - c) ** 2,
|
| 269 |
+
)
|
| 270 |
+
obj = _place_object(rng, cell["rect"], _sample_attrs(rng), centered=True)
|
| 271 |
+
cell["obj"] = 0
|
| 272 |
+
bg_name, bg_rgb = _pick_bg(rng)
|
| 273 |
+
_apply_bg(tree, bg_rgb)
|
| 274 |
+
return Scene(idx=idx, tier=0, tree=tree, objects=[obj], background=bg_name)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _build_tier1(rng: np.random.Generator, idx: int) -> Scene:
|
| 278 |
+
for _ in range(_MAX_TRIES):
|
| 279 |
+
tree = _grow(rng, (0, 0, CANVAS, CANVAS))
|
| 280 |
+
cells_lo = _object_cells(tree["children"][0])
|
| 281 |
+
cells_hi = _object_cells(tree["children"][1])
|
| 282 |
+
if cells_lo and cells_hi:
|
| 283 |
+
break
|
| 284 |
+
else: # pragma: no cover
|
| 285 |
+
raise RuntimeError("tier1: no cells on both sides of root split")
|
| 286 |
+
cell_a = cells_lo[int(rng.integers(len(cells_lo)))]
|
| 287 |
+
cell_b = cells_hi[int(rng.integers(len(cells_hi)))]
|
| 288 |
+
attrs_a = _sample_attrs(rng)
|
| 289 |
+
attrs_b = _sample_attrs(rng, exclude={(attrs_a["color"], attrs_a["shape"])})
|
| 290 |
+
obj_a = _place_object(rng, cell_a["rect"], attrs_a)
|
| 291 |
+
obj_b = _place_object(rng, cell_b["rect"], attrs_b)
|
| 292 |
+
cell_a["obj"] = 0
|
| 293 |
+
cell_b["obj"] = 1
|
| 294 |
+
relation = {"type": "left" if tree["axis"] == "V" else "above", "a": 0, "b": 1}
|
| 295 |
+
bg_name, bg_rgb = _pick_bg(rng)
|
| 296 |
+
_apply_bg(tree, bg_rgb)
|
| 297 |
+
return Scene(
|
| 298 |
+
idx=idx,
|
| 299 |
+
tier=1,
|
| 300 |
+
tree=tree,
|
| 301 |
+
objects=[obj_a, obj_b],
|
| 302 |
+
background=bg_name,
|
| 303 |
+
relation=relation,
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _build_tier2(rng: np.random.Generator, idx: int) -> Scene:
|
| 308 |
+
n_obj = 3 + int(rng.integers(3)) # 3..5
|
| 309 |
+
skyground = rng.random() < 0.5
|
| 310 |
+
for _ in range(_MAX_TRIES):
|
| 311 |
+
tree = _grow(
|
| 312 |
+
rng, (0, 0, CANVAS, CANVAS), force_axis="H" if skyground else None
|
| 313 |
+
)
|
| 314 |
+
cells = _object_cells(tree)
|
| 315 |
+
if len(cells) >= n_obj:
|
| 316 |
+
break
|
| 317 |
+
else: # pragma: no cover
|
| 318 |
+
raise RuntimeError("tier2: not enough object cells")
|
| 319 |
+
order = rng.permutation(len(cells))[:n_obj]
|
| 320 |
+
objects: List[Dict[str, Any]] = []
|
| 321 |
+
used: set = set()
|
| 322 |
+
for i, ci in enumerate(order):
|
| 323 |
+
attrs = _sample_attrs(rng, exclude=used)
|
| 324 |
+
used.add((attrs["color"], attrs["shape"]))
|
| 325 |
+
cell = cells[int(ci)]
|
| 326 |
+
objects.append(_place_object(rng, cell["rect"], attrs))
|
| 327 |
+
cell["obj"] = i
|
| 328 |
+
if skyground:
|
| 329 |
+
cut = tree["cut"]
|
| 330 |
+
sky_rgb = _jitter(BACKGROUNDS["sky"], rng, BG_JITTER)
|
| 331 |
+
sand_rgb = _jitter(BACKGROUNDS["sand"], rng, BG_JITTER)
|
| 332 |
+
for leaf in iter_leaves(tree):
|
| 333 |
+
leaf["fill"] = list(sky_rgb) if leaf["rect"][3] <= cut else list(sand_rgb)
|
| 334 |
+
bg_name = "sky|sand"
|
| 335 |
+
else:
|
| 336 |
+
bg_name, bg_rgb = _pick_bg(rng)
|
| 337 |
+
_apply_bg(tree, bg_rgb)
|
| 338 |
+
return Scene(idx=idx, tier=2, tree=tree, objects=objects, background=bg_name)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def _frame_region_tree(
|
| 342 |
+
rng: np.random.Generator,
|
| 343 |
+
rect: Tuple[int, int, int, int],
|
| 344 |
+
frame_rgb: List[int],
|
| 345 |
+
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
| 346 |
+
"""Decompose a 40x40 region into frame cells around one inner 16x16 cell.
|
| 347 |
+
|
| 348 |
+
Per axis the strips are (16, 16, 8) or (8, 16, 16) so every cut keeps its
|
| 349 |
+
relative offset in [0.3, 0.7]. Returns (subtree, inner_leaf_ref).
|
| 350 |
+
"""
|
| 351 |
+
fx, fy, _, _ = rect
|
| 352 |
+
|
| 353 |
+
def frame_leaf(r: Sequence[int]) -> Dict[str, Any]:
|
| 354 |
+
leaf = _mk_leaf(r)
|
| 355 |
+
leaf["frame"] = True
|
| 356 |
+
leaf["fill"] = list(frame_rgb)
|
| 357 |
+
return leaf
|
| 358 |
+
|
| 359 |
+
def frame_subtree(r: Tuple[int, int, int, int]) -> Dict[str, Any]:
|
| 360 |
+
sub = _grow(rng, r)
|
| 361 |
+
for leaf in iter_leaves(sub):
|
| 362 |
+
leaf["frame"] = True
|
| 363 |
+
leaf["fill"] = list(frame_rgb)
|
| 364 |
+
return sub
|
| 365 |
+
|
| 366 |
+
f0x = 16 if rng.random() < 0.5 else 8
|
| 367 |
+
f0y = 16 if rng.random() < 0.5 else 8
|
| 368 |
+
ix0 = fx + f0x # inner cell x range [ix0, ix0+16]
|
| 369 |
+
iy0 = fy + f0y
|
| 370 |
+
|
| 371 |
+
# middle vertical strip (16 wide, 40 tall) split along y into
|
| 372 |
+
# top frame cell / inner object cell / bottom frame cell
|
| 373 |
+
inner = _mk_leaf((ix0, iy0, ix0 + 16, iy0 + 16))
|
| 374 |
+
top = frame_leaf((ix0, fy, ix0 + 16, iy0))
|
| 375 |
+
bot = frame_leaf((ix0, iy0 + 16, ix0 + 16, fy + 40))
|
| 376 |
+
strip_rect = (ix0, fy, ix0 + 16, fy + 40)
|
| 377 |
+
if f0y == 16:
|
| 378 |
+
lower = _mk_node((ix0, iy0, ix0 + 16, fy + 40), "H", iy0 + 16, inner, bot)
|
| 379 |
+
mid = _mk_node(strip_rect, "H", iy0, top, lower)
|
| 380 |
+
else:
|
| 381 |
+
upper = _mk_node((ix0, fy, ix0 + 16, iy0 + 16), "H", iy0, top, inner)
|
| 382 |
+
mid = _mk_node(strip_rect, "H", iy0 + 16, upper, bot)
|
| 383 |
+
|
| 384 |
+
left = frame_subtree((fx, fy, ix0, fy + 40))
|
| 385 |
+
right = frame_subtree((ix0 + 16, fy, fx + 40, fy + 40))
|
| 386 |
+
if f0x == 16:
|
| 387 |
+
rt = _mk_node((ix0, fy, fx + 40, fy + 40), "V", ix0 + 16, mid, right)
|
| 388 |
+
node = _mk_node(rect, "V", ix0, left, rt)
|
| 389 |
+
else:
|
| 390 |
+
lt = _mk_node((fx, fy, ix0 + 16, fy + 40), "V", ix0, left, mid)
|
| 391 |
+
node = _mk_node(rect, "V", ix0 + 16, lt, right)
|
| 392 |
+
return node, inner
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def _build_tier3(rng: np.random.Generator, idx: int) -> Scene:
|
| 396 |
+
fx = 0 if rng.random() < 0.5 else 24
|
| 397 |
+
fy = 0 if rng.random() < 0.5 else 24
|
| 398 |
+
frect = (fx, fy, fx + 40, fy + 40)
|
| 399 |
+
|
| 400 |
+
attrs = _sample_attrs(rng)
|
| 401 |
+
frame_color = attrs["color"]
|
| 402 |
+
while frame_color == attrs["color"]:
|
| 403 |
+
frame_color = COLOR_NAMES[int(rng.integers(len(COLOR_NAMES)))]
|
| 404 |
+
frame_rgb = _jitter(COLORS[frame_color], rng, COLOR_JITTER)
|
| 405 |
+
|
| 406 |
+
frame_node, inner = _frame_region_tree(rng, frect, frame_rgb)
|
| 407 |
+
frame_node["frame_color"] = frame_color
|
| 408 |
+
obj = _place_object(rng, inner["rect"], attrs)
|
| 409 |
+
inner["obj"] = 0
|
| 410 |
+
|
| 411 |
+
# isolate the frame region from the canvas root: one V cut, one H cut
|
| 412 |
+
# (in random order); both offsets (24/64, 40/64) are in [0.3, 0.7].
|
| 413 |
+
def isolate(rect: Tuple[int, int, int, int], axis: str, inner_node: Dict) -> Dict:
|
| 414 |
+
x0, y0, x1, y1 = rect
|
| 415 |
+
if axis == "V":
|
| 416 |
+
cut = fx + 40 if fx == 0 else fx
|
| 417 |
+
(rl, rh) = _split_rect(rect, "V", cut)
|
| 418 |
+
keep_lo = fx == 0
|
| 419 |
+
else:
|
| 420 |
+
cut = fy + 40 if fy == 0 else fy
|
| 421 |
+
(rl, rh) = _split_rect(rect, "H", cut)
|
| 422 |
+
keep_lo = fy == 0
|
| 423 |
+
other = _grow(rng, rh if keep_lo else rl)
|
| 424 |
+
lo = inner_node if keep_lo else other
|
| 425 |
+
hi = other if keep_lo else inner_node
|
| 426 |
+
return _mk_node(rect, axis, cut, lo, hi)
|
| 427 |
+
|
| 428 |
+
if rng.random() < 0.5:
|
| 429 |
+
band = isolate((fx, 0, fx + 40, CANVAS), "H", frame_node)
|
| 430 |
+
tree = isolate((0, 0, CANVAS, CANVAS), "V", band)
|
| 431 |
+
else:
|
| 432 |
+
band = isolate((0, fy, CANVAS, fy + 40), "V", frame_node)
|
| 433 |
+
tree = isolate((0, 0, CANVAS, CANVAS), "H", band)
|
| 434 |
+
|
| 435 |
+
bg_name, bg_rgb = _pick_bg(rng)
|
| 436 |
+
_apply_bg(tree, bg_rgb)
|
| 437 |
+
frame_info = {
|
| 438 |
+
"color": frame_color,
|
| 439 |
+
"rgb": list(frame_rgb),
|
| 440 |
+
"rect": list(frect),
|
| 441 |
+
"inner": list(inner["rect"]),
|
| 442 |
+
}
|
| 443 |
+
return Scene(
|
| 444 |
+
idx=idx,
|
| 445 |
+
tier=3,
|
| 446 |
+
tree=tree,
|
| 447 |
+
objects=[obj],
|
| 448 |
+
background=bg_name,
|
| 449 |
+
frame=frame_info,
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
_BUILDERS = {0: _build_tier0, 1: _build_tier1, 2: _build_tier2, 3: _build_tier3}
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
# --------------------------------------------------------------------------
|
| 457 |
+
# public API
|
| 458 |
+
# --------------------------------------------------------------------------
|
| 459 |
+
|
| 460 |
+
def sample_scene(
|
| 461 |
+
global_seed: int,
|
| 462 |
+
idx: int,
|
| 463 |
+
tier: Optional[int] = None,
|
| 464 |
+
tier_mix: Sequence[float] = DEFAULT_TIER_MIX,
|
| 465 |
+
) -> Scene:
|
| 466 |
+
"""Deterministically sample scene `idx` under `global_seed`.
|
| 467 |
+
|
| 468 |
+
If `tier` is None it is drawn from `tier_mix` (first rng draw, so the
|
| 469 |
+
scene is a pure function of (global_seed, idx, tier or tier_mix)).
|
| 470 |
+
"""
|
| 471 |
+
rng = scene_rng(global_seed, idx)
|
| 472 |
+
if tier is None:
|
| 473 |
+
p = np.asarray(tier_mix, dtype=np.float64)
|
| 474 |
+
p = p / p.sum()
|
| 475 |
+
tier = int(rng.choice(4, p=p))
|
| 476 |
+
return _BUILDERS[int(tier)](rng, int(idx))
|
sprig/data/procgen/vocab.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vocabulary and geometry constants for the procedural 2D scene generator.
|
| 2 |
+
|
| 3 |
+
Everything here is a plain constant so the sampler / renderer / captioner and
|
| 4 |
+
the eval agents (color anchor classification) share one source of truth.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from typing import Dict, Tuple
|
| 9 |
+
|
| 10 |
+
# --- geometry (must match the model lattice in DESIGN.md §2/§3) -------------
|
| 11 |
+
CANVAS: int = 64 # canvas side, px
|
| 12 |
+
GRID: int = 8 # model lattice stride, px — ALL cuts land on this grid
|
| 13 |
+
MAX_LEAF: int = 16 # model support: leaf regions have both sides <= 16 px
|
| 14 |
+
SUPERSAMPLE: int = 4 # render at 256x256, BOX-downsample to 64x64
|
| 15 |
+
OFFSET_LO: float = 0.3 # relative cut offset range (inside model support)
|
| 16 |
+
OFFSET_HI: float = 0.7
|
| 17 |
+
MIN_MARGIN: float = 2.0 # min px margin between a shape bbox and its cell walls
|
| 18 |
+
|
| 19 |
+
# --- object vocabulary -------------------------------------------------------
|
| 20 |
+
# 8 colors: name -> RGB anchor (renderer jitters around these; eval classifies
|
| 21 |
+
# back to nearest anchor in Lab space).
|
| 22 |
+
COLORS: Dict[str, Tuple[int, int, int]] = {
|
| 23 |
+
"red": (210, 45, 45),
|
| 24 |
+
"green": (55, 170, 60),
|
| 25 |
+
"blue": (50, 90, 215),
|
| 26 |
+
"yellow": (235, 215, 55),
|
| 27 |
+
"orange": (240, 145, 40),
|
| 28 |
+
"purple": (140, 60, 185),
|
| 29 |
+
"cyan": (60, 205, 215),
|
| 30 |
+
"magenta": (220, 70, 175),
|
| 31 |
+
}
|
| 32 |
+
COLOR_NAMES: Tuple[str, ...] = tuple(COLORS.keys())
|
| 33 |
+
|
| 34 |
+
SHAPES: Tuple[str, ...] = (
|
| 35 |
+
"circle", "square", "triangle", "rectangle", "diamond", "star", "cross", "ring",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# 2 sizes: name -> shape bbox side in px. Objects live in 16x16 leaf cells with
|
| 39 |
+
# >= MIN_MARGIN px of clearance: large 11 -> 2.5 px margin, small 6 -> 5 px.
|
| 40 |
+
SIZES: Dict[str, float] = {"small": 6.0, "large": 11.0}
|
| 41 |
+
SIZE_NAMES: Tuple[str, ...] = tuple(SIZES.keys())
|
| 42 |
+
|
| 43 |
+
TEXTURES: Tuple[str, ...] = ("solid", "striped", "dotted", "checker")
|
| 44 |
+
# caption adjective per texture ("" = no adjective for solid fills)
|
| 45 |
+
TEXTURE_ADJ: Dict[str, str] = {
|
| 46 |
+
"solid": "",
|
| 47 |
+
"striped": "striped",
|
| 48 |
+
"dotted": "dotted",
|
| 49 |
+
"checker": "checkered",
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
# 5 backgrounds: name -> RGB anchor. "sky"/"sand" are also used as the fixed
|
| 53 |
+
# pair for the tier-2 sky/ground root split.
|
| 54 |
+
BACKGROUNDS: Dict[str, Tuple[int, int, int]] = {
|
| 55 |
+
"white": (245, 245, 245),
|
| 56 |
+
"black": (28, 28, 28),
|
| 57 |
+
"gray": (128, 128, 128),
|
| 58 |
+
"sky": (170, 210, 240),
|
| 59 |
+
"sand": (205, 180, 130),
|
| 60 |
+
}
|
| 61 |
+
BACKGROUND_NAMES: Tuple[str, ...] = tuple(BACKGROUNDS.keys())
|
| 62 |
+
|
| 63 |
+
# per-channel uniform jitter amplitude applied to anchors at scene-sample time
|
| 64 |
+
COLOR_JITTER: int = 12
|
| 65 |
+
BG_JITTER: int = 8
|
| 66 |
+
|
| 67 |
+
# --- compositional holdout ---------------------------------------------------
|
| 68 |
+
# These (color, shape) combos NEVER appear in training scenes or captions
|
| 69 |
+
# (enforced by rejection-resampling in the sampler; scanned in tests/CI).
|
| 70 |
+
HOLDOUT_COMBOS: frozenset = frozenset(
|
| 71 |
+
{("blue", "triangle"), ("red", "ring"), ("green", "star"), ("yellow", "cross")}
|
| 72 |
+
)
|
sprig/data/procgen/writer.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Parallel pregeneration of the procedural dataset to raw memmaps.
|
| 2 |
+
|
| 3 |
+
Outputs in --out DIR:
|
| 4 |
+
images.u8 uint8 memmap [N,64,64,3]
|
| 5 |
+
meta.jsonl one JSON object per line:
|
| 6 |
+
{idx, tier, caption, template_id, partial, objects, tree}
|
| 7 |
+
meta_offsets.i64 int64 [N]: byte offset of the START of line i
|
| 8 |
+
tier_idx/tier{0..3}.i64 int64 sorted idx arrays per tier (curriculum)
|
| 9 |
+
|
| 10 |
+
Embeddings are NOT written here — sprig/data/embed_t5.py does that later.
|
| 11 |
+
|
| 12 |
+
CLI:
|
| 13 |
+
python -m sprig.data.procgen.writer --out DIR --n N --seed SEED \
|
| 14 |
+
--tier-mix "0.1,0.3,0.4,0.2" --workers K
|
| 15 |
+
|
| 16 |
+
Workers own disjoint contiguous row ranges; each writes its rows of the image
|
| 17 |
+
memmap plus a meta/tier shard, and the parent concatenates shards in order.
|
| 18 |
+
Every sample is a pure function of (seed, idx), so the output is independent
|
| 19 |
+
of the worker count.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import argparse
|
| 24 |
+
import json
|
| 25 |
+
import multiprocessing as mp
|
| 26 |
+
import os
|
| 27 |
+
import sys
|
| 28 |
+
import time
|
| 29 |
+
from typing import List, Optional, Sequence, Tuple
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
|
| 33 |
+
from .captions import sample_caption
|
| 34 |
+
from .render import render_scene
|
| 35 |
+
from .sampler import DEFAULT_TIER_MIX, caption_rng, sample_scene
|
| 36 |
+
|
| 37 |
+
IMG_SHAPE = (64, 64, 3)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _meta_part(out_dir: str, wid: int) -> str:
|
| 41 |
+
return os.path.join(out_dir, "meta.part{:03d}.jsonl".format(wid))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _tier_part(out_dir: str, wid: int) -> str:
|
| 45 |
+
return os.path.join(out_dir, "tiers.part{:03d}.i64".format(wid))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _generate_range(
|
| 49 |
+
out_dir: str,
|
| 50 |
+
n: int,
|
| 51 |
+
seed: int,
|
| 52 |
+
tier_mix: Sequence[float],
|
| 53 |
+
lo: int,
|
| 54 |
+
hi: int,
|
| 55 |
+
wid: int,
|
| 56 |
+
seed_start: int = 0,
|
| 57 |
+
) -> None:
|
| 58 |
+
"""Worker: generate rows [lo, hi) into the shared image memmap + shards.
|
| 59 |
+
|
| 60 |
+
Row idx is generated from scene index seed_start + idx, so different
|
| 61 |
+
splits get disjoint scene streams from disjoint seed ranges."""
|
| 62 |
+
images = np.memmap(
|
| 63 |
+
os.path.join(out_dir, "images.u8"), dtype=np.uint8, mode="r+",
|
| 64 |
+
shape=(n,) + IMG_SHAPE,
|
| 65 |
+
)
|
| 66 |
+
tiers = np.empty(hi - lo, dtype=np.int64)
|
| 67 |
+
with open(_meta_part(out_dir, wid), "w", encoding="utf-8") as f:
|
| 68 |
+
for idx in range(lo, hi):
|
| 69 |
+
sidx = seed_start + idx
|
| 70 |
+
scene = sample_scene(seed, sidx, tier_mix=tier_mix)
|
| 71 |
+
images[idx] = render_scene(scene)
|
| 72 |
+
cap = sample_caption(scene, caption_rng(seed, sidx), mode="train")
|
| 73 |
+
rec = {
|
| 74 |
+
"idx": idx,
|
| 75 |
+
"tier": scene.tier,
|
| 76 |
+
"caption": cap.text,
|
| 77 |
+
"template_id": cap.template_id,
|
| 78 |
+
"partial": cap.partial,
|
| 79 |
+
"objects": scene.objects,
|
| 80 |
+
"tree": scene.tree,
|
| 81 |
+
}
|
| 82 |
+
f.write(json.dumps(rec, separators=(",", ":")) + "\n")
|
| 83 |
+
tiers[idx - lo] = scene.tier
|
| 84 |
+
images.flush()
|
| 85 |
+
del images
|
| 86 |
+
tiers.tofile(_tier_part(out_dir, wid))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def write_dataset(
|
| 90 |
+
out_dir: str,
|
| 91 |
+
n: int,
|
| 92 |
+
seed: int = 0,
|
| 93 |
+
tier_mix: Sequence[float] = DEFAULT_TIER_MIX,
|
| 94 |
+
workers: int = 1,
|
| 95 |
+
seed_start: int = 0,
|
| 96 |
+
) -> None:
|
| 97 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 98 |
+
os.makedirs(os.path.join(out_dir, "tier_idx"), exist_ok=True)
|
| 99 |
+
workers = max(1, min(int(workers), n))
|
| 100 |
+
|
| 101 |
+
# preallocate the image memmap
|
| 102 |
+
images = np.memmap(
|
| 103 |
+
os.path.join(out_dir, "images.u8"), dtype=np.uint8, mode="w+",
|
| 104 |
+
shape=(n,) + IMG_SHAPE,
|
| 105 |
+
)
|
| 106 |
+
images.flush()
|
| 107 |
+
del images
|
| 108 |
+
|
| 109 |
+
# disjoint contiguous row ranges
|
| 110 |
+
bounds = np.linspace(0, n, workers + 1).astype(np.int64)
|
| 111 |
+
ranges: List[Tuple[int, int]] = [
|
| 112 |
+
(int(bounds[w]), int(bounds[w + 1])) for w in range(workers)
|
| 113 |
+
]
|
| 114 |
+
|
| 115 |
+
t0 = time.time()
|
| 116 |
+
if workers == 1:
|
| 117 |
+
_generate_range(out_dir, n, seed, tier_mix, 0, n, 0, seed_start)
|
| 118 |
+
else:
|
| 119 |
+
ctx = mp.get_context("spawn")
|
| 120 |
+
procs = []
|
| 121 |
+
for wid, (lo, hi) in enumerate(ranges):
|
| 122 |
+
p = ctx.Process(
|
| 123 |
+
target=_generate_range,
|
| 124 |
+
args=(out_dir, n, seed, tuple(tier_mix), lo, hi, wid, seed_start),
|
| 125 |
+
)
|
| 126 |
+
p.start()
|
| 127 |
+
procs.append(p)
|
| 128 |
+
for p in procs:
|
| 129 |
+
p.join()
|
| 130 |
+
failed = [p.exitcode for p in procs if p.exitcode != 0]
|
| 131 |
+
if failed:
|
| 132 |
+
raise RuntimeError("worker(s) failed with exit codes {}".format(failed))
|
| 133 |
+
|
| 134 |
+
# concatenate meta shards in order, recording line-start byte offsets
|
| 135 |
+
offsets = np.empty(n, dtype=np.int64)
|
| 136 |
+
tiers = np.empty(n, dtype=np.int64)
|
| 137 |
+
pos = 0
|
| 138 |
+
row = 0
|
| 139 |
+
with open(os.path.join(out_dir, "meta.jsonl"), "wb") as out:
|
| 140 |
+
for wid in range(workers):
|
| 141 |
+
with open(_meta_part(out_dir, wid), "rb") as part:
|
| 142 |
+
for line in part:
|
| 143 |
+
offsets[row] = pos
|
| 144 |
+
out.write(line)
|
| 145 |
+
pos += len(line)
|
| 146 |
+
row += 1
|
| 147 |
+
tiers_part = np.fromfile(_tier_part(out_dir, wid), dtype=np.int64)
|
| 148 |
+
lo, hi = ranges[wid]
|
| 149 |
+
tiers[lo:hi] = tiers_part
|
| 150 |
+
os.remove(_meta_part(out_dir, wid))
|
| 151 |
+
os.remove(_tier_part(out_dir, wid))
|
| 152 |
+
assert row == n, "meta line count {} != n {}".format(row, n)
|
| 153 |
+
offsets.tofile(os.path.join(out_dir, "meta_offsets.i64"))
|
| 154 |
+
for t in range(4):
|
| 155 |
+
idxs = np.nonzero(tiers == t)[0].astype(np.int64)
|
| 156 |
+
idxs.tofile(os.path.join(out_dir, "tier_idx", "tier{}.i64".format(t)))
|
| 157 |
+
|
| 158 |
+
counts = [int((tiers == t).sum()) for t in range(4)]
|
| 159 |
+
print(
|
| 160 |
+
"wrote {} samples to {} in {:.1f}s (tier counts: {})".format(
|
| 161 |
+
n, out_dir, time.time() - t0, counts
|
| 162 |
+
)
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _parse_tier_mix(s: str) -> Tuple[float, ...]:
|
| 167 |
+
parts = tuple(float(v) for v in s.split(","))
|
| 168 |
+
if len(parts) != 4 or min(parts) < 0 or sum(parts) <= 0:
|
| 169 |
+
raise argparse.ArgumentTypeError(
|
| 170 |
+
"--tier-mix must be 4 nonnegative comma-separated floats"
|
| 171 |
+
)
|
| 172 |
+
return parts
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def main(argv: Optional[Sequence[str]] = None) -> None:
|
| 176 |
+
ap = argparse.ArgumentParser(description="Pregenerate the proc2d dataset.")
|
| 177 |
+
ap.add_argument("--out", required=True, help="output directory")
|
| 178 |
+
ap.add_argument("--n", type=int, required=True, help="number of samples")
|
| 179 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 180 |
+
ap.add_argument(
|
| 181 |
+
"--tier-mix", type=_parse_tier_mix,
|
| 182 |
+
default=DEFAULT_TIER_MIX, help='e.g. "0.1,0.3,0.4,0.2"',
|
| 183 |
+
)
|
| 184 |
+
ap.add_argument("--workers", type=int, default=1)
|
| 185 |
+
ap.add_argument("--seed-start", type=int, default=0,
|
| 186 |
+
help="scene-index offset (disjoint ranges per split)")
|
| 187 |
+
args = ap.parse_args(argv)
|
| 188 |
+
write_dataset(args.out, args.n, args.seed, args.tier_mix, args.workers,
|
| 189 |
+
seed_start=args.seed_start)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
if __name__ == "__main__":
|
| 193 |
+
main(sys.argv[1:])
|
sprig/dp/__init__.py
ADDED
|
File without changes
|
sprig/dp/inside.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inside DP over the finite region lattice (DESIGN.md section 5).
|
| 2 |
+
|
| 3 |
+
Exposes the three DP entry points SPRIGModel calls through its adapter
|
| 4 |
+
(sprig.model.sprig._call_dp), all keyed by the DESIGN section 5 argument
|
| 5 |
+
names plus ``log_PT`` (the [R, T_v] log texel prior table the leaf T-mix
|
| 6 |
+
needs; p(T|A,c) = logsumexp_k(U_logmix[..,A,k] + log_PT[k,T])):
|
| 7 |
+
|
| 8 |
+
inside(ell_leaf, term_logits, cut_logits, U_logmix, logV, logW,
|
| 9 |
+
lattice, temper_kappa, log_PT) -> (beta [B,N_reg,S] fp32, logZ [B])
|
| 10 |
+
inside_logZ(...) -> alias of ``inside``
|
| 11 |
+
viterbi(...) -> (score [B], trees)
|
| 12 |
+
posterior_marginals(...) -> dict of expected-count tensors
|
| 13 |
+
|
| 14 |
+
Semantics (matching tests/fixtures_dp_stub.py, the brute-force-validated
|
| 15 |
+
reference):
|
| 16 |
+
* beta(r, A) for leaf-eligible r combines
|
| 17 |
+
log p_term(r,A) + logsumexp_T(log p(T|A,c) + ell(r,T)/kappa(r))
|
| 18 |
+
with
|
| 19 |
+
log(1 - p_term(r,A)) + expand(r,A),
|
| 20 |
+
where must_terminate regions force p_term = 1 and must_expand regions
|
| 21 |
+
(any side > leaf_max) expand with probability 1 (no p_term factor).
|
| 22 |
+
* expand(r, A) is computed by a level-synchronous sweep in cell-area
|
| 23 |
+
ascending order using the lattice's flattened per-level cut tables;
|
| 24 |
+
cut-type probability mass is split uniformly across same-type concrete
|
| 25 |
+
cuts (the precomputed -log(count) correction).
|
| 26 |
+
* logZ[b] = beta[b, root, 0] (axiom nonterminal = symbol 0).
|
| 27 |
+
|
| 28 |
+
Numerics: all log-domain accumulation in fp32; every logsumexp/logbmm is
|
| 29 |
+
max-shifted with *detached* shifts. -1e30 sentinels are never fed through
|
| 30 |
+
branches on the differentiable path (they only seed the beta table and the
|
| 31 |
+
scatter-max identity); leaf/expand branches are combined by mask + index so
|
| 32 |
+
the module is safe under double backward (SPRIGModel.loss uses
|
| 33 |
+
create_graph=True through the DP for the under-use hinges).
|
| 34 |
+
|
| 35 |
+
Every function accepts ``exact=True`` to replace the max-shifted
|
| 36 |
+
exp/matmul/log fast path with a direct fp32 logsumexp — the exact fallback
|
| 37 |
+
DESIGN section 5 asks for (used by the parity tests).
|
| 38 |
+
"""
|
| 39 |
+
from __future__ import annotations
|
| 40 |
+
|
| 41 |
+
from typing import Dict, List, Optional, Tuple
|
| 42 |
+
|
| 43 |
+
import torch
|
| 44 |
+
import torch.nn.functional as F
|
| 45 |
+
|
| 46 |
+
NEG = -1e30
|
| 47 |
+
# Clamp floor for post-shift exp-sums before the final log. It must be large
|
| 48 |
+
# enough that log's DOUBLE backward (-grad/x^2) stays finite in fp32
|
| 49 |
+
# (x^2 >= 1e-30 > fp32 tiny), because SPRIGModel.loss differentiates the
|
| 50 |
+
# under-use hinges through the DP with create_graph=True. Floor 1e-15 caps a
|
| 51 |
+
# reduction at ~34.5 nats below the factored max-shifts, which only rounds up
|
| 52 |
+
# paths of negligible posterior mass. (The segment-logsumexp sums are >= 1 by
|
| 53 |
+
# construction and never hit the clamp.)
|
| 54 |
+
_TINY = 1e-15
|
| 55 |
+
# Element budget for the [B, chunk, S, R] / [B, chunk, S, T_v] blocks the
|
| 56 |
+
# max-semiring (argmax) passes materialize.
|
| 57 |
+
_MAX_SEMIRING_BUDGET = 1 << 24
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def logbmm(x_log: torch.Tensor, w_log: torch.Tensor, exact: bool = False) -> torch.Tensor:
|
| 61 |
+
"""out[..., m] = logsumexp_k(x_log[..., k] + w_log[k, m]).
|
| 62 |
+
|
| 63 |
+
x_log [..., K] and w_log [K, M], both finite fp32. Max-shifted (detached
|
| 64 |
+
shifts), exp, matmul, log, shifts added back. ``exact=True`` is the direct
|
| 65 |
+
fp32 logsumexp fallback for tests.
|
| 66 |
+
"""
|
| 67 |
+
x_log = x_log.float()
|
| 68 |
+
w_log = w_log.float()
|
| 69 |
+
if exact:
|
| 70 |
+
expand_shape = (1,) * (x_log.dim() - 1) + tuple(w_log.shape)
|
| 71 |
+
return torch.logsumexp(x_log.unsqueeze(-1) + w_log.view(expand_shape), dim=-2)
|
| 72 |
+
sx = x_log.max(dim=-1, keepdim=True).values.detach() # [..., 1]
|
| 73 |
+
sw = w_log.max(dim=0, keepdim=True).values.detach() # [1, M]
|
| 74 |
+
out = torch.matmul(torch.exp(x_log - sx), torch.exp(w_log - sw))
|
| 75 |
+
return torch.log(out.clamp(min=_TINY)) + sx + sw
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def logbmm_batched(x_log: torch.Tensor, w_log: torch.Tensor, exact: bool = False) -> torch.Tensor:
|
| 79 |
+
"""out[b, p, m] = logsumexp_k(x_log[b, p, k] + w_log[b, k, m]).
|
| 80 |
+
|
| 81 |
+
Batched-weight variant of :func:`logbmm` (x [B, P, K], w [B, K, M])."""
|
| 82 |
+
x_log = x_log.float()
|
| 83 |
+
w_log = w_log.float()
|
| 84 |
+
if exact:
|
| 85 |
+
return torch.logsumexp(x_log.unsqueeze(-1) + w_log.unsqueeze(1), dim=-2)
|
| 86 |
+
sx = x_log.max(dim=-1, keepdim=True).values.detach() # [B, P, 1]
|
| 87 |
+
sw = w_log.max(dim=1, keepdim=True).values.detach() # [B, 1, M]
|
| 88 |
+
out = torch.bmm(torch.exp(x_log - sx), torch.exp(w_log - sw))
|
| 89 |
+
return torch.log(out.clamp(min=_TINY)) + sx + sw
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _segment_logsumexp(
|
| 93 |
+
x: torch.Tensor, seg_index: torch.Tensor, n_seg: int
|
| 94 |
+
) -> torch.Tensor:
|
| 95 |
+
"""Group-by-segment logsumexp along dim 1: x [B, M, R], seg_index [M]
|
| 96 |
+
-> [B, n_seg, R]. Max-shift per segment (detached), scatter-add of exp,
|
| 97 |
+
log."""
|
| 98 |
+
B, M, R = x.shape
|
| 99 |
+
idx = seg_index.view(1, -1, 1).expand(B, M, R)
|
| 100 |
+
mx = torch.full((B, n_seg, R), NEG, device=x.device, dtype=x.dtype).scatter_reduce(
|
| 101 |
+
1, idx, x.detach(), reduce="amax", include_self=True
|
| 102 |
+
)
|
| 103 |
+
ex = torch.exp(x - mx[:, seg_index, :])
|
| 104 |
+
ssum = torch.zeros(B, n_seg, R, device=x.device, dtype=x.dtype).index_add_(
|
| 105 |
+
1, seg_index, ex
|
| 106 |
+
)
|
| 107 |
+
return mx + torch.log(ssum.clamp(min=_TINY))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _row_cut_logp(cut_logits: torch.Tensor, lattice, lv, exact: bool = False) -> torch.Tensor:
|
| 111 |
+
"""Per concrete cut row: masked + renormalized cut-type log-prob with the
|
| 112 |
+
uniform same-type mass split -> [B, M, R].
|
| 113 |
+
|
| 114 |
+
cut_logits [B, R, 14]; validity mask per unique parent from
|
| 115 |
+
lattice.type_present; log-softmax over the masked set; -log(count)
|
| 116 |
+
correction per row (lv.log_cnt). (Fallback path — the sweep normally uses
|
| 117 |
+
:func:`_cut_logp_flat` + one gather per level.)"""
|
| 118 |
+
del exact # the masked log-softmax is already exact fp32
|
| 119 |
+
B, R = cut_logits.shape[0], cut_logits.shape[1]
|
| 120 |
+
M = lv.parent_ids.shape[0]
|
| 121 |
+
pres = lattice.type_present[lv.parents] # [P, 14]
|
| 122 |
+
tl = cut_logits.float().unsqueeze(1).masked_fill(
|
| 123 |
+
~pres.view(1, -1, 1, pres.shape[-1]), float("-inf")
|
| 124 |
+
) # [B, P, R, 14]
|
| 125 |
+
tls = F.log_softmax(tl, dim=-1)
|
| 126 |
+
sel = tls[:, lv.parent_index, :, :] # [B, M, R, 14]
|
| 127 |
+
idx = lv.cut_type.view(1, -1, 1, 1).expand(B, M, R, 1)
|
| 128 |
+
return sel.gather(-1, idx).squeeze(-1) - lv.log_cnt.view(1, -1, 1)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _cut_logp_flat(cut_logits: torch.Tensor, lattice) -> Optional[torch.Tensor]:
|
| 132 |
+
"""Masked + renormalized cut-type log-probs for every DISTINCT
|
| 133 |
+
type-presence pattern, flattened for per-level gathering:
|
| 134 |
+
|
| 135 |
+
out [B, n_pat * 14, R]; row(pattern p, type t) = out[:, p*14 + t, :].
|
| 136 |
+
|
| 137 |
+
One log-softmax per sweep instead of one per level; identical values (the
|
| 138 |
+
per-parent mask in :func:`_row_cut_logp` only depends on the parent's
|
| 139 |
+
type-presence pattern). Returns None when the lattice does not carry the
|
| 140 |
+
precomputed pattern tables (foreign/stub lattices -> per-level fallback).
|
| 141 |
+
"""
|
| 142 |
+
pats = getattr(lattice, "type_patterns", None)
|
| 143 |
+
if pats is None:
|
| 144 |
+
return None
|
| 145 |
+
B, R = cut_logits.shape[0], cut_logits.shape[1]
|
| 146 |
+
n_pat, T = pats.shape
|
| 147 |
+
tl = cut_logits.float().unsqueeze(1).masked_fill(
|
| 148 |
+
~pats.view(1, n_pat, 1, T), float("-inf")
|
| 149 |
+
) # [B, n_pat, R, 14]
|
| 150 |
+
tls = F.log_softmax(tl, dim=-1)
|
| 151 |
+
return tls.permute(0, 1, 3, 2).reshape(B, n_pat * T, R)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _level_cut_logp(
|
| 155 |
+
tls_flat: Optional[torch.Tensor], cut_logits: torch.Tensor, lattice, lv,
|
| 156 |
+
) -> torch.Tensor:
|
| 157 |
+
"""Per-row cut log-prob [B, M, R] for one level (gather from the per-sweep
|
| 158 |
+
flat table when available, else the per-level fallback)."""
|
| 159 |
+
if tls_flat is not None and getattr(lv, "flat_type_idx", None) is not None:
|
| 160 |
+
return tls_flat[:, lv.flat_type_idx, :] - lv.log_cnt.view(1, -1, 1)
|
| 161 |
+
return _row_cut_logp(cut_logits, lattice, lv)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _mt_meta(lattice) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 165 |
+
"""(must-terminate region ids, their leaf slots) — precomputed on the
|
| 166 |
+
lattice when available (no per-call ``nonzero``)."""
|
| 167 |
+
rid_mt = getattr(lattice, "mt_ids", None)
|
| 168 |
+
if rid_mt is None:
|
| 169 |
+
rid_mt = torch.nonzero(lattice.must_terminate, as_tuple=False).reshape(-1)
|
| 170 |
+
slot_mt = getattr(lattice, "mt_slots", None)
|
| 171 |
+
if slot_mt is None:
|
| 172 |
+
slot_mt = lattice.leaf_index_of_region[rid_mt]
|
| 173 |
+
return rid_mt, slot_mt
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _sweep_meta(lattice) -> List[Tuple]:
|
| 177 |
+
"""Per-level (has_leaf, has_nonleaf, leaf_pos, nonleaf_pos, leaf_rids,
|
| 178 |
+
nonleaf_rids, leaf_slots). Reads the tensors precomputed at lattice build;
|
| 179 |
+
falls back to computing them (with syncs) for foreign/stub lattices."""
|
| 180 |
+
metas: List[Tuple] = []
|
| 181 |
+
for lv in lattice.levels:
|
| 182 |
+
if getattr(lv, "leaf_pos", None) is not None:
|
| 183 |
+
metas.append((lv.has_leaf, lv.has_nonleaf, lv.leaf_pos,
|
| 184 |
+
lv.nonleaf_pos, lv.leaf_rids, lv.nonleaf_rids,
|
| 185 |
+
lv.leaf_slots))
|
| 186 |
+
else:
|
| 187 |
+
is_leaf = lattice.leaf_mask[lv.parents]
|
| 188 |
+
leaf_pos = torch.nonzero(is_leaf, as_tuple=False).reshape(-1)
|
| 189 |
+
nonleaf_pos = torch.nonzero(~is_leaf, as_tuple=False).reshape(-1)
|
| 190 |
+
leaf_rids = lv.parents[leaf_pos]
|
| 191 |
+
nonleaf_rids = lv.parents[nonleaf_pos]
|
| 192 |
+
leaf_slots = lattice.leaf_index_of_region[leaf_rids]
|
| 193 |
+
metas.append((bool(leaf_pos.numel() > 0), bool(nonleaf_pos.numel() > 0),
|
| 194 |
+
leaf_pos, nonleaf_pos, leaf_rids, nonleaf_rids, leaf_slots))
|
| 195 |
+
return metas
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _leaf_terms(
|
| 199 |
+
ell_leaf: torch.Tensor,
|
| 200 |
+
term_logits: torch.Tensor,
|
| 201 |
+
U_logmix: torch.Tensor,
|
| 202 |
+
log_PT: torch.Tensor,
|
| 203 |
+
lattice,
|
| 204 |
+
temper_kappa: torch.Tensor,
|
| 205 |
+
term_mark: Optional[torch.Tensor] = None,
|
| 206 |
+
semiring: str = "sum",
|
| 207 |
+
exact: bool = False,
|
| 208 |
+
) -> Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]:
|
| 209 |
+
"""Termination scores for every leaf-eligible region.
|
| 210 |
+
|
| 211 |
+
Returns (term_score [B, n_leaf, S], log_cont [B, n_leaf, S], argT).
|
| 212 |
+
term_score = log p_term + mix_T(log p(T|A,c) + ell/kappa) where mix is
|
| 213 |
+
logsumexp (sum semiring) or max (max semiring; argT returned).
|
| 214 |
+
must_terminate: log p_term = 0, log(1 - p_term) = NEG sentinel (never fed
|
| 215 |
+
through logaddexp — the caller indexes around it).
|
| 216 |
+
"""
|
| 217 |
+
prior = logbmm(U_logmix.float(), log_PT.float(), exact=exact) # [B, S, T_v]
|
| 218 |
+
ell_t = ell_leaf.float() / temper_kappa.float().view(1, -1, 1) # [B, n_leaf, T_v]
|
| 219 |
+
if semiring == "sum":
|
| 220 |
+
mix = logbmm_batched(ell_t, prior.transpose(1, 2), exact=exact) # [B, n_leaf, S]
|
| 221 |
+
argT = None
|
| 222 |
+
else:
|
| 223 |
+
B, n_leaf, T_v = ell_t.shape
|
| 224 |
+
S = prior.shape[1]
|
| 225 |
+
step = max(1, _MAX_SEMIRING_BUDGET // max(1, B * S * T_v))
|
| 226 |
+
mixes, args = [], []
|
| 227 |
+
for i0 in range(0, n_leaf, step):
|
| 228 |
+
scores = prior.unsqueeze(1) + ell_t[:, i0 : i0 + step].unsqueeze(2)
|
| 229 |
+
m, a = scores.max(dim=-1) # [B, chunk, S]
|
| 230 |
+
mixes.append(m)
|
| 231 |
+
args.append(a)
|
| 232 |
+
mix = torch.cat(mixes, dim=1)
|
| 233 |
+
argT = torch.cat(args, dim=1)
|
| 234 |
+
lt = term_logits.float()[:, lattice.leaf_ids, :]
|
| 235 |
+
log_term = F.logsigmoid(lt)
|
| 236 |
+
log_cont = F.logsigmoid(-lt)
|
| 237 |
+
mt = lattice.must_terminate[lattice.leaf_ids].view(1, -1, 1)
|
| 238 |
+
log_term = torch.where(mt, torch.zeros_like(log_term), log_term)
|
| 239 |
+
log_cont = torch.where(mt, torch.full_like(log_cont, NEG), log_cont)
|
| 240 |
+
term_score = log_term + mix
|
| 241 |
+
if term_mark is not None:
|
| 242 |
+
term_score = term_score + term_mark[:, lattice.leaf_ids, :]
|
| 243 |
+
return term_score, log_cont, argT
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _sweep(
|
| 247 |
+
ell_leaf: torch.Tensor,
|
| 248 |
+
term_logits: torch.Tensor,
|
| 249 |
+
cut_logits: torch.Tensor,
|
| 250 |
+
U_logmix: torch.Tensor,
|
| 251 |
+
logV: torch.Tensor,
|
| 252 |
+
logW: torch.Tensor,
|
| 253 |
+
lattice,
|
| 254 |
+
temper_kappa: torch.Tensor,
|
| 255 |
+
log_PT: torch.Tensor,
|
| 256 |
+
term_mark: Optional[torch.Tensor] = None,
|
| 257 |
+
expand_mark: Optional[torch.Tensor] = None,
|
| 258 |
+
cut_mark: Optional[torch.Tensor] = None,
|
| 259 |
+
exact: bool = False,
|
| 260 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 261 |
+
"""Level-synchronous inside sweep -> (beta [B, N_reg, S] fp32, logZ [B]).
|
| 262 |
+
|
| 263 |
+
All shape-dependent decisions (leaf/non-leaf membership per level,
|
| 264 |
+
must-terminate ids, cut-type masks) come from lattice-precomputed index
|
| 265 |
+
tensors and python bools so the loop issues no CPU-GPU syncs (no
|
| 266 |
+
``bool(tensor)``, no bool-mask advanced indexing, no ``nonzero``).
|
| 267 |
+
|
| 268 |
+
Two equivalent implementations: the fast sweep (contiguous-id lattices,
|
| 269 |
+
i.e. every Lattice built by sprig.dp.lattice) keeps per-level beta blocks
|
| 270 |
+
in a list and maintains the R-compressed child tables
|
| 271 |
+
Blo[b,r,k] = logsumexp_B(beta[b,r,B] + logV[k,B]) (Bhi with logW)
|
| 272 |
+
so the per-level child gathers and writes touch [B,N,R] instead of
|
| 273 |
+
[B,N,S] — identical values (the compression is exactly the logbmm the
|
| 274 |
+
reference applies per cut row, hoisted per region) but ~S/R times less
|
| 275 |
+
index/index_put traffic, which dominates the DP's backward. The reference
|
| 276 |
+
sweep is kept for foreign/stub lattices."""
|
| 277 |
+
B, _N, S = term_logits.shape
|
| 278 |
+
device = ell_leaf.device
|
| 279 |
+
logV = logV.float()
|
| 280 |
+
logW = logW.float()
|
| 281 |
+
U_log = U_logmix.float()
|
| 282 |
+
|
| 283 |
+
term_score, log_cont, _ = _leaf_terms(
|
| 284 |
+
ell_leaf, term_logits, U_log, log_PT, lattice, temper_kappa,
|
| 285 |
+
term_mark=term_mark, semiring="sum", exact=exact,
|
| 286 |
+
)
|
| 287 |
+
tls_flat = _cut_logp_flat(cut_logits, lattice)
|
| 288 |
+
U_log_t = U_log.transpose(1, 2)
|
| 289 |
+
rid_mt, slot_mt = _mt_meta(lattice)
|
| 290 |
+
|
| 291 |
+
if getattr(lattice, "contiguous_ids", False):
|
| 292 |
+
return _sweep_fast(
|
| 293 |
+
term_score, log_cont, cut_logits, U_log_t, logV, logW, lattice,
|
| 294 |
+
tls_flat, slot_mt, expand_mark, cut_mark, exact,
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
beta = torch.full((B, lattice.n_regions, S), NEG, device=device)
|
| 298 |
+
beta[:, rid_mt, :] = term_score[:, slot_mt, :]
|
| 299 |
+
|
| 300 |
+
row_offset = 0
|
| 301 |
+
for lv, meta in zip(lattice.levels, _sweep_meta(lattice)):
|
| 302 |
+
has_leaf, has_nonleaf, leaf_pos, nonleaf_pos, leaf_rids, nonleaf_rids, leaf_slots = meta
|
| 303 |
+
M = lv.parent_ids.shape[0]
|
| 304 |
+
P = lv.parents.shape[0]
|
| 305 |
+
# Bhat[b,m,k] = logsumexp_B(beta[b, child_lo[m], B] + logV[k, B]); Chat same with logW.
|
| 306 |
+
Bhat = logbmm(beta[:, lv.child_lo, :], logV.t(), exact=exact) # [B, M, R]
|
| 307 |
+
Chat = logbmm(beta[:, lv.child_hi, :], logW.t(), exact=exact)
|
| 308 |
+
contrib = _level_cut_logp(tls_flat, cut_logits, lattice, lv) + Bhat + Chat
|
| 309 |
+
if cut_mark is not None:
|
| 310 |
+
contrib = contrib + cut_mark[:, row_offset : row_offset + M].unsqueeze(-1)
|
| 311 |
+
comb = _segment_logsumexp(contrib, lv.parent_index, P) # [B, P, R]
|
| 312 |
+
# expand[b,p,A] = logsumexp_k(comb[b,p,k] + U_log[b,A,k])
|
| 313 |
+
expand = logbmm_batched(comb, U_log_t, exact=exact) # [B, P, S]
|
| 314 |
+
if expand_mark is not None:
|
| 315 |
+
expand = expand + expand_mark[:, lv.parents, :]
|
| 316 |
+
|
| 317 |
+
# Combine the terminate/expand branches. Only finite values may enter
|
| 318 |
+
# the log-add: with the -1e30 sentinel (and equally with legitimate
|
| 319 |
+
# log-domain gaps > ~88 nats once emissions sharpen) torch.logaddexp's
|
| 320 |
+
# first-order backward materializes exp(b - a) = inf nodes whose
|
| 321 |
+
# double-backward (create_graph=True in SPRIGModel.loss) is NaN — so
|
| 322 |
+
# mask/index the branches and use a detached-max shift, which keeps
|
| 323 |
+
# every exp argument <= 0 and the log argument in [1, 2].
|
| 324 |
+
if has_nonleaf:
|
| 325 |
+
beta[:, nonleaf_rids, :] = expand[:, nonleaf_pos, :]
|
| 326 |
+
if has_leaf:
|
| 327 |
+
a = term_score[:, leaf_slots, :]
|
| 328 |
+
b = log_cont[:, leaf_slots, :] + expand[:, leaf_pos, :]
|
| 329 |
+
m = torch.maximum(a, b).detach()
|
| 330 |
+
beta[:, leaf_rids, :] = m + torch.log(torch.exp(a - m) + torch.exp(b - m))
|
| 331 |
+
row_offset += M
|
| 332 |
+
|
| 333 |
+
logZ = beta[:, lattice.root_id, 0]
|
| 334 |
+
return beta, logZ
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def _combine_branches(
|
| 338 |
+
expand: torch.Tensor,
|
| 339 |
+
term_score: torch.Tensor,
|
| 340 |
+
log_cont: torch.Tensor,
|
| 341 |
+
lv,
|
| 342 |
+
) -> torch.Tensor:
|
| 343 |
+
"""Terminate/expand combination for one level -> beta block [B, P, S] in
|
| 344 |
+
parent order. Same guarded log-add as the reference sweep (see the long
|
| 345 |
+
comment there); rows are assembled with a precomputed permutation gather
|
| 346 |
+
instead of index_put (cheaper backward, no clone of the block)."""
|
| 347 |
+
if not lv.has_leaf:
|
| 348 |
+
return expand
|
| 349 |
+
a = term_score[:, lv.leaf_slots, :]
|
| 350 |
+
b = log_cont[:, lv.leaf_slots, :] + expand[:, lv.leaf_pos, :]
|
| 351 |
+
m = torch.maximum(a, b).detach()
|
| 352 |
+
leaf_val = m + torch.log(torch.exp(a - m) + torch.exp(b - m))
|
| 353 |
+
if not lv.has_nonleaf:
|
| 354 |
+
return leaf_val
|
| 355 |
+
both = torch.cat([expand[:, lv.nonleaf_pos, :], leaf_val], dim=1)
|
| 356 |
+
return both[:, lv.reorder, :]
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _sweep_fast(
|
| 360 |
+
term_score: torch.Tensor,
|
| 361 |
+
log_cont: torch.Tensor,
|
| 362 |
+
cut_logits: torch.Tensor,
|
| 363 |
+
U_log_t: torch.Tensor,
|
| 364 |
+
logV: torch.Tensor,
|
| 365 |
+
logW: torch.Tensor,
|
| 366 |
+
lattice,
|
| 367 |
+
tls_flat: Optional[torch.Tensor],
|
| 368 |
+
slot_mt: torch.Tensor,
|
| 369 |
+
expand_mark: Optional[torch.Tensor],
|
| 370 |
+
cut_mark: Optional[torch.Tensor],
|
| 371 |
+
exact: bool,
|
| 372 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 373 |
+
"""Fast inside sweep over contiguous-id lattices (see _sweep docstring)."""
|
| 374 |
+
B, S = term_score.shape[0], term_score.shape[-1]
|
| 375 |
+
R = logV.shape[0]
|
| 376 |
+
N = lattice.n_regions
|
| 377 |
+
device = term_score.device
|
| 378 |
+
|
| 379 |
+
base = term_score[:, slot_mt, :] # [B, n_mt, S] == ids [0, n_mt)
|
| 380 |
+
parts = [base]
|
| 381 |
+
Blo = torch.full((B, N, R), NEG, device=device)
|
| 382 |
+
Bhi = torch.full((B, N, R), NEG, device=device)
|
| 383 |
+
n_mt = base.shape[1]
|
| 384 |
+
Blo[:, :n_mt, :] = logbmm(base, logV.t(), exact=exact)
|
| 385 |
+
Bhi[:, :n_mt, :] = logbmm(base, logW.t(), exact=exact)
|
| 386 |
+
|
| 387 |
+
row_offset = 0
|
| 388 |
+
for lv in lattice.levels:
|
| 389 |
+
M = lv.parent_ids.shape[0]
|
| 390 |
+
P = lv.parents.shape[0]
|
| 391 |
+
Bhat = Blo[:, lv.child_lo, :] # [B, M, R]
|
| 392 |
+
Chat = Bhi[:, lv.child_hi, :]
|
| 393 |
+
contrib = _level_cut_logp(tls_flat, cut_logits, lattice, lv) + Bhat + Chat
|
| 394 |
+
if cut_mark is not None:
|
| 395 |
+
contrib = contrib + cut_mark[:, row_offset : row_offset + M].unsqueeze(-1)
|
| 396 |
+
comb = _segment_logsumexp(contrib, lv.parent_index, P) # [B, P, R]
|
| 397 |
+
expand = logbmm_batched(comb, U_log_t, exact=exact) # [B, P, S]
|
| 398 |
+
if expand_mark is not None:
|
| 399 |
+
expand = expand + expand_mark[:, lv.parents, :]
|
| 400 |
+
val = _combine_branches(expand, term_score, log_cont, lv) # [B, P, S]
|
| 401 |
+
parts.append(val)
|
| 402 |
+
Blo[:, lv.id_lo : lv.id_hi, :] = logbmm(val, logV.t(), exact=exact)
|
| 403 |
+
Bhi[:, lv.id_lo : lv.id_hi, :] = logbmm(val, logW.t(), exact=exact)
|
| 404 |
+
row_offset += M
|
| 405 |
+
|
| 406 |
+
beta = torch.cat(parts, dim=1) # region-id order
|
| 407 |
+
logZ = beta[:, lattice.root_id, 0]
|
| 408 |
+
return beta, logZ
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def inside(
|
| 412 |
+
ell_leaf: torch.Tensor,
|
| 413 |
+
term_logits: torch.Tensor,
|
| 414 |
+
cut_logits: torch.Tensor,
|
| 415 |
+
U_logmix: torch.Tensor,
|
| 416 |
+
logV: torch.Tensor,
|
| 417 |
+
logW: torch.Tensor,
|
| 418 |
+
lattice,
|
| 419 |
+
temper_kappa: torch.Tensor,
|
| 420 |
+
log_PT: torch.Tensor,
|
| 421 |
+
exact: bool = False,
|
| 422 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 423 |
+
"""DESIGN section 5 inside DP -> (beta [B, N_reg, S] fp32, logZ [B])."""
|
| 424 |
+
return _sweep(
|
| 425 |
+
ell_leaf, term_logits, cut_logits, U_logmix, logV, logW, lattice,
|
| 426 |
+
temper_kappa, log_PT, exact=exact,
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
# DESIGN.md section 5 names the function `inside`; SPRIGModel's adapter looks
|
| 431 |
+
# for `inside_logZ` first — keep both bound to the same implementation.
|
| 432 |
+
inside_logZ = inside
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def posterior_marginals(
|
| 436 |
+
ell_leaf: torch.Tensor,
|
| 437 |
+
term_logits: torch.Tensor,
|
| 438 |
+
cut_logits: torch.Tensor,
|
| 439 |
+
U_logmix: torch.Tensor,
|
| 440 |
+
logV: torch.Tensor,
|
| 441 |
+
logW: torch.Tensor,
|
| 442 |
+
lattice,
|
| 443 |
+
temper_kappa: torch.Tensor,
|
| 444 |
+
log_PT: torch.Tensor,
|
| 445 |
+
exact: bool = False,
|
| 446 |
+
) -> Dict[str, torch.Tensor]:
|
| 447 |
+
"""Expected-count posterior marginals via the autograd identity.
|
| 448 |
+
|
| 449 |
+
Zero-valued marker potentials are added on the terminate branch, the
|
| 450 |
+
expand branch, and every concrete cut row; grad of logZ w.r.t. a marker
|
| 451 |
+
equals the posterior expected count of that event. Returns detached
|
| 452 |
+
tensors: node/term/expand [B, N_reg, S], cut [B, M_total] (levels
|
| 453 |
+
concatenated in lattice.levels row order), texel [B, n_leaf, T_v]
|
| 454 |
+
(grad w.r.t. ell rescaled by kappa), rule [B, S, R] (grad w.r.t.
|
| 455 |
+
U_logmix), logZ [B].
|
| 456 |
+
"""
|
| 457 |
+
B, N, S = term_logits.shape
|
| 458 |
+
device = ell_leaf.device
|
| 459 |
+
M_total = sum(int(lv.parent_ids.shape[0]) for lv in lattice.levels)
|
| 460 |
+
ell_d = ell_leaf.detach().requires_grad_(True)
|
| 461 |
+
U_d = U_logmix.detach().requires_grad_(True)
|
| 462 |
+
tm = torch.zeros(B, N, S, device=device, requires_grad=True)
|
| 463 |
+
em = torch.zeros(B, N, S, device=device, requires_grad=True)
|
| 464 |
+
cm = torch.zeros(B, M_total, device=device, requires_grad=True)
|
| 465 |
+
with torch.enable_grad():
|
| 466 |
+
_, logZ = _sweep(
|
| 467 |
+
ell_d, term_logits.detach(), cut_logits.detach(), U_d,
|
| 468 |
+
logV.detach(), logW.detach(), lattice,
|
| 469 |
+
temper_kappa.detach(), log_PT.detach(),
|
| 470 |
+
term_mark=tm, expand_mark=em, cut_mark=cm, exact=exact,
|
| 471 |
+
)
|
| 472 |
+
g_tm, g_em, g_cm, g_ell, g_u = torch.autograd.grad(
|
| 473 |
+
logZ.sum(), [tm, em, cm, ell_d, U_d]
|
| 474 |
+
)
|
| 475 |
+
texel = g_ell * temper_kappa.detach().float().view(1, -1, 1)
|
| 476 |
+
return {
|
| 477 |
+
"logZ": logZ.detach(),
|
| 478 |
+
"node": (g_tm + g_em).detach(),
|
| 479 |
+
"term": g_tm.detach(),
|
| 480 |
+
"expand": g_em.detach(),
|
| 481 |
+
"cut": g_cm.detach(),
|
| 482 |
+
"texel": texel.detach(),
|
| 483 |
+
"rule": g_u.detach(),
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def viterbi(
|
| 488 |
+
ell_leaf: torch.Tensor,
|
| 489 |
+
term_logits: torch.Tensor,
|
| 490 |
+
cut_logits: torch.Tensor,
|
| 491 |
+
U_logmix: torch.Tensor,
|
| 492 |
+
logV: torch.Tensor,
|
| 493 |
+
logW: torch.Tensor,
|
| 494 |
+
lattice,
|
| 495 |
+
temper_kappa: torch.Tensor,
|
| 496 |
+
log_PT: torch.Tensor,
|
| 497 |
+
exact: bool = False,
|
| 498 |
+
) -> Tuple[torch.Tensor, List[Tuple]]:
|
| 499 |
+
"""Max-semiring sweep + argmax backtrace.
|
| 500 |
+
|
| 501 |
+
Returns (score [B], trees): trees[b] is a nested tuple
|
| 502 |
+
(region_id, symbol, texel_or_None, axis_or_None, cut_px_or_None,
|
| 503 |
+
(children...)) rooted at (lattice.root_id, symbol 0). The texel T-mix is
|
| 504 |
+
a max; the texel prior p(T|A,c) itself stays an exact sum over k.
|
| 505 |
+
"""
|
| 506 |
+
with torch.no_grad():
|
| 507 |
+
B, N, S = term_logits.shape
|
| 508 |
+
R = U_logmix.shape[-1]
|
| 509 |
+
device = ell_leaf.device
|
| 510 |
+
U_log = U_logmix.float()
|
| 511 |
+
logV_f = logV.float()
|
| 512 |
+
logW_f = logW.float()
|
| 513 |
+
term_score, log_cont, argT = _leaf_terms(
|
| 514 |
+
ell_leaf, term_logits, U_log, log_PT, lattice, temper_kappa,
|
| 515 |
+
semiring="max", exact=exact,
|
| 516 |
+
)
|
| 517 |
+
beta = torch.full((B, N, S), NEG, device=device)
|
| 518 |
+
term_choice = torch.zeros(B, N, S, dtype=torch.bool, device=device)
|
| 519 |
+
leaf_slot = lattice.leaf_index_of_region
|
| 520 |
+
rid_mt, slot_mt = _mt_meta(lattice)
|
| 521 |
+
beta[:, rid_mt, :] = term_score[:, slot_mt, :]
|
| 522 |
+
term_choice[:, rid_mt, :] = True
|
| 523 |
+
|
| 524 |
+
tls_flat = _cut_logp_flat(cut_logits, lattice)
|
| 525 |
+
metas = _sweep_meta(lattice)
|
| 526 |
+
bp: List[Dict[str, torch.Tensor]] = []
|
| 527 |
+
parent_pos: Dict[int, Tuple[int, int]] = {}
|
| 528 |
+
for li, lv in enumerate(lattice.levels):
|
| 529 |
+
for p_i, r in enumerate(lv.parents.tolist()):
|
| 530 |
+
parent_pos[r] = (li, p_i)
|
| 531 |
+
M = lv.parent_ids.shape[0]
|
| 532 |
+
P = lv.parents.shape[0]
|
| 533 |
+
|
| 534 |
+
# Per-row maxima over child symbols and over components, chunked
|
| 535 |
+
# over rows to bound the [B, chunk, R, S] / [B, chunk, S, R]
|
| 536 |
+
# materializations.
|
| 537 |
+
step = max(1, _MAX_SEMIRING_BUDGET // max(1, B * S * R))
|
| 538 |
+
cutlp = _level_cut_logp(tls_flat, cut_logits, lattice, lv) # [B, M, R]
|
| 539 |
+
argB = torch.empty(B, M, R, dtype=torch.int64, device=device)
|
| 540 |
+
argC = torch.empty(B, M, R, dtype=torch.int64, device=device)
|
| 541 |
+
argk = torch.empty(B, M, S, dtype=torch.int64, device=device)
|
| 542 |
+
tmax = torch.empty(B, M, S, device=device)
|
| 543 |
+
for m0 in range(0, M, step):
|
| 544 |
+
m1 = min(M, m0 + step)
|
| 545 |
+
lo_b = beta[:, lv.child_lo[m0:m1], :] # [B, c, S]
|
| 546 |
+
hi_b = beta[:, lv.child_hi[m0:m1], :]
|
| 547 |
+
Bv, aB = (lo_b.unsqueeze(2) + logV_f.view(1, 1, R, S)).max(dim=-1)
|
| 548 |
+
Cv, aC = (hi_b.unsqueeze(2) + logW_f.view(1, 1, R, S)).max(dim=-1)
|
| 549 |
+
argB[:, m0:m1] = aB
|
| 550 |
+
argC[:, m0:m1] = aC
|
| 551 |
+
contrib = cutlp[:, m0:m1] + Bv + Cv # [B, c, R]
|
| 552 |
+
t = contrib.unsqueeze(2) + U_log.unsqueeze(1) # [B, c, S, R]
|
| 553 |
+
tm_c, ak = t.max(dim=-1)
|
| 554 |
+
tmax[:, m0:m1] = tm_c
|
| 555 |
+
argk[:, m0:m1] = ak
|
| 556 |
+
|
| 557 |
+
# Group-max over the rows of each parent; ties -> lowest row index.
|
| 558 |
+
pidx = lv.parent_index.view(1, -1, 1).expand(B, M, S)
|
| 559 |
+
pmax = torch.full((B, P, S), NEG, device=device).scatter_reduce(
|
| 560 |
+
1, pidx, tmax, reduce="amax", include_self=True
|
| 561 |
+
)
|
| 562 |
+
rowidx = torch.arange(M, device=device).view(1, -1, 1).expand(B, M, S)
|
| 563 |
+
cand = torch.where(
|
| 564 |
+
tmax >= pmax[:, lv.parent_index, :], rowidx,
|
| 565 |
+
torch.full_like(rowidx, M),
|
| 566 |
+
)
|
| 567 |
+
argm = torch.full((B, P, S), M, dtype=torch.int64, device=device).scatter_reduce(
|
| 568 |
+
1, pidx, cand, reduce="amin", include_self=True
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
+
rid = lv.parents
|
| 572 |
+
has_leaf, _hn, leaf_pos, _np, _lr, _nr, leaf_slots = metas[li]
|
| 573 |
+
cont = torch.zeros(B, P, S, device=device)
|
| 574 |
+
tb = torch.full((B, P, S), NEG, device=device)
|
| 575 |
+
if has_leaf:
|
| 576 |
+
cont[:, leaf_pos, :] = log_cont[:, leaf_slots, :]
|
| 577 |
+
tb[:, leaf_pos, :] = term_score[:, leaf_slots, :]
|
| 578 |
+
exp_v = cont + pmax
|
| 579 |
+
beta[:, rid, :] = torch.maximum(tb, exp_v)
|
| 580 |
+
term_choice[:, rid, :] = tb >= exp_v
|
| 581 |
+
bp.append({"argB": argB, "argC": argC, "argk": argk, "argm": argm})
|
| 582 |
+
|
| 583 |
+
score = beta[:, lattice.root_id, 0]
|
| 584 |
+
|
| 585 |
+
def extract(b: int, rid: int, sym: int) -> Tuple:
|
| 586 |
+
if bool(term_choice[b, rid, sym]):
|
| 587 |
+
slot = int(leaf_slot[rid])
|
| 588 |
+
tex = int(argT[b, slot, sym])
|
| 589 |
+
return (rid, sym, tex, None, None, ())
|
| 590 |
+
li, p_i = parent_pos[rid]
|
| 591 |
+
lv = lattice.levels[li]
|
| 592 |
+
m = int(bp[li]["argm"][b, p_i, sym])
|
| 593 |
+
k = int(bp[li]["argk"][b, m, sym])
|
| 594 |
+
b_sym = int(bp[li]["argB"][b, m, k])
|
| 595 |
+
c_sym = int(bp[li]["argC"][b, m, k])
|
| 596 |
+
lo, hi = int(lv.child_lo[m]), int(lv.child_hi[m])
|
| 597 |
+
axis, px = int(lv.cut_axis[m]), int(lv.cut_px[m])
|
| 598 |
+
return (
|
| 599 |
+
rid, sym, None, axis, px,
|
| 600 |
+
(extract(b, lo, b_sym), extract(b, hi, c_sym)),
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
trees = [extract(b, lattice.root_id, 0) for b in range(B)]
|
| 604 |
+
return score, trees
|
sprig/dp/lattice.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Finite region lattice for the SPRIG inside DP.
|
| 2 |
+
|
| 3 |
+
Regions are axis-aligned rectangles whose corners lie on a fixed pixel grid.
|
| 4 |
+
Every cell-interval pair is a region; every interior grid line of a region is
|
| 5 |
+
a valid cut producing two child regions. See DESIGN.md section 3.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import math
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Dict, List, Optional, Tuple
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
|
| 15 |
+
N_CUT_TYPES = 14 # 2 axes x 7 relative-offset buckets {1/8 .. 7/8}
|
| 16 |
+
_OFFSET_BUCKETS = [i / 8.0 for i in range(1, 8)]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _bucket(rel: float) -> int:
|
| 20 |
+
return min(range(7), key=lambda i: abs(_OFFSET_BUCKETS[i] - rel))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def cut_type_id(axis: int, rel: float) -> int:
|
| 24 |
+
"""axis: 0 = vertical cut (splits x), 1 = horizontal cut (splits y)."""
|
| 25 |
+
return axis * 7 + _bucket(rel)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class LatticeLevel:
|
| 30 |
+
"""Flattened cut tables for all parent regions of one cell-area level."""
|
| 31 |
+
parent_ids: torch.Tensor # int64 [M]
|
| 32 |
+
cut_type: torch.Tensor # int64 [M] global cut-type id
|
| 33 |
+
log_cnt: torch.Tensor # fp32 [M] log(#same-type cuts in this region)
|
| 34 |
+
child_lo: torch.Tensor # int64 [M] lesser-coordinate child region id
|
| 35 |
+
child_hi: torch.Tensor # int64 [M]
|
| 36 |
+
cut_axis: torch.Tensor # int64 [M] 0=v, 1=h
|
| 37 |
+
cut_px: torch.Tensor # int64 [M] absolute cut coordinate in px
|
| 38 |
+
parents: torch.Tensor # int64 [P] unique parent ids at this level
|
| 39 |
+
parent_index: torch.Tensor # int64 [M] index into `parents` for scatter
|
| 40 |
+
# Precomputed sweep metadata (pure functions of the lattice; filled in by
|
| 41 |
+
# Lattice.__post_init__ so the inside DP never branches on device tensors
|
| 42 |
+
# or bool-mask-indexes inside the per-level loop — those force CPU-GPU
|
| 43 |
+
# syncs). Optional so externally-built levels stay constructible;
|
| 44 |
+
# sprig.dp.inside falls back to computing them on the fly if absent.
|
| 45 |
+
leaf_pos: Optional[torch.Tensor] = None # int64 [Pl] positions in `parents` that are leaf-eligible
|
| 46 |
+
nonleaf_pos: Optional[torch.Tensor] = None # int64 [Pn]
|
| 47 |
+
leaf_rids: Optional[torch.Tensor] = None # int64 [Pl] = parents[leaf_pos]
|
| 48 |
+
nonleaf_rids: Optional[torch.Tensor] = None # int64 [Pn]
|
| 49 |
+
leaf_slots: Optional[torch.Tensor] = None # int64 [Pl] leaf_index_of_region[leaf_rids]
|
| 50 |
+
flat_type_idx: Optional[torch.Tensor] = None # int64 [M] pattern_of_region[parent]*14 + cut_type
|
| 51 |
+
has_leaf: bool = False
|
| 52 |
+
has_nonleaf: bool = False
|
| 53 |
+
reorder: Optional[torch.Tensor] = None # int64 [P]: cat(nonleaf, leaf) -> parent order
|
| 54 |
+
id_lo: int = 0 # parents == arange(id_lo, id_hi) when the
|
| 55 |
+
id_hi: int = 0 # lattice has contiguous_ids (see Lattice)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass
|
| 59 |
+
class Lattice:
|
| 60 |
+
canvas_px: int
|
| 61 |
+
grid_px: int
|
| 62 |
+
leaf_max_px: int
|
| 63 |
+
|
| 64 |
+
regions: torch.Tensor = field(init=False) # int64 [N,4] x0,y0,x1,y1 (px)
|
| 65 |
+
region_id: Dict[Tuple[int, int, int, int], int] = field(init=False)
|
| 66 |
+
leaf_mask: torch.Tensor = field(init=False) # bool [N]
|
| 67 |
+
must_terminate: torch.Tensor = field(init=False) # bool [N]
|
| 68 |
+
must_expand: torch.Tensor = field(init=False) # bool [N]
|
| 69 |
+
area_px: torch.Tensor = field(init=False) # fp32 [N]
|
| 70 |
+
phi_geom: torch.Tensor = field(init=False) # fp32 [N,64]
|
| 71 |
+
type_present: torch.Tensor = field(init=False) # bool [N,14]
|
| 72 |
+
levels: List[LatticeLevel] = field(init=False) # area-ascending, only levels with cuts
|
| 73 |
+
root_id: int = field(init=False)
|
| 74 |
+
leaf_ids: torch.Tensor = field(init=False) # int64 [NL] leaf-eligible region ids
|
| 75 |
+
leaf_index_of_region: torch.Tensor = field(init=False) # int64 [N] (-1 if not leaf)
|
| 76 |
+
cuts_of_region: Dict[int, List[Tuple[int, int, int, int, int, float]]] = field(init=False)
|
| 77 |
+
# cuts_of_region[r] = list of (axis, px, child_lo, child_hi, type_id, log_cnt)
|
| 78 |
+
# Precomputed sweep/emission metadata (see __post_init__).
|
| 79 |
+
mt_ids: torch.Tensor = field(init=False) # int64 [n_mt] must-terminate region ids
|
| 80 |
+
mt_slots: torch.Tensor = field(init=False) # int64 [n_mt] their leaf slots
|
| 81 |
+
type_patterns: torch.Tensor = field(init=False) # bool [n_pat, 14] distinct type_present rows
|
| 82 |
+
pattern_of_region: torch.Tensor = field(init=False) # int64 [N] (-1 if region has no cuts)
|
| 83 |
+
shape_groups: List[Tuple[int, int, torch.Tensor, torch.Tensor]] = field(init=False)
|
| 84 |
+
contiguous_ids: bool = field(init=False) # id blocks are (mt, level0, level1, ...)
|
| 85 |
+
|
| 86 |
+
def __post_init__(self) -> None:
|
| 87 |
+
g, c = self.grid_px, self.canvas_px
|
| 88 |
+
assert c % g == 0
|
| 89 |
+
n_cells = c // g
|
| 90 |
+
lines = [i * g for i in range(n_cells + 1)]
|
| 91 |
+
|
| 92 |
+
rects: List[Tuple[int, int, int, int]] = []
|
| 93 |
+
for x0 in range(n_cells):
|
| 94 |
+
for x1 in range(x0 + 1, n_cells + 1):
|
| 95 |
+
for y0 in range(n_cells):
|
| 96 |
+
for y1 in range(y0 + 1, n_cells + 1):
|
| 97 |
+
rects.append((lines[x0], lines[y0], lines[x1], lines[y1]))
|
| 98 |
+
rects.sort(key=lambda r: ((r[2] - r[0]) * (r[3] - r[1]), r))
|
| 99 |
+
self.region_id = {r: i for i, r in enumerate(rects)}
|
| 100 |
+
self.regions = torch.tensor(rects, dtype=torch.int64)
|
| 101 |
+
N = len(rects)
|
| 102 |
+
|
| 103 |
+
w = self.regions[:, 2] - self.regions[:, 0]
|
| 104 |
+
h = self.regions[:, 3] - self.regions[:, 1]
|
| 105 |
+
self.leaf_mask = (w <= self.leaf_max_px) & (h <= self.leaf_max_px)
|
| 106 |
+
self.must_terminate = (w == g) & (h == g)
|
| 107 |
+
self.must_expand = (w > self.leaf_max_px) | (h > self.leaf_max_px)
|
| 108 |
+
self.area_px = (w * h).to(torch.float32)
|
| 109 |
+
self.root_id = self.region_id[(0, 0, c, c)]
|
| 110 |
+
|
| 111 |
+
self.leaf_ids = torch.nonzero(self.leaf_mask, as_tuple=False).squeeze(1)
|
| 112 |
+
self.leaf_index_of_region = torch.full((N,), -1, dtype=torch.int64)
|
| 113 |
+
self.leaf_index_of_region[self.leaf_ids] = torch.arange(len(self.leaf_ids))
|
| 114 |
+
|
| 115 |
+
# Fourier geometry features (log-area, log-aspect, center x/y) -> 64 dims.
|
| 116 |
+
la = torch.log(self.area_px / (c * c))
|
| 117 |
+
lasp = torch.log(w.float() / h.float())
|
| 118 |
+
cx = (self.regions[:, 0] + self.regions[:, 2]).float() / (2 * c)
|
| 119 |
+
cy = (self.regions[:, 1] + self.regions[:, 3]).float() / (2 * c)
|
| 120 |
+
base = torch.stack([la / 8.0, lasp / 4.0, cx, cy], dim=1) # [N,4]
|
| 121 |
+
freqs = torch.tensor([1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 3.0, 5.0]) # 8 freqs
|
| 122 |
+
ang = base.unsqueeze(2) * freqs.view(1, 1, 8) * math.pi # [N,4,8]
|
| 123 |
+
self.phi_geom = torch.cat([torch.sin(ang), torch.cos(ang)], dim=2).reshape(N, 64)
|
| 124 |
+
|
| 125 |
+
# Enumerate cuts.
|
| 126 |
+
self.cuts_of_region = {}
|
| 127 |
+
self.type_present = torch.zeros(N, N_CUT_TYPES, dtype=torch.bool)
|
| 128 |
+
per_level: Dict[int, List[Tuple[int, int, int, float, int, int, int]]] = {}
|
| 129 |
+
for rid, (x0, y0, x1, y1) in enumerate(rects):
|
| 130 |
+
cuts: List[Tuple[int, int, int, int, int, float]] = []
|
| 131 |
+
type_counts: Dict[int, int] = {}
|
| 132 |
+
cand: List[Tuple[int, int, int, int, int]] = [] # axis, px, lo, hi, type
|
| 133 |
+
for px in range(x0 + g, x1, g): # vertical cuts
|
| 134 |
+
lo = self.region_id[(x0, y0, px, y1)]
|
| 135 |
+
hi = self.region_id[(px, y0, x1, y1)]
|
| 136 |
+
t = cut_type_id(0, (px - x0) / (x1 - x0))
|
| 137 |
+
cand.append((0, px, lo, hi, t))
|
| 138 |
+
type_counts[t] = type_counts.get(t, 0) + 1
|
| 139 |
+
for py in range(y0 + g, y1, g): # horizontal cuts
|
| 140 |
+
lo = self.region_id[(x0, y0, x1, py)]
|
| 141 |
+
hi = self.region_id[(x0, py, x1, y1)]
|
| 142 |
+
t = cut_type_id(1, (py - y0) / (y1 - y0))
|
| 143 |
+
cand.append((1, py, lo, hi, t))
|
| 144 |
+
type_counts[t] = type_counts.get(t, 0) + 1
|
| 145 |
+
for axis, px, lo, hi, t in cand:
|
| 146 |
+
lc = math.log(type_counts[t])
|
| 147 |
+
cuts.append((axis, px, lo, hi, t, lc))
|
| 148 |
+
self.type_present[rid, t] = True
|
| 149 |
+
self.cuts_of_region[rid] = cuts
|
| 150 |
+
if cuts and not bool(self.must_terminate[rid]):
|
| 151 |
+
area_cells = int(self.area_px[rid].item()) // (g * g)
|
| 152 |
+
per_level.setdefault(area_cells, [])
|
| 153 |
+
for axis, px, lo, hi, t, lc in cuts:
|
| 154 |
+
per_level[area_cells].append((rid, t, lo, hi, lc, axis, px))
|
| 155 |
+
|
| 156 |
+
self.levels = []
|
| 157 |
+
for area_cells in sorted(per_level.keys()):
|
| 158 |
+
rows = per_level[area_cells]
|
| 159 |
+
parent_ids = torch.tensor([r[0] for r in rows], dtype=torch.int64)
|
| 160 |
+
parents, parent_index = torch.unique(parent_ids, return_inverse=True)
|
| 161 |
+
self.levels.append(LatticeLevel(
|
| 162 |
+
parent_ids=parent_ids,
|
| 163 |
+
cut_type=torch.tensor([r[1] for r in rows], dtype=torch.int64),
|
| 164 |
+
log_cnt=torch.tensor([r[4] for r in rows], dtype=torch.float32),
|
| 165 |
+
child_lo=torch.tensor([r[2] for r in rows], dtype=torch.int64),
|
| 166 |
+
child_hi=torch.tensor([r[3] for r in rows], dtype=torch.int64),
|
| 167 |
+
cut_axis=torch.tensor([r[5] for r in rows], dtype=torch.int64),
|
| 168 |
+
cut_px=torch.tensor([r[6] for r in rows], dtype=torch.int64),
|
| 169 |
+
parents=parents,
|
| 170 |
+
parent_index=parent_index,
|
| 171 |
+
))
|
| 172 |
+
|
| 173 |
+
# ---- precomputed sweep metadata (everything shape-related the DP
|
| 174 |
+
# would otherwise derive from device tensors inside the level loop).
|
| 175 |
+
self.mt_ids = torch.nonzero(self.must_terminate, as_tuple=False).reshape(-1)
|
| 176 |
+
self.mt_slots = self.leaf_index_of_region[self.mt_ids]
|
| 177 |
+
|
| 178 |
+
# Distinct cut-type-presence patterns over regions that actually have
|
| 179 |
+
# cuts (every level parent). Lets the DP do ONE masked log-softmax
|
| 180 |
+
# over [B, n_pat, R, 14] per sweep instead of one per level.
|
| 181 |
+
pat_of: Dict[Tuple[bool, ...], int] = {}
|
| 182 |
+
pats: List[torch.Tensor] = []
|
| 183 |
+
self.pattern_of_region = torch.full((N,), -1, dtype=torch.int64)
|
| 184 |
+
for lv in self.levels:
|
| 185 |
+
for rid in lv.parents.tolist():
|
| 186 |
+
key = tuple(self.type_present[rid].tolist())
|
| 187 |
+
pid = pat_of.setdefault(key, len(pats))
|
| 188 |
+
if pid == len(pats):
|
| 189 |
+
pats.append(self.type_present[rid].clone())
|
| 190 |
+
self.pattern_of_region[rid] = pid
|
| 191 |
+
self.type_patterns = torch.stack(pats, dim=0) # bool [n_pat, 14]
|
| 192 |
+
|
| 193 |
+
for lv in self.levels:
|
| 194 |
+
is_leaf = self.leaf_mask[lv.parents]
|
| 195 |
+
lv.leaf_pos = torch.nonzero(is_leaf, as_tuple=False).reshape(-1)
|
| 196 |
+
lv.nonleaf_pos = torch.nonzero(~is_leaf, as_tuple=False).reshape(-1)
|
| 197 |
+
lv.leaf_rids = lv.parents[lv.leaf_pos]
|
| 198 |
+
lv.nonleaf_rids = lv.parents[lv.nonleaf_pos]
|
| 199 |
+
lv.leaf_slots = self.leaf_index_of_region[lv.leaf_rids]
|
| 200 |
+
lv.flat_type_idx = (
|
| 201 |
+
self.pattern_of_region[lv.parent_ids] * N_CUT_TYPES + lv.cut_type
|
| 202 |
+
)
|
| 203 |
+
lv.has_leaf = bool(lv.leaf_pos.numel() > 0)
|
| 204 |
+
lv.has_nonleaf = bool(lv.nonleaf_pos.numel() > 0)
|
| 205 |
+
lv.reorder = torch.argsort(torch.cat([lv.nonleaf_pos, lv.leaf_pos]))
|
| 206 |
+
|
| 207 |
+
# Region ids are assigned area-ascending (rects sorted by pixel area),
|
| 208 |
+
# so the must-terminate block and each level's parent block are
|
| 209 |
+
# contiguous, consecutive id ranges covering all N regions. The DP's
|
| 210 |
+
# fast sweep relies on this to assemble beta with one cat instead of
|
| 211 |
+
# per-level index_put on the [B, N, S] table. Verified here; any
|
| 212 |
+
# violation falls back to the reference sweep.
|
| 213 |
+
self.contiguous_ids = bool(
|
| 214 |
+
self.mt_ids.equal(torch.arange(self.mt_ids.numel()))
|
| 215 |
+
)
|
| 216 |
+
off = int(self.mt_ids.numel())
|
| 217 |
+
for lv in self.levels:
|
| 218 |
+
P = int(lv.parents.numel())
|
| 219 |
+
if not bool(lv.parents.equal(torch.arange(off, off + P))):
|
| 220 |
+
self.contiguous_ids = False
|
| 221 |
+
break
|
| 222 |
+
lv.id_lo, lv.id_hi = off, off + P
|
| 223 |
+
off += P
|
| 224 |
+
if off != N:
|
| 225 |
+
self.contiguous_ids = False
|
| 226 |
+
|
| 227 |
+
# Leaf shape groups with flattened pixel-gather indices, cached for
|
| 228 |
+
# emission scoring (atlas.score_leaves): (h, w, slots [n_g],
|
| 229 |
+
# pix [n_g*h*w] into a [canvas*canvas]-flattened image).
|
| 230 |
+
self.shape_groups = []
|
| 231 |
+
for (h, w), slots in self.leaf_shape_groups().items():
|
| 232 |
+
rects = self.regions[self.leaf_ids[slots]]
|
| 233 |
+
yy = rects[:, 1].view(-1, 1, 1) + torch.arange(h).view(1, -1, 1)
|
| 234 |
+
xx = rects[:, 0].view(-1, 1, 1) + torch.arange(w).view(1, 1, -1)
|
| 235 |
+
pix = (yy * c + xx).reshape(-1)
|
| 236 |
+
self.shape_groups.append((h, w, slots, pix))
|
| 237 |
+
|
| 238 |
+
@property
|
| 239 |
+
def n_regions(self) -> int:
|
| 240 |
+
return int(self.regions.shape[0])
|
| 241 |
+
|
| 242 |
+
@property
|
| 243 |
+
def n_leaf_regions(self) -> int:
|
| 244 |
+
return int(self.leaf_ids.shape[0])
|
| 245 |
+
|
| 246 |
+
def leaf_shape_groups(self) -> Dict[Tuple[int, int], torch.Tensor]:
|
| 247 |
+
"""Map (h, w) -> leaf-slot indices (into the leaf_ids ordering)."""
|
| 248 |
+
groups: Dict[Tuple[int, int], List[int]] = {}
|
| 249 |
+
for slot, rid in enumerate(self.leaf_ids.tolist()):
|
| 250 |
+
x0, y0, x1, y1 = self.regions[rid].tolist()
|
| 251 |
+
groups.setdefault((y1 - y0, x1 - x0), []).append(slot)
|
| 252 |
+
return {k: torch.tensor(v, dtype=torch.int64) for k, v in groups.items()}
|
| 253 |
+
|
| 254 |
+
def to(self, device: torch.device) -> "Lattice":
|
| 255 |
+
device = torch.device(device)
|
| 256 |
+
if getattr(self, "_device", None) == device:
|
| 257 |
+
return self
|
| 258 |
+
for name in ("regions", "leaf_mask", "must_terminate", "must_expand",
|
| 259 |
+
"area_px", "phi_geom", "type_present", "leaf_ids",
|
| 260 |
+
"leaf_index_of_region", "mt_ids", "mt_slots",
|
| 261 |
+
"type_patterns", "pattern_of_region"):
|
| 262 |
+
setattr(self, name, getattr(self, name).to(device))
|
| 263 |
+
for lv in self.levels:
|
| 264 |
+
for name in ("parent_ids", "cut_type", "log_cnt", "child_lo", "child_hi",
|
| 265 |
+
"cut_axis", "cut_px", "parents", "parent_index",
|
| 266 |
+
"leaf_pos", "nonleaf_pos", "leaf_rids", "nonleaf_rids",
|
| 267 |
+
"leaf_slots", "flat_type_idx", "reorder"):
|
| 268 |
+
t = getattr(lv, name)
|
| 269 |
+
if t is not None:
|
| 270 |
+
setattr(lv, name, t.to(device))
|
| 271 |
+
self.shape_groups = [
|
| 272 |
+
(h, w, slots.to(device), pix.to(device))
|
| 273 |
+
for h, w, slots, pix in self.shape_groups
|
| 274 |
+
]
|
| 275 |
+
self._device = device
|
| 276 |
+
return self
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
_CACHE: Dict[Tuple[int, int, int], Lattice] = {}
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def get_lattice(canvas_px: int, grid_px: int, leaf_max_px: int) -> Lattice:
|
| 283 |
+
key = (canvas_px, grid_px, leaf_max_px)
|
| 284 |
+
if key not in _CACHE:
|
| 285 |
+
_CACHE[key] = Lattice(canvas_px, grid_px, leaf_max_px)
|
| 286 |
+
return _CACHE[key]
|
sprig/eval/__init__.py
ADDED
|
File without changes
|
sprig/eval/baseline_pixmix.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""B0 trivial baseline: position-dependent 4-component DL mixture per pixel.
|
| 2 |
+
|
| 3 |
+
One parameter grid [64, 64, 40] (no neural net, no caption): per pixel, the
|
| 4 |
+
same 40-channel discretized-logistic mixture parameterization as the texel
|
| 5 |
+
atlas (sprig/model/dl.py layout: 4 components x contiguous blocks of
|
| 6 |
+
[weight, 3 means, 3 log-scales, 3 coupling coeffs]). B0's bpd measures
|
| 7 |
+
exactly what the grammar buys over a caption-free per-pixel model.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
from typing import Union
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
|
| 18 |
+
from sprig.model.dl import LOGSCALE_IDX, N_CH, N_COMP, dl_logprob, u8_to_unit
|
| 19 |
+
|
| 20 |
+
LOG2 = math.log(2.0)
|
| 21 |
+
N_PARAMS = N_CH # 40
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _to_u8_tensor(images_u8: Union[np.ndarray, torch.Tensor]) -> torch.Tensor:
|
| 25 |
+
if isinstance(images_u8, np.ndarray):
|
| 26 |
+
images_u8 = torch.from_numpy(np.array(images_u8, dtype=images_u8.dtype))
|
| 27 |
+
return images_u8
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class PixMixBaseline(nn.Module):
|
| 31 |
+
"""params: [H, W, 40] trainable grid; no other parameters."""
|
| 32 |
+
|
| 33 |
+
def __init__(self, height: int = 64, width: int = 64):
|
| 34 |
+
super().__init__()
|
| 35 |
+
p = torch.zeros(height, width, N_PARAMS)
|
| 36 |
+
# spread component means so the mixture starts multi-modal
|
| 37 |
+
comp_means = torch.linspace(-0.6, 0.6, N_COMP)
|
| 38 |
+
for j in range(N_COMP):
|
| 39 |
+
p[..., 10 * j + 1: 10 * j + 4] = comp_means[j]
|
| 40 |
+
p[..., LOGSCALE_IDX] = -1.0
|
| 41 |
+
self.params = nn.Parameter(p)
|
| 42 |
+
|
| 43 |
+
def logprob_per_image(self, images_u8: Union[np.ndarray, torch.Tensor]) -> torch.Tensor:
|
| 44 |
+
"""[B] total log-likelihood (nats) per image."""
|
| 45 |
+
img = _to_u8_tensor(images_u8).to(self.params.device)
|
| 46 |
+
x = u8_to_unit(img).permute(0, 3, 1, 2) # [B,3,H,W]
|
| 47 |
+
# expand (view) params to the batch lead dim: dl_logprob broadcasts
|
| 48 |
+
# x against params but not vice versa
|
| 49 |
+
p = self.params.permute(2, 0, 1).unsqueeze(0).expand(x.shape[0], -1, -1, -1)
|
| 50 |
+
lp = dl_logprob(p, x) # [B,H,W] fp32
|
| 51 |
+
return lp.sum(dim=(1, 2))
|
| 52 |
+
|
| 53 |
+
@torch.no_grad()
|
| 54 |
+
def bpd(self, images_u8: Union[np.ndarray, torch.Tensor], batch_size: int = 256) -> float:
|
| 55 |
+
"""Mean bits/dim over images (dims = 3*H*W)."""
|
| 56 |
+
n = int(images_u8.shape[0])
|
| 57 |
+
h, w = self.params.shape[0], self.params.shape[1]
|
| 58 |
+
total = 0.0
|
| 59 |
+
for i in range(0, n, batch_size):
|
| 60 |
+
total += float(self.logprob_per_image(images_u8[i:i + batch_size]).sum())
|
| 61 |
+
return -total / (n * 3.0 * h * w * LOG2)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def fit(
|
| 65 |
+
images_u8: Union[np.ndarray, torch.Tensor],
|
| 66 |
+
steps: int = 2000,
|
| 67 |
+
lr: float = 5e-2,
|
| 68 |
+
batch_size: int = 128,
|
| 69 |
+
device: str = "cpu",
|
| 70 |
+
seed: int = 0,
|
| 71 |
+
) -> PixMixBaseline:
|
| 72 |
+
"""Fit B0 by Adam on random minibatches of training images; returns the model."""
|
| 73 |
+
torch.manual_seed(seed)
|
| 74 |
+
model = PixMixBaseline().to(device)
|
| 75 |
+
opt = torch.optim.Adam(model.parameters(), lr=lr)
|
| 76 |
+
n = images_u8.shape[0]
|
| 77 |
+
gen = np.random.default_rng(seed)
|
| 78 |
+
for _ in range(steps):
|
| 79 |
+
idx = gen.integers(0, n, size=min(batch_size, n))
|
| 80 |
+
if isinstance(images_u8, np.ndarray):
|
| 81 |
+
batch = images_u8[idx]
|
| 82 |
+
else:
|
| 83 |
+
batch = images_u8[torch.from_numpy(idx)]
|
| 84 |
+
nll = -model.logprob_per_image(batch).mean() / (3.0 * 64 * 64)
|
| 85 |
+
opt.zero_grad()
|
| 86 |
+
nll.backward()
|
| 87 |
+
opt.step()
|
| 88 |
+
return model
|
sprig/eval/color_checks.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GT-free faithfulness checks on generated images.
|
| 2 |
+
|
| 3 |
+
Pipeline (pure numpy, no scipy):
|
| 4 |
+
background = modal border color; foreground = pixels whose max-channel
|
| 5 |
+
deviation from the background exceeds `thresh`; objects = 4-connected
|
| 6 |
+
components of the foreground; object color = median RGB mapped to the
|
| 7 |
+
nearest vocabulary color anchor in CIE Lab; relations compared by centroid.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Dict, List, Optional, Tuple
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
# Vocabulary color anchors (RGB). Single source of truth is
|
| 16 |
+
# sprig/data/procgen/vocab.py (COLORS dict); these literals are the fallback.
|
| 17 |
+
_DEFAULT_ANCHORS: Dict[str, Tuple[int, int, int]] = {
|
| 18 |
+
"red": (210, 45, 45),
|
| 19 |
+
"green": (55, 170, 60),
|
| 20 |
+
"blue": (50, 90, 215),
|
| 21 |
+
"yellow": (235, 215, 55),
|
| 22 |
+
"orange": (240, 145, 40),
|
| 23 |
+
"purple": (140, 60, 185),
|
| 24 |
+
"cyan": (60, 205, 215),
|
| 25 |
+
"magenta": (220, 70, 175),
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _load_anchors() -> Dict[str, Tuple[int, int, int]]:
|
| 30 |
+
try:
|
| 31 |
+
from sprig.data.procgen import vocab as _vocab # type: ignore
|
| 32 |
+
|
| 33 |
+
for name in ("COLOR_ANCHORS", "COLORS", "COLOR_RGB"):
|
| 34 |
+
anchors = getattr(_vocab, name, None)
|
| 35 |
+
if isinstance(anchors, dict) and anchors:
|
| 36 |
+
first = next(iter(anchors.values()))
|
| 37 |
+
if hasattr(first, "__len__") and len(first) == 3:
|
| 38 |
+
return {str(k): tuple(int(v) for v in rgb) for k, rgb in anchors.items()}
|
| 39 |
+
except Exception:
|
| 40 |
+
pass
|
| 41 |
+
return dict(_DEFAULT_ANCHORS)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
COLOR_ANCHORS: Dict[str, Tuple[int, int, int]] = _load_anchors()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def rgb_to_lab(rgb: np.ndarray) -> np.ndarray:
|
| 48 |
+
"""sRGB (0..255, any leading shape [..,3]) -> CIE Lab (D65), numpy only."""
|
| 49 |
+
c = np.asarray(rgb, dtype=np.float64) / 255.0
|
| 50 |
+
lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
|
| 51 |
+
m = np.array(
|
| 52 |
+
[
|
| 53 |
+
[0.4124564, 0.3575761, 0.1804375],
|
| 54 |
+
[0.2126729, 0.7151522, 0.0721750],
|
| 55 |
+
[0.0193339, 0.1191920, 0.9503041],
|
| 56 |
+
]
|
| 57 |
+
)
|
| 58 |
+
xyz = lin @ m.T
|
| 59 |
+
xyz = xyz / np.array([0.95047, 1.0, 1.08883])
|
| 60 |
+
d = 6.0 / 29.0
|
| 61 |
+
f = np.where(xyz > d ** 3, np.cbrt(xyz), xyz / (3 * d * d) + 4.0 / 29.0)
|
| 62 |
+
lab = np.empty_like(f)
|
| 63 |
+
lab[..., 0] = 116.0 * f[..., 1] - 16.0
|
| 64 |
+
lab[..., 1] = 500.0 * (f[..., 0] - f[..., 1])
|
| 65 |
+
lab[..., 2] = 200.0 * (f[..., 1] - f[..., 2])
|
| 66 |
+
return lab
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
_ANCHOR_NAMES = list(COLOR_ANCHORS.keys())
|
| 70 |
+
_ANCHOR_LAB = rgb_to_lab(np.array([COLOR_ANCHORS[n] for n in _ANCHOR_NAMES], dtype=np.float64))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def nearest_color(rgb) -> str:
|
| 74 |
+
"""Nearest vocabulary anchor to an RGB triple, in Lab space."""
|
| 75 |
+
lab = rgb_to_lab(np.asarray(rgb, dtype=np.float64))
|
| 76 |
+
d = np.linalg.norm(_ANCHOR_LAB - lab[None, :], axis=1)
|
| 77 |
+
return _ANCHOR_NAMES[int(np.argmin(d))]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _modal_border_color(img: np.ndarray) -> np.ndarray:
|
| 81 |
+
border = np.concatenate([img[0], img[-1], img[1:-1, 0], img[1:-1, -1]], axis=0)
|
| 82 |
+
codes = (
|
| 83 |
+
border[:, 0].astype(np.int64) * 65536
|
| 84 |
+
+ border[:, 1].astype(np.int64) * 256
|
| 85 |
+
+ border[:, 2].astype(np.int64)
|
| 86 |
+
)
|
| 87 |
+
vals, counts = np.unique(codes, return_counts=True)
|
| 88 |
+
code = int(vals[np.argmax(counts)])
|
| 89 |
+
return np.array([code // 65536, (code // 256) % 256, code % 256], dtype=np.float64)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def connected_components(mask: np.ndarray) -> Tuple[np.ndarray, int]:
|
| 93 |
+
"""4-connectivity components of a boolean mask via label propagation.
|
| 94 |
+
|
| 95 |
+
Returns (labels [H,W] int32 with 0 = background, 1..n components, n).
|
| 96 |
+
"""
|
| 97 |
+
h, w = mask.shape
|
| 98 |
+
labels = np.where(mask, np.arange(1, h * w + 1, dtype=np.int64).reshape(h, w), 0)
|
| 99 |
+
while True:
|
| 100 |
+
prev = labels
|
| 101 |
+
up = np.zeros_like(labels)
|
| 102 |
+
up[1:, :] = labels[:-1, :]
|
| 103 |
+
down = np.zeros_like(labels)
|
| 104 |
+
down[:-1, :] = labels[1:, :]
|
| 105 |
+
left = np.zeros_like(labels)
|
| 106 |
+
left[:, 1:] = labels[:, :-1]
|
| 107 |
+
right = np.zeros_like(labels)
|
| 108 |
+
right[:, :-1] = labels[:, 1:]
|
| 109 |
+
stacked = np.stack([labels, up, down, left, right], axis=0)
|
| 110 |
+
stacked = np.where(stacked == 0, np.iinfo(np.int64).max, stacked)
|
| 111 |
+
labels = np.where(mask, stacked.min(axis=0), 0)
|
| 112 |
+
if np.array_equal(labels, prev):
|
| 113 |
+
break
|
| 114 |
+
vals = np.unique(labels)
|
| 115 |
+
vals = vals[vals > 0]
|
| 116 |
+
out = np.zeros_like(labels, dtype=np.int32)
|
| 117 |
+
for i, v in enumerate(vals):
|
| 118 |
+
out[labels == v] = i + 1
|
| 119 |
+
return out, len(vals)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def extract(image: np.ndarray, thresh: int = 40, min_area: int = 12) -> Dict[str, object]:
|
| 123 |
+
"""Extract background + object list from a rendered/generated 64x64 image.
|
| 124 |
+
|
| 125 |
+
Returns {"background": color_name, "background_rgb": (r,g,b),
|
| 126 |
+
"objects": [{"color", "rgb", "centroid": (x,y), "area",
|
| 127 |
+
"bbox": (x0,y0,x1,y1)}]} sorted by area descending.
|
| 128 |
+
"""
|
| 129 |
+
img = np.asarray(image)
|
| 130 |
+
if img.dtype != np.uint8:
|
| 131 |
+
img = np.clip(img, 0, 255).astype(np.uint8)
|
| 132 |
+
bg = _modal_border_color(img)
|
| 133 |
+
dev = np.abs(img.astype(np.float64) - bg[None, None, :]).max(axis=-1)
|
| 134 |
+
mask = dev > float(thresh)
|
| 135 |
+
labels, n = connected_components(mask)
|
| 136 |
+
objects: List[Dict[str, object]] = []
|
| 137 |
+
for i in range(1, n + 1):
|
| 138 |
+
ys, xs = np.nonzero(labels == i)
|
| 139 |
+
if ys.size < min_area:
|
| 140 |
+
continue
|
| 141 |
+
med = np.median(img[ys, xs].astype(np.float64), axis=0)
|
| 142 |
+
objects.append(
|
| 143 |
+
{
|
| 144 |
+
"color": nearest_color(med),
|
| 145 |
+
"rgb": tuple(float(v) for v in med),
|
| 146 |
+
"centroid": (float(xs.mean()), float(ys.mean())),
|
| 147 |
+
"area": int(ys.size),
|
| 148 |
+
"bbox": (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1),
|
| 149 |
+
}
|
| 150 |
+
)
|
| 151 |
+
objects.sort(key=lambda o: -int(o["area"]))
|
| 152 |
+
return {
|
| 153 |
+
"background": nearest_color(bg),
|
| 154 |
+
"background_rgb": tuple(float(v) for v in bg),
|
| 155 |
+
"objects": objects,
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _find_by_color(objects: List[Dict[str, object]], color: str) -> Optional[Dict[str, object]]:
|
| 160 |
+
for o in objects:
|
| 161 |
+
if o["color"] == color:
|
| 162 |
+
return o
|
| 163 |
+
return None
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def relation_holds(
|
| 167 |
+
extraction: Dict[str, object], color_a: str, color_b: str, relation: str
|
| 168 |
+
) -> Optional[bool]:
|
| 169 |
+
"""Does 'the color_a object is <relation> the color_b object' hold by centroids?
|
| 170 |
+
|
| 171 |
+
relation in {"left of", "right of", "above", "below"}.
|
| 172 |
+
Returns None when either object is missing (unscoreable).
|
| 173 |
+
"""
|
| 174 |
+
objs = extraction["objects"] # type: ignore[index]
|
| 175 |
+
a = _find_by_color(objs, color_a) # type: ignore[arg-type]
|
| 176 |
+
b = _find_by_color(objs, color_b) # type: ignore[arg-type]
|
| 177 |
+
if a is None or b is None:
|
| 178 |
+
return None
|
| 179 |
+
ax, ay = a["centroid"] # type: ignore[misc]
|
| 180 |
+
bx, by = b["centroid"] # type: ignore[misc]
|
| 181 |
+
if relation == "left of":
|
| 182 |
+
return bool(ax < bx)
|
| 183 |
+
if relation == "right of":
|
| 184 |
+
return bool(ax > bx)
|
| 185 |
+
if relation == "above":
|
| 186 |
+
return bool(ay < by)
|
| 187 |
+
if relation == "below":
|
| 188 |
+
return bool(ay > by)
|
| 189 |
+
raise ValueError("unknown relation: {}".format(relation))
|
sprig/eval/monitors.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training-health monitors: pure functions over posterior-usage statistics.
|
| 2 |
+
|
| 3 |
+
Inputs come from `SPRIGModel.posterior_usage` (DESIGN §4/C3):
|
| 4 |
+
symbol_usage [S], texel_usage [T_v] (expected-count vectors, any positive
|
| 5 |
+
scale — normalized here), node_entropy (nats/node), emit_mag / rule_mag
|
| 6 |
+
(gradient-magnitude scalars), mean_depth, mean_leaves.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Dict, Tuple, Union
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
ArrayLike = Union[np.ndarray, "torch.Tensor", list] # noqa: F821
|
| 15 |
+
|
| 16 |
+
# Alarm thresholds (plan §Verification).
|
| 17 |
+
S_EFF_COLLAPSE_FRAC = 0.15 # S_eff < 0.15*S -> grammar collapse
|
| 18 |
+
MAG_RATIO_ALARM = 100.0 # emission/rule ratio > 100 ...
|
| 19 |
+
NODE_ENTROPY_FLOOR = 0.1 # ... with node entropy < 0.1 nats
|
| 20 |
+
ALIVE_TEXEL_ALARM_FRAC = 0.25 # alive fraction below this -> texel death
|
| 21 |
+
QUADTREE_DEPTH = 6.0 # uniform 64px -> 8px binary-split depth
|
| 22 |
+
QUADTREE_LEAVES = 64.0 # 8x8 grid of 8px leaves
|
| 23 |
+
ALIVE_USAGE_THRESH_FRAC = 0.1 # texel alive iff usage > 0.1 / T_v
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _to_numpy(x: ArrayLike) -> np.ndarray:
|
| 27 |
+
if hasattr(x, "detach"): # torch tensor without importing torch
|
| 28 |
+
x = x.detach().cpu().numpy()
|
| 29 |
+
return np.asarray(x, dtype=np.float64)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def s_eff(symbol_usage: ArrayLike) -> float:
|
| 33 |
+
"""Effective symbol count exp(H(usage)); usage is normalized internally."""
|
| 34 |
+
p = _to_numpy(symbol_usage)
|
| 35 |
+
total = p.sum()
|
| 36 |
+
if total <= 0:
|
| 37 |
+
return 0.0
|
| 38 |
+
p = p / total
|
| 39 |
+
nz = p[p > 0]
|
| 40 |
+
return float(np.exp(-(nz * np.log(nz)).sum()))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def alive_texels(texel_usage: ArrayLike, T_v: int) -> int:
|
| 44 |
+
"""Number of texels with normalized usage above 0.1/T_v (resurrection thresh)."""
|
| 45 |
+
p = _to_numpy(texel_usage)
|
| 46 |
+
total = p.sum()
|
| 47 |
+
if total <= 0:
|
| 48 |
+
return 0
|
| 49 |
+
p = p / total
|
| 50 |
+
return int((p > ALIVE_USAGE_THRESH_FRAC / float(T_v)).sum())
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def magnitude_ratio(emit_mag: float, rule_mag: float, eps: float = 1e-12) -> float:
|
| 54 |
+
"""Emission-vs-rule gradient magnitude ratio (tempered-DP failure detector)."""
|
| 55 |
+
return float(emit_mag) / (float(rule_mag) + eps)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def pi_controller_eta(
|
| 59 |
+
eta: float,
|
| 60 |
+
node_entropy: float,
|
| 61 |
+
band: Tuple[float, float] = (0.5, 3.0),
|
| 62 |
+
eta_range: Tuple[float, float] = (0.0, 1.5),
|
| 63 |
+
) -> float:
|
| 64 |
+
"""One PI-controller update of the tempering exponent eta (DESIGN §5).
|
| 65 |
+
|
| 66 |
+
If posterior node entropy H is below the band, raise eta by
|
| 67 |
+
0.05 + 0.1*(lo - H); if above the band, lower it by 0.05; clamp to range.
|
| 68 |
+
"""
|
| 69 |
+
lo, hi = band
|
| 70 |
+
h = float(node_entropy)
|
| 71 |
+
new_eta = float(eta)
|
| 72 |
+
if h < lo:
|
| 73 |
+
new_eta += 0.05 + 0.1 * (lo - h)
|
| 74 |
+
elif h > hi:
|
| 75 |
+
new_eta -= 0.05
|
| 76 |
+
return float(min(max(new_eta, eta_range[0]), eta_range[1]))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def build_alarms(stats: Dict[str, object], S: int, T_v: int) -> Dict[str, bool]:
|
| 80 |
+
"""Alarm dict per the plan's training alarms.
|
| 81 |
+
|
| 82 |
+
`stats` is the posterior_usage dict: keys symbol_usage, texel_usage,
|
| 83 |
+
node_entropy, emit_mag, rule_mag, mean_depth, mean_leaves.
|
| 84 |
+
Returns booleans: grammar_collapse, tempered_dp_failure, texel_death,
|
| 85 |
+
parse_collapse, and `any`.
|
| 86 |
+
"""
|
| 87 |
+
seff = s_eff(stats["symbol_usage"])
|
| 88 |
+
ratio = magnitude_ratio(float(stats["emit_mag"]), float(stats["rule_mag"]))
|
| 89 |
+
node_h = float(stats["node_entropy"])
|
| 90 |
+
alive_frac = alive_texels(stats["texel_usage"], T_v) / float(T_v)
|
| 91 |
+
mean_depth = float(stats.get("mean_depth", 0.0))
|
| 92 |
+
mean_leaves = float(stats.get("mean_leaves", 0.0))
|
| 93 |
+
|
| 94 |
+
alarms = {
|
| 95 |
+
"grammar_collapse": seff < S_EFF_COLLAPSE_FRAC * S,
|
| 96 |
+
"tempered_dp_failure": (ratio > MAG_RATIO_ALARM) and (node_h < NODE_ENTROPY_FLOOR),
|
| 97 |
+
"texel_death": alive_frac < ALIVE_TEXEL_ALARM_FRAC,
|
| 98 |
+
"parse_collapse": (mean_leaves >= 0.95 * QUADTREE_LEAVES)
|
| 99 |
+
and (mean_depth >= 0.95 * QUADTREE_DEPTH),
|
| 100 |
+
}
|
| 101 |
+
alarms["any"] = bool(any(alarms.values()))
|
| 102 |
+
return alarms
|
sprig/eval/probe.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""3-conv-block CNN probe: shape (8-way) + color (8-way) heads.
|
| 2 |
+
|
| 3 |
+
Used for the compositional-holdout score: trained on a probe dataset that
|
| 4 |
+
DOES include the held-out combos (leakage-safe by directory separation),
|
| 5 |
+
then applied to model generations.
|
| 6 |
+
|
| 7 |
+
Labeled image dir formats accepted by `train` (checked in this order):
|
| 8 |
+
1. memmap pair: `images.u8` [N,64,64,3] + `labels.npy` int64 [N,2]
|
| 9 |
+
(shape_idx, color_idx) in the canonical prompts.SHAPES / prompts.COLORS order;
|
| 10 |
+
2. flat PNG files named `<shape>_<color>_<anything>.png`.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import glob
|
| 15 |
+
import os
|
| 16 |
+
from typing import List, Optional, Tuple, Union
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
|
| 23 |
+
from sprig.eval.prompts import COLORS, SHAPES
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ProbeCNN(nn.Module):
|
| 27 |
+
def __init__(self, width: int = 32):
|
| 28 |
+
super().__init__()
|
| 29 |
+
w = width
|
| 30 |
+
self.blocks = nn.Sequential(
|
| 31 |
+
nn.Conv2d(3, w, 3, padding=1), nn.BatchNorm2d(w), nn.ReLU(),
|
| 32 |
+
nn.Conv2d(w, w, 3, padding=1), nn.BatchNorm2d(w), nn.ReLU(),
|
| 33 |
+
nn.MaxPool2d(2), # 32
|
| 34 |
+
nn.Conv2d(w, 2 * w, 3, padding=1), nn.BatchNorm2d(2 * w), nn.ReLU(),
|
| 35 |
+
nn.Conv2d(2 * w, 2 * w, 3, padding=1), nn.BatchNorm2d(2 * w), nn.ReLU(),
|
| 36 |
+
nn.MaxPool2d(2), # 16
|
| 37 |
+
nn.Conv2d(2 * w, 4 * w, 3, padding=1), nn.BatchNorm2d(4 * w), nn.ReLU(),
|
| 38 |
+
nn.Conv2d(4 * w, 4 * w, 3, padding=1), nn.BatchNorm2d(4 * w), nn.ReLU(),
|
| 39 |
+
nn.MaxPool2d(2), # 8
|
| 40 |
+
)
|
| 41 |
+
self.shape_head = nn.Linear(4 * w, len(SHAPES))
|
| 42 |
+
self.color_head = nn.Linear(4 * w, len(COLORS))
|
| 43 |
+
|
| 44 |
+
def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 45 |
+
h = self.blocks(x)
|
| 46 |
+
h = h.mean(dim=(2, 3))
|
| 47 |
+
return self.shape_head(h), self.color_head(h)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _images_to_tensor(images: Union[np.ndarray, torch.Tensor]) -> torch.Tensor:
|
| 51 |
+
"""u8 [N,64,64,3] (numpy or torch) -> float [N,3,64,64] in [-1,1]."""
|
| 52 |
+
if isinstance(images, np.ndarray):
|
| 53 |
+
images = torch.from_numpy(np.array(images, dtype=images.dtype))
|
| 54 |
+
x = images.to(torch.float32) / 127.5 - 1.0
|
| 55 |
+
return x.permute(0, 3, 1, 2).contiguous()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _load_probe_dir(data_dir: str) -> Tuple[np.ndarray, np.ndarray]:
|
| 59 |
+
img_path = os.path.join(data_dir, "images.u8")
|
| 60 |
+
lab_path = os.path.join(data_dir, "labels.npy")
|
| 61 |
+
if os.path.exists(img_path) and os.path.exists(lab_path):
|
| 62 |
+
labels = np.load(lab_path).astype(np.int64)
|
| 63 |
+
n = labels.shape[0]
|
| 64 |
+
images = np.memmap(img_path, dtype=np.uint8, mode="r", shape=(n, 64, 64, 3))
|
| 65 |
+
return np.asarray(images), labels
|
| 66 |
+
files = sorted(glob.glob(os.path.join(data_dir, "*.png")))
|
| 67 |
+
if not files:
|
| 68 |
+
raise FileNotFoundError(
|
| 69 |
+
"probe dir {} has neither images.u8+labels.npy nor *.png".format(data_dir)
|
| 70 |
+
)
|
| 71 |
+
from PIL import Image
|
| 72 |
+
|
| 73 |
+
imgs: List[np.ndarray] = []
|
| 74 |
+
labs: List[Tuple[int, int]] = []
|
| 75 |
+
for f in files:
|
| 76 |
+
parts = os.path.basename(f).split("_")
|
| 77 |
+
shape_name, color_name = parts[0], parts[1]
|
| 78 |
+
labs.append((SHAPES.index(shape_name), COLORS.index(color_name)))
|
| 79 |
+
imgs.append(np.asarray(Image.open(f).convert("RGB"), dtype=np.uint8))
|
| 80 |
+
return np.stack(imgs), np.asarray(labs, dtype=np.int64)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def train(
|
| 84 |
+
data_dir: str,
|
| 85 |
+
epochs: int = 10,
|
| 86 |
+
batch_size: int = 64,
|
| 87 |
+
lr: float = 1e-3,
|
| 88 |
+
device: str = "cpu",
|
| 89 |
+
ckpt_path: Optional[str] = None,
|
| 90 |
+
seed: int = 0,
|
| 91 |
+
) -> str:
|
| 92 |
+
"""Train the probe on a labeled image dir; returns the checkpoint path."""
|
| 93 |
+
images, labels = _load_probe_dir(data_dir)
|
| 94 |
+
torch.manual_seed(seed)
|
| 95 |
+
model = ProbeCNN().to(device)
|
| 96 |
+
opt = torch.optim.Adam(model.parameters(), lr=lr)
|
| 97 |
+
n = images.shape[0]
|
| 98 |
+
gen = np.random.default_rng(seed)
|
| 99 |
+
model.train()
|
| 100 |
+
for _ in range(epochs):
|
| 101 |
+
order = gen.permutation(n)
|
| 102 |
+
for i in range(0, n, batch_size):
|
| 103 |
+
idx = order[i:i + batch_size]
|
| 104 |
+
x = _images_to_tensor(images[idx]).to(device)
|
| 105 |
+
ys = torch.from_numpy(labels[idx, 0]).to(device)
|
| 106 |
+
yc = torch.from_numpy(labels[idx, 1]).to(device)
|
| 107 |
+
logit_s, logit_c = model(x)
|
| 108 |
+
loss = F.cross_entropy(logit_s, ys) + F.cross_entropy(logit_c, yc)
|
| 109 |
+
opt.zero_grad()
|
| 110 |
+
loss.backward()
|
| 111 |
+
opt.step()
|
| 112 |
+
if ckpt_path is None:
|
| 113 |
+
ckpt_path = os.path.join(data_dir, "probe.pt")
|
| 114 |
+
torch.save(
|
| 115 |
+
{"state_dict": model.state_dict(), "shapes": SHAPES, "colors": COLORS}, ckpt_path
|
| 116 |
+
)
|
| 117 |
+
return ckpt_path
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def load_probe(ckpt_path: str, device: str = "cpu") -> ProbeCNN:
|
| 121 |
+
ckpt = torch.load(ckpt_path, map_location=device)
|
| 122 |
+
model = ProbeCNN().to(device)
|
| 123 |
+
model.load_state_dict(ckpt["state_dict"])
|
| 124 |
+
model.eval()
|
| 125 |
+
return model
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@torch.no_grad()
|
| 129 |
+
def score_generations(
|
| 130 |
+
images: Union[np.ndarray, torch.Tensor],
|
| 131 |
+
expected_shape: str,
|
| 132 |
+
expected_color: str,
|
| 133 |
+
ckpt_path: Union[str, ProbeCNN],
|
| 134 |
+
device: str = "cpu",
|
| 135 |
+
batch_size: int = 64,
|
| 136 |
+
) -> float:
|
| 137 |
+
"""Fraction of images classified as (expected_shape AND expected_color)."""
|
| 138 |
+
model = ckpt_path if isinstance(ckpt_path, ProbeCNN) else load_probe(ckpt_path, device)
|
| 139 |
+
model.eval()
|
| 140 |
+
target_s = SHAPES.index(expected_shape)
|
| 141 |
+
target_c = COLORS.index(expected_color)
|
| 142 |
+
x_all = _images_to_tensor(images)
|
| 143 |
+
hits = 0
|
| 144 |
+
for i in range(0, x_all.shape[0], batch_size):
|
| 145 |
+
x = x_all[i:i + batch_size].to(device)
|
| 146 |
+
logit_s, logit_c = model(x)
|
| 147 |
+
pred_s = logit_s.argmax(dim=1)
|
| 148 |
+
pred_c = logit_c.argmax(dim=1)
|
| 149 |
+
hits += int(((pred_s == target_s) & (pred_c == target_c)).sum().item())
|
| 150 |
+
return hits / float(x_all.shape[0])
|
sprig/eval/prompts.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fixed 32-prompt evaluation bank, minimal pairs, and held-out combos.
|
| 2 |
+
|
| 3 |
+
The bank composition is pinned by the project plan (Part 3):
|
| 4 |
+
8 seen single-object, 8 relations, 3 counts, 1 containment, 2 sizes,
|
| 5 |
+
2 backgrounds, 4 held-out combos, 2 partial, 2 CLEVR-style = 32 prompts.
|
| 6 |
+
|
| 7 |
+
Prompt phrasings follow the training caption templates in
|
| 8 |
+
sprig/data/procgen/captions.py (T1/T2/T3/T5/T6/T7/T9 realizations; the
|
| 9 |
+
attribute-dropped forms are valid partial captions), and the CLEVR-style
|
| 10 |
+
prompts follow sprig/data/clevr/prep.py's synthesized caption grammar.
|
| 11 |
+
|
| 12 |
+
The four compositional holdout combos {blue triangle, red ring, green star,
|
| 13 |
+
yellow cross} never appear in training data; they appear here (only) as the
|
| 14 |
+
held-out generalization probes.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from typing import List, Tuple
|
| 19 |
+
|
| 20 |
+
# Canonical attribute vocabularies — single source of truth is
|
| 21 |
+
# sprig/data/procgen/vocab.py; literals kept as fallback for lean imports.
|
| 22 |
+
try:
|
| 23 |
+
from sprig.data.procgen.vocab import COLOR_NAMES as _COLOR_NAMES
|
| 24 |
+
from sprig.data.procgen.vocab import SHAPES as _SHAPES
|
| 25 |
+
|
| 26 |
+
COLORS: List[str] = list(_COLOR_NAMES)
|
| 27 |
+
SHAPES: List[str] = list(_SHAPES)
|
| 28 |
+
except Exception: # pragma: no cover
|
| 29 |
+
COLORS = ["red", "green", "blue", "yellow", "orange", "purple", "cyan", "magenta"]
|
| 30 |
+
SHAPES = ["circle", "square", "triangle", "rectangle", "diamond", "star", "cross", "ring"]
|
| 31 |
+
|
| 32 |
+
SIZES: List[str] = ["small", "large"]
|
| 33 |
+
RELATIONS: List[str] = ["to the left of", "to the right of", "above", "below"]
|
| 34 |
+
|
| 35 |
+
# (color, shape) pairs excluded from all training scenes/captions.
|
| 36 |
+
HELDOUT_COMBOS: List[Tuple[str, str]] = [
|
| 37 |
+
("blue", "triangle"),
|
| 38 |
+
("red", "ring"),
|
| 39 |
+
("green", "star"),
|
| 40 |
+
("yellow", "cross"),
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
_SINGLE_OBJECT = [
|
| 44 |
+
"a red circle",
|
| 45 |
+
"a blue square",
|
| 46 |
+
"a green triangle",
|
| 47 |
+
"a yellow star",
|
| 48 |
+
"a purple diamond",
|
| 49 |
+
"an orange cross",
|
| 50 |
+
"a cyan ring",
|
| 51 |
+
"a magenta rectangle",
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
_RELATIONS = [
|
| 55 |
+
"a red circle to the left of a blue square",
|
| 56 |
+
"a green diamond to the right of a yellow circle",
|
| 57 |
+
"a purple square above an orange circle",
|
| 58 |
+
"a cyan triangle below a magenta square",
|
| 59 |
+
"a yellow rectangle to the left of a purple star",
|
| 60 |
+
"an orange square to the right of a cyan diamond",
|
| 61 |
+
"a magenta circle above a red square",
|
| 62 |
+
"a blue diamond below a green rectangle",
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
_COUNTS = [
|
| 66 |
+
"a scene with two shapes on a white background",
|
| 67 |
+
"three shapes: a red circle, a blue square, and a green diamond",
|
| 68 |
+
"a scene with four shapes",
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
_CONTAINMENT = [
|
| 72 |
+
"a yellow circle inside a purple frame",
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
_SIZES = [
|
| 76 |
+
"a large orange diamond",
|
| 77 |
+
"a small cyan square",
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
_BACKGROUNDS = [
|
| 81 |
+
"a purple circle on a black background",
|
| 82 |
+
"a red square on a gray background",
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
_HELDOUT = ["a {} {}".format(c, s) for (c, s) in HELDOUT_COMBOS]
|
| 86 |
+
|
| 87 |
+
_PARTIAL = [
|
| 88 |
+
"a circle",
|
| 89 |
+
"a striped square",
|
| 90 |
+
]
|
| 91 |
+
|
| 92 |
+
_CLEVR_STYLE = [
|
| 93 |
+
"a large red rubber cube to the left of a small blue metal sphere",
|
| 94 |
+
"a scene with three objects, including a small green metal cylinder",
|
| 95 |
+
]
|
| 96 |
+
|
| 97 |
+
PROMPTS: List[str] = (
|
| 98 |
+
_SINGLE_OBJECT
|
| 99 |
+
+ _RELATIONS
|
| 100 |
+
+ _COUNTS
|
| 101 |
+
+ _CONTAINMENT
|
| 102 |
+
+ _SIZES
|
| 103 |
+
+ _BACKGROUNDS
|
| 104 |
+
+ _HELDOUT
|
| 105 |
+
+ _PARTIAL
|
| 106 |
+
+ _CLEVR_STYLE
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Named groups for reporting / grid row labels.
|
| 110 |
+
PROMPT_GROUPS = {
|
| 111 |
+
"single_object": _SINGLE_OBJECT,
|
| 112 |
+
"relations": _RELATIONS,
|
| 113 |
+
"counts": _COUNTS,
|
| 114 |
+
"containment": _CONTAINMENT,
|
| 115 |
+
"sizes": _SIZES,
|
| 116 |
+
"backgrounds": _BACKGROUNDS,
|
| 117 |
+
"heldout_combos": _HELDOUT,
|
| 118 |
+
"partial": _PARTIAL,
|
| 119 |
+
"clevr_style": _CLEVR_STYLE,
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
# (prompt_a, prompt_b, changed_attribute); attribute in
|
| 123 |
+
# {"color", "shape", "relation", "size"}.
|
| 124 |
+
MINIMAL_PAIRS: List[Tuple[str, str, str]] = [
|
| 125 |
+
("a red circle", "a blue circle", "color"),
|
| 126 |
+
("a green square", "a purple square", "color"),
|
| 127 |
+
("a yellow diamond", "a cyan diamond", "color"),
|
| 128 |
+
("an orange ring", "a magenta ring", "color"),
|
| 129 |
+
(
|
| 130 |
+
"a red circle to the left of a blue square",
|
| 131 |
+
"a red circle to the right of a blue square",
|
| 132 |
+
"relation",
|
| 133 |
+
),
|
| 134 |
+
(
|
| 135 |
+
"a green circle above a yellow square",
|
| 136 |
+
"a green circle below a yellow square",
|
| 137 |
+
"relation",
|
| 138 |
+
),
|
| 139 |
+
("a small purple square", "a large purple square", "size"),
|
| 140 |
+
("a red circle", "a red square", "shape"),
|
| 141 |
+
]
|
sprig/eval/report.py
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Full evaluation report for a checkpoint: metrics.json + final_report.md.
|
| 2 |
+
|
| 3 |
+
Expected data_dir layout (plan Part 1/3 formats):
|
| 4 |
+
data_dir/val/ images.u8 [N,64,64,3], emb.f16 (packed ragged),
|
| 5 |
+
emb_offsets.i64 [N+1], meta.jsonl
|
| 6 |
+
data_dir/parse_eval/ same files; meta lines carry the GT tree under
|
| 7 |
+
"tree" (or "gt_tree")
|
| 8 |
+
data_dir/train/images.u8 (optional; B0 is fit on val images otherwise)
|
| 9 |
+
data_dir/t5/null.f16 null-caption embedding, packed f16 [L0*768]
|
| 10 |
+
data_dir/t5/promptbank.npz npz {emb: f16 [P,Lmax,768] zero-padded,
|
| 11 |
+
len: i32 [P]} from sprig.data.embed_t5 --prompts-out,
|
| 12 |
+
32 prompts in sprig.eval.prompts.PROMPTS order
|
| 13 |
+
data_dir/t5/minimal_pairs.npz same npz format, 16 prompts interleaved
|
| 14 |
+
a0,b0,a1,b1,... in MINIMAL_PAIRS order (optional —
|
| 15 |
+
the prompt-control gate is SKIPPED if absent)
|
| 16 |
+
|
| 17 |
+
Stages that need a missing optional input are recorded as None and their
|
| 18 |
+
gate reported SKIPPED, so the report degrades gracefully while any gate that
|
| 19 |
+
can be evaluated is a hard PASS/FAIL.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
import math
|
| 25 |
+
import os
|
| 26 |
+
from typing import Dict, List, Optional, Sequence, Tuple
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
import torch
|
| 30 |
+
from PIL import Image
|
| 31 |
+
|
| 32 |
+
from sprig.eval import color_checks, monitors, tree_metrics
|
| 33 |
+
from sprig.eval import baseline_pixmix
|
| 34 |
+
from sprig.eval.prompts import COLORS, HELDOUT_COMBOS, MINIMAL_PAIRS, PROMPTS, SHAPES
|
| 35 |
+
|
| 36 |
+
LOG2 = math.log(2.0)
|
| 37 |
+
DIMS = 3.0 * 64 * 64
|
| 38 |
+
|
| 39 |
+
GATE_THRESH = {
|
| 40 |
+
"b0_margin": 0.15,
|
| 41 |
+
"delta_c": 0.05,
|
| 42 |
+
"recall_tier1": 0.70,
|
| 43 |
+
"recall_tier2": 0.50,
|
| 44 |
+
"visible_cut_f1": 0.60,
|
| 45 |
+
"attribute_move": 0.80,
|
| 46 |
+
"relation_accuracy": 0.70,
|
| 47 |
+
"holdout_probe": 0.60,
|
| 48 |
+
"s_eff_frac": 0.25,
|
| 49 |
+
"alive_texel_frac": 0.50,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# --------------------------------------------------------------- data access
|
| 54 |
+
|
| 55 |
+
class MemmapSplit:
|
| 56 |
+
"""Reader for one dataset split in the plan's raw-memmap format."""
|
| 57 |
+
|
| 58 |
+
def __init__(self, split_dir: str):
|
| 59 |
+
self.dir = split_dir
|
| 60 |
+
img_path = os.path.join(split_dir, "images.u8")
|
| 61 |
+
n_bytes = os.path.getsize(img_path)
|
| 62 |
+
self.n = n_bytes // (64 * 64 * 3)
|
| 63 |
+
self.images = np.memmap(img_path, dtype=np.uint8, mode="r", shape=(self.n, 64, 64, 3))
|
| 64 |
+
off_path = os.path.join(split_dir, "emb_offsets.i64")
|
| 65 |
+
emb_path = os.path.join(split_dir, "emb.f16")
|
| 66 |
+
self.emb_offsets = np.fromfile(off_path, dtype=np.int64)
|
| 67 |
+
n_rows = int(self.emb_offsets[-1])
|
| 68 |
+
self.emb = np.memmap(emb_path, dtype=np.float16, mode="r", shape=(n_rows, 768))
|
| 69 |
+
self.meta: List[dict] = []
|
| 70 |
+
meta_path = os.path.join(split_dir, "meta.jsonl")
|
| 71 |
+
if os.path.exists(meta_path):
|
| 72 |
+
with open(meta_path) as f:
|
| 73 |
+
self.meta = [json.loads(line) for line in f if line.strip()]
|
| 74 |
+
|
| 75 |
+
def __len__(self) -> int:
|
| 76 |
+
return self.n
|
| 77 |
+
|
| 78 |
+
def image(self, i: int) -> np.ndarray:
|
| 79 |
+
return np.asarray(self.images[i])
|
| 80 |
+
|
| 81 |
+
def emb_i(self, i: int) -> torch.Tensor:
|
| 82 |
+
lo, hi = int(self.emb_offsets[i]), int(self.emb_offsets[i + 1])
|
| 83 |
+
return torch.from_numpy(np.array(self.emb[lo:hi])).to(torch.float16)
|
| 84 |
+
|
| 85 |
+
def tier(self, i: int) -> int:
|
| 86 |
+
return int(self.meta[i].get("tier", 0)) if self.meta else 0
|
| 87 |
+
|
| 88 |
+
def tree(self, i: int) -> Optional[dict]:
|
| 89 |
+
if not self.meta:
|
| 90 |
+
return None
|
| 91 |
+
return self.meta[i].get("tree") or self.meta[i].get("gt_tree")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def load_prompt_npz(path: str) -> List[torch.Tensor]:
|
| 95 |
+
"""embed_t5 --prompts-out npz {emb [P,Lmax,768] f16, len [P] i32} ->
|
| 96 |
+
list of unpadded [L_i,768] f16 tensors."""
|
| 97 |
+
z = np.load(path)
|
| 98 |
+
emb, lens = z["emb"], z["len"]
|
| 99 |
+
return [
|
| 100 |
+
torch.from_numpy(np.array(emb[i, : int(lens[i])])).to(torch.float16)
|
| 101 |
+
for i in range(emb.shape[0])
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def load_null_emb(t5_dir: str) -> torch.Tensor:
|
| 106 |
+
flat = np.fromfile(os.path.join(t5_dir, "null.f16"), dtype=np.float16)
|
| 107 |
+
return torch.from_numpy(flat.reshape(-1, 768)).to(torch.float16)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def pad_embs(embs: Sequence[torch.Tensor]) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 111 |
+
"""Ragged [L_i,768] f16 list -> (emb [B,Lmax,768] f16, emb_len [B] i32)."""
|
| 112 |
+
lens = torch.tensor([e.shape[0] for e in embs], dtype=torch.int32)
|
| 113 |
+
lmax = int(lens.max())
|
| 114 |
+
out = torch.zeros(len(embs), lmax, 768, dtype=torch.float16)
|
| 115 |
+
for i, e in enumerate(embs):
|
| 116 |
+
out[i, : e.shape[0]] = e
|
| 117 |
+
return out, lens
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# -------------------------------------------------------------- model access
|
| 121 |
+
|
| 122 |
+
def _build_model_from_cfg(cfg):
|
| 123 |
+
"""Instantiate SPRIGModel from a checkpoint 'config' entry, which may be a
|
| 124 |
+
SPRIGConfig, a full train-yaml dict (model section nested), or a flat dict
|
| 125 |
+
of SPRIGConfig fields."""
|
| 126 |
+
import dataclasses
|
| 127 |
+
|
| 128 |
+
from sprig.model.sprig import SPRIGConfig, SPRIGModel # lazy: heavy import
|
| 129 |
+
|
| 130 |
+
if isinstance(cfg, SPRIGConfig):
|
| 131 |
+
return SPRIGModel(cfg)
|
| 132 |
+
if isinstance(cfg, dict):
|
| 133 |
+
section = cfg.get("model", cfg)
|
| 134 |
+
if isinstance(section, dict):
|
| 135 |
+
fields = {f.name for f in dataclasses.fields(SPRIGConfig)}
|
| 136 |
+
kw = {k: v for k, v in section.items() if k in fields}
|
| 137 |
+
return SPRIGModel(SPRIGConfig(**kw))
|
| 138 |
+
try:
|
| 139 |
+
return SPRIGModel(cfg)
|
| 140 |
+
except TypeError:
|
| 141 |
+
return SPRIGModel(**cfg)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def load_model(ckpt_path: str, device: str = "cpu"):
|
| 145 |
+
"""Load a SPRIGModel from a training checkpoint (lazy model import).
|
| 146 |
+
|
| 147 |
+
Prefers EMA weights (DESIGN: EMA is eval-only). train.py checkpoints
|
| 148 |
+
store EMA as {"decay", "shadow": {param_name: tensor}} — the shadow is
|
| 149 |
+
overlaid on the raw 'model' state dict (which also carries the buffers).
|
| 150 |
+
"""
|
| 151 |
+
ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
|
| 152 |
+
if isinstance(ckpt, torch.nn.Module):
|
| 153 |
+
model = ckpt
|
| 154 |
+
else:
|
| 155 |
+
cfg = ckpt.get("config") or ckpt.get("cfg") or {}
|
| 156 |
+
model = _build_model_from_cfg(cfg)
|
| 157 |
+
state = None
|
| 158 |
+
for key in ("ema", "ema_state_dict", "model", "model_state_dict", "state_dict"):
|
| 159 |
+
if key in ckpt:
|
| 160 |
+
state = ckpt[key]
|
| 161 |
+
break
|
| 162 |
+
if state is None:
|
| 163 |
+
state = ckpt
|
| 164 |
+
if isinstance(state, dict) and isinstance(state.get("shadow"), dict):
|
| 165 |
+
base = dict(ckpt.get("model") or {})
|
| 166 |
+
base.update(state["shadow"])
|
| 167 |
+
state = base
|
| 168 |
+
model.load_state_dict(state)
|
| 169 |
+
model = model.to(device)
|
| 170 |
+
# Report numbers are untempered by definition: mid-anneal checkpoints carry
|
| 171 |
+
# a nonzero eta buffer which silently deflates bpd/delta_c (eval-audit
|
| 172 |
+
# finding C1 — the 75k ckpt reported bpd 0.53 instead of 3.97).
|
| 173 |
+
eta = getattr(model, "eta", None)
|
| 174 |
+
if isinstance(eta, torch.Tensor):
|
| 175 |
+
eta.data.fill_(0.0)
|
| 176 |
+
model.eval()
|
| 177 |
+
if hasattr(model, "report_mode"):
|
| 178 |
+
model.report_mode = True
|
| 179 |
+
return model
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _as_u8_numpy(images) -> np.ndarray:
|
| 183 |
+
if isinstance(images, torch.Tensor):
|
| 184 |
+
images = images.detach().cpu().numpy()
|
| 185 |
+
return np.asarray(images).astype(np.uint8)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _log_marginal(model, images: np.ndarray, embs: Sequence[torch.Tensor], device: str) -> np.ndarray:
|
| 189 |
+
emb, emb_len = pad_embs(embs)
|
| 190 |
+
img = torch.from_numpy(np.array(images, dtype=np.uint8)).to(device)
|
| 191 |
+
with torch.no_grad():
|
| 192 |
+
logz = model.log_marginal(img, emb.to(device), emb_len.to(device))
|
| 193 |
+
return logz.detach().cpu().to(torch.float64).numpy()
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def bpd_from_logz(logz: np.ndarray) -> np.ndarray:
|
| 197 |
+
return -np.asarray(logz, dtype=np.float64) / (DIMS * LOG2)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _sample_images(model, emb: torch.Tensor, seed_struct: int, seed_material: int,
|
| 201 |
+
n: int, device: str) -> np.ndarray:
|
| 202 |
+
e, elen = pad_embs([emb])
|
| 203 |
+
with torch.no_grad():
|
| 204 |
+
images, _trees = model.sample(
|
| 205 |
+
e.to(device), elen.to(device), seed_struct, seed_material, n
|
| 206 |
+
)
|
| 207 |
+
return _as_u8_numpy(images)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _parse_one(model, image: np.ndarray, emb: torch.Tensor, device: str):
|
| 211 |
+
e, elen = pad_embs([emb])
|
| 212 |
+
img = torch.from_numpy(np.array(image[None], dtype=np.uint8)).to(device)
|
| 213 |
+
with torch.no_grad():
|
| 214 |
+
result = model.map_parse(img, e.to(device), elen.to(device))
|
| 215 |
+
if result and isinstance(result[0], (list, tuple)): # batched return
|
| 216 |
+
result = result[0]
|
| 217 |
+
return list(result)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ------------------------------------------------------------- image grids
|
| 221 |
+
|
| 222 |
+
def image_grid(rows: Sequence[Sequence[np.ndarray]], pad: int = 2) -> Image.Image:
|
| 223 |
+
"""[R][C] of u8 [64,64,3] arrays -> one PIL image with white padding."""
|
| 224 |
+
r, c = len(rows), max(len(row) for row in rows)
|
| 225 |
+
h = w = 64
|
| 226 |
+
canvas = np.full((r * (h + pad) + pad, c * (w + pad) + pad, 3), 255, dtype=np.uint8)
|
| 227 |
+
for i, row in enumerate(rows):
|
| 228 |
+
for j, img in enumerate(row):
|
| 229 |
+
y, x = pad + i * (h + pad), pad + j * (w + pad)
|
| 230 |
+
canvas[y:y + h, x:x + w] = np.asarray(img, dtype=np.uint8)
|
| 231 |
+
return Image.fromarray(canvas)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def save_grid_jpeg(rows: Sequence[Sequence[np.ndarray]], path: str) -> None:
|
| 235 |
+
image_grid(rows).save(path, format="JPEG", quality=92)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ------------------------------------------------------------ eval stages
|
| 239 |
+
|
| 240 |
+
def eval_bpd(model, split: MemmapSplit, device: str, max_images: int = 2000,
|
| 241 |
+
batch_size: int = 32) -> Dict[str, object]:
|
| 242 |
+
n = min(len(split), max_images)
|
| 243 |
+
bpds = np.zeros(n)
|
| 244 |
+
tiers = np.array([split.tier(i) for i in range(n)], dtype=np.int64)
|
| 245 |
+
for i in range(0, n, batch_size):
|
| 246 |
+
j = min(i + batch_size, n)
|
| 247 |
+
embs = [split.emb_i(k) for k in range(i, j)]
|
| 248 |
+
logz = _log_marginal(model, np.asarray(split.images[i:j]), embs, device)
|
| 249 |
+
bpds[i:j] = bpd_from_logz(logz)
|
| 250 |
+
per_tier = {
|
| 251 |
+
int(t): float(bpds[tiers == t].mean()) for t in np.unique(tiers)
|
| 252 |
+
}
|
| 253 |
+
ge1 = bpds[tiers >= 1]
|
| 254 |
+
return {
|
| 255 |
+
"bpd_val": float(bpds.mean()),
|
| 256 |
+
"bpd_per_tier": per_tier,
|
| 257 |
+
"bpd_tier_ge1": float(ge1.mean()) if ge1.size else float(bpds.mean()),
|
| 258 |
+
"tier_ge1_index": np.nonzero(tiers >= 1)[0],
|
| 259 |
+
"n": n,
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def eval_delta_c(model, split: MemmapSplit, null_emb: torch.Tensor, device: str,
|
| 264 |
+
max_images: int = 512, batch_size: int = 32) -> float:
|
| 265 |
+
n = min(len(split), max_images)
|
| 266 |
+
total = 0.0
|
| 267 |
+
for i in range(0, n, batch_size):
|
| 268 |
+
j = min(i + batch_size, n)
|
| 269 |
+
imgs = np.asarray(split.images[i:j])
|
| 270 |
+
embs = [split.emb_i(k) for k in range(i, j)]
|
| 271 |
+
logz_c = _log_marginal(model, imgs, embs, device)
|
| 272 |
+
logz_0 = _log_marginal(model, imgs, [null_emb] * (j - i), device)
|
| 273 |
+
total += float((bpd_from_logz(logz_0) - bpd_from_logz(logz_c)).sum())
|
| 274 |
+
return total / n
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def eval_caption_swap(model, split: MemmapSplit, device: str, n_groups: int = 4,
|
| 278 |
+
group: int = 8) -> float:
|
| 279 |
+
"""8-way in-batch caption swap: fraction of images whose own caption gives
|
| 280 |
+
a higher logZ than all 7 swapped captions."""
|
| 281 |
+
rng = np.random.default_rng(0)
|
| 282 |
+
n = len(split)
|
| 283 |
+
wins = 0
|
| 284 |
+
total = 0
|
| 285 |
+
for _ in range(n_groups):
|
| 286 |
+
idx = rng.choice(n, size=group, replace=False)
|
| 287 |
+
imgs = np.asarray(split.images[np.sort(idx)])
|
| 288 |
+
embs = [split.emb_i(int(i)) for i in np.sort(idx)]
|
| 289 |
+
scores = np.zeros((group, group))
|
| 290 |
+
for j in range(group):
|
| 291 |
+
scores[:, j] = _log_marginal(model, imgs, [embs[j]] * group, device)
|
| 292 |
+
for i in range(group):
|
| 293 |
+
off_diag = np.delete(scores[i], i)
|
| 294 |
+
wins += int(scores[i, i] > off_diag.max())
|
| 295 |
+
total += 1
|
| 296 |
+
return wins / float(total)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def eval_b0(fit_images: np.ndarray, eval_images: np.ndarray, steps: int = 2000,
|
| 300 |
+
device: str = "cpu") -> float:
|
| 301 |
+
b0 = baseline_pixmix.fit(fit_images, steps=steps, device=device)
|
| 302 |
+
return float(b0.bpd(eval_images))
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def eval_tree_metrics(model, split: MemmapSplit, device: str,
|
| 306 |
+
max_images: int = 512) -> Dict[str, object]:
|
| 307 |
+
n = min(len(split), max_images)
|
| 308 |
+
recalls: Dict[int, List[float]] = {}
|
| 309 |
+
f1s: List[float] = []
|
| 310 |
+
aris: List[float] = []
|
| 311 |
+
for i in range(n):
|
| 312 |
+
gt = split.tree(i)
|
| 313 |
+
if gt is None:
|
| 314 |
+
continue
|
| 315 |
+
img = split.image(i)
|
| 316 |
+
parse = _parse_one(model, img, split.emb_i(i), device)
|
| 317 |
+
recalls.setdefault(split.tier(i), []).append(
|
| 318 |
+
tree_metrics.object_cell_recall(parse, gt)
|
| 319 |
+
)
|
| 320 |
+
f1s.append(tree_metrics.visible_cut_f1(parse, gt, img))
|
| 321 |
+
aris.append(tree_metrics.leaf_ari(parse, gt))
|
| 322 |
+
out: Dict[str, object] = {
|
| 323 |
+
"visible_cut_f1": float(np.mean(f1s)) if f1s else None,
|
| 324 |
+
"leaf_ari": float(np.mean(aris)) if aris else None,
|
| 325 |
+
"recall_per_tier": {t: float(np.mean(v)) for t, v in recalls.items()},
|
| 326 |
+
}
|
| 327 |
+
out["recall_tier1"] = out["recall_per_tier"].get(1) # type: ignore[union-attr]
|
| 328 |
+
out["recall_tier2"] = out["recall_per_tier"].get(2) # type: ignore[union-attr]
|
| 329 |
+
return out
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def sample_prompt_bank_grid(model, prompt_embs: Sequence[torch.Tensor], out_path: str,
|
| 333 |
+
device: str, n_seeds: int = 8, seed0: int = 0) -> None:
|
| 334 |
+
rows = []
|
| 335 |
+
for i, emb in enumerate(prompt_embs):
|
| 336 |
+
row = [
|
| 337 |
+
_sample_images(model, emb, seed0 + 1000 * s + i, seed0 + 5000 + 1000 * s + i,
|
| 338 |
+
1, device)[0]
|
| 339 |
+
for s in range(n_seeds)
|
| 340 |
+
]
|
| 341 |
+
rows.append(row)
|
| 342 |
+
save_grid_jpeg(rows, out_path)
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def layout_material_grid(model, emb: torch.Tensor, out_path: str, device: str,
|
| 346 |
+
k: int = 4) -> None:
|
| 347 |
+
"""k x k grid: rows = frozen structural seed, cols = rerolled material seed."""
|
| 348 |
+
rows = []
|
| 349 |
+
for r in range(k):
|
| 350 |
+
rows.append([
|
| 351 |
+
_sample_images(model, emb, 100 + r, 900 + c, 1, device)[0] for c in range(k)
|
| 352 |
+
])
|
| 353 |
+
save_grid_jpeg(rows, out_path)
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def _tokens(prompt: str) -> List[str]:
|
| 357 |
+
return prompt.replace(",", " ").split()
|
| 358 |
+
|
| 359 |
+
def _color_in(prompt: str) -> List[str]:
|
| 360 |
+
return [t for t in _tokens(prompt) if t in COLORS]
|
| 361 |
+
|
| 362 |
+
def _shape_in(prompt: str) -> List[str]:
|
| 363 |
+
return [t for t in _tokens(prompt) if t in SHAPES]
|
| 364 |
+
|
| 365 |
+
def _relation_in(prompt: str) -> Optional[str]:
|
| 366 |
+
for rel in ("left of", "right of"):
|
| 367 |
+
if rel in prompt:
|
| 368 |
+
return rel
|
| 369 |
+
for rel in ("above", "below"):
|
| 370 |
+
if rel in prompt.split():
|
| 371 |
+
return rel
|
| 372 |
+
return None
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _median_object_area(images: np.ndarray) -> float:
|
| 376 |
+
areas = []
|
| 377 |
+
for img in images:
|
| 378 |
+
objs = color_checks.extract(img)["objects"]
|
| 379 |
+
if objs:
|
| 380 |
+
areas.append(objs[0]["area"])
|
| 381 |
+
return float(np.median(areas)) if areas else 0.0
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def eval_minimal_pairs(model, pair_embs: Sequence[torch.Tensor], device: str,
|
| 385 |
+
n_seeds: int = 64, probe_ckpt: Optional[str] = None,
|
| 386 |
+
batch: int = 16) -> Dict[str, object]:
|
| 387 |
+
"""pair_embs: 16 embeddings, (a_i, b_i) interleaved, MINIMAL_PAIRS order."""
|
| 388 |
+
per_pair: Dict[str, float] = {}
|
| 389 |
+
move_scores: List[float] = []
|
| 390 |
+
rel_scores: List[float] = []
|
| 391 |
+
for p, (prompt_a, prompt_b, attr) in enumerate(MINIMAL_PAIRS):
|
| 392 |
+
emb_a, emb_b = pair_embs[2 * p], pair_embs[2 * p + 1]
|
| 393 |
+
imgs_b = np.concatenate([
|
| 394 |
+
_sample_images(model, emb_b, 10 * p + s, 77 + 10 * p + s, 1, device)
|
| 395 |
+
for s in range(n_seeds)
|
| 396 |
+
])
|
| 397 |
+
score: Optional[float] = None
|
| 398 |
+
if attr == "color":
|
| 399 |
+
new_color = [c for c in _color_in(prompt_b) if c not in _color_in(prompt_a)][0]
|
| 400 |
+
hits = [
|
| 401 |
+
any(o["color"] == new_color for o in color_checks.extract(im)["objects"])
|
| 402 |
+
for im in imgs_b
|
| 403 |
+
]
|
| 404 |
+
score = float(np.mean(hits))
|
| 405 |
+
elif attr == "relation":
|
| 406 |
+
colors = _color_in(prompt_b)
|
| 407 |
+
rel = _relation_in(prompt_b)
|
| 408 |
+
oks = []
|
| 409 |
+
for im in imgs_b:
|
| 410 |
+
r = color_checks.relation_holds(
|
| 411 |
+
color_checks.extract(im), colors[0], colors[1], rel
|
| 412 |
+
)
|
| 413 |
+
oks.append(bool(r) if r is not None else False)
|
| 414 |
+
score = float(np.mean(oks))
|
| 415 |
+
elif attr == "size":
|
| 416 |
+
imgs_a = np.concatenate([
|
| 417 |
+
_sample_images(model, emb_a, 10 * p + s, 77 + 10 * p + s, 1, device)
|
| 418 |
+
for s in range(n_seeds)
|
| 419 |
+
])
|
| 420 |
+
bigger_in_b = "large" in _tokens(prompt_b)
|
| 421 |
+
area_a, area_b = _median_object_area(imgs_a), _median_object_area(imgs_b)
|
| 422 |
+
score = float(area_b > area_a) if bigger_in_b else float(area_b < area_a)
|
| 423 |
+
elif attr == "shape" and probe_ckpt is not None:
|
| 424 |
+
from sprig.eval import probe
|
| 425 |
+
|
| 426 |
+
score = probe.score_generations(
|
| 427 |
+
imgs_b, _shape_in(prompt_b)[0], _color_in(prompt_b)[0], probe_ckpt, device
|
| 428 |
+
)
|
| 429 |
+
if score is not None:
|
| 430 |
+
per_pair["{} -> {}".format(prompt_a, prompt_b)] = score
|
| 431 |
+
(rel_scores if attr == "relation" else move_scores).append(score)
|
| 432 |
+
return {
|
| 433 |
+
"per_pair": per_pair,
|
| 434 |
+
"attribute_move": float(np.mean(move_scores)) if move_scores else None,
|
| 435 |
+
"relation_accuracy": float(np.mean(rel_scores)) if rel_scores else None,
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def eval_heldout_combos(model, prompt_embs: Sequence[torch.Tensor], probe_ckpt: str,
|
| 440 |
+
device: str, n: int = 64) -> Dict[str, object]:
|
| 441 |
+
from sprig.eval import probe
|
| 442 |
+
|
| 443 |
+
model_probe = probe.load_probe(probe_ckpt, device)
|
| 444 |
+
per_combo: Dict[str, float] = {}
|
| 445 |
+
for color, shape in HELDOUT_COMBOS:
|
| 446 |
+
prompt = "a {} {}".format(color, shape)
|
| 447 |
+
emb = prompt_embs[PROMPTS.index(prompt)]
|
| 448 |
+
imgs = np.concatenate([
|
| 449 |
+
_sample_images(model, emb, 313 + s, 707 + s, 1, device) for s in range(n)
|
| 450 |
+
])
|
| 451 |
+
per_combo[prompt] = probe.score_generations(imgs, shape, color, model_probe, device)
|
| 452 |
+
return {
|
| 453 |
+
"per_combo": per_combo,
|
| 454 |
+
"holdout_probe_acc": float(np.mean(list(per_combo.values()))),
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def eval_health(model, split: MemmapSplit, device: str, n_images: int = 64) -> Dict[str, object]:
|
| 459 |
+
n = min(len(split), n_images)
|
| 460 |
+
imgs = torch.from_numpy(np.array(split.images[:n], dtype=np.uint8)).to(device)
|
| 461 |
+
emb, elen = pad_embs([split.emb_i(i) for i in range(n)])
|
| 462 |
+
stats = model.posterior_usage(imgs, emb.to(device), elen.to(device))
|
| 463 |
+
usage = stats["symbol_usage"]
|
| 464 |
+
texels = stats["texel_usage"]
|
| 465 |
+
s = int(usage.shape[-1]) if hasattr(usage, "shape") else len(usage)
|
| 466 |
+
t_v = int(texels.shape[-1]) if hasattr(texels, "shape") else len(texels)
|
| 467 |
+
return {
|
| 468 |
+
"S": s,
|
| 469 |
+
"T_v": t_v,
|
| 470 |
+
"s_eff": monitors.s_eff(usage),
|
| 471 |
+
"alive_texel_frac": monitors.alive_texels(texels, t_v) / float(t_v),
|
| 472 |
+
"node_entropy": float(stats.get("node_entropy", float("nan"))),
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
# ---------------------------------------------------------------- gates
|
| 477 |
+
|
| 478 |
+
def _gate(ok: Optional[bool], detail: str) -> Dict[str, str]:
|
| 479 |
+
if ok is None:
|
| 480 |
+
return {"status": "SKIPPED", "detail": detail}
|
| 481 |
+
return {"status": "PASS" if ok else "FAIL", "detail": detail}
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def evaluate_gates(metrics: Dict[str, object]) -> Dict[str, Dict[str, str]]:
|
| 485 |
+
"""The plan's 5 proof-of-concept success criteria as PASS/FAIL/SKIPPED."""
|
| 486 |
+
g = GATE_THRESH
|
| 487 |
+
gates: Dict[str, Dict[str, str]] = {}
|
| 488 |
+
|
| 489 |
+
bpd = metrics.get("bpd_tier_ge1")
|
| 490 |
+
b0 = metrics.get("b0_bpd")
|
| 491 |
+
dc = metrics.get("delta_c")
|
| 492 |
+
if bpd is None or b0 is None or dc is None:
|
| 493 |
+
gates["1_likelihood"] = _gate(None, "missing bpd/b0/delta_c")
|
| 494 |
+
else:
|
| 495 |
+
ok = (b0 - bpd >= g["b0_margin"]) and (dc >= g["delta_c"])
|
| 496 |
+
gates["1_likelihood"] = _gate(ok, "bpd(tier>=1)={:.3f} vs B0={:.3f} (need margin >= {}); delta_c={:.3f} (need >= {})".format(bpd, b0, g["b0_margin"], dc, g["delta_c"]))
|
| 497 |
+
|
| 498 |
+
tm = metrics.get("tree") or {}
|
| 499 |
+
r1, r2, f1 = tm.get("recall_tier1"), tm.get("recall_tier2"), tm.get("visible_cut_f1")
|
| 500 |
+
if r1 is None or r2 is None or f1 is None:
|
| 501 |
+
gates["2_parses"] = _gate(None, "missing tree metrics")
|
| 502 |
+
else:
|
| 503 |
+
ok = (r1 >= g["recall_tier1"]) and (r2 >= g["recall_tier2"]) and (f1 >= g["visible_cut_f1"])
|
| 504 |
+
gates["2_parses"] = _gate(ok, "recall t1={:.2f} (>= {}), t2={:.2f} (>= {}), cut F1={:.2f} (>= {})".format(r1, g["recall_tier1"], r2, g["recall_tier2"], f1, g["visible_cut_f1"]))
|
| 505 |
+
|
| 506 |
+
ps = metrics.get("prompt_swap") or {}
|
| 507 |
+
move, rel = ps.get("attribute_move"), ps.get("relation_accuracy")
|
| 508 |
+
if move is None or rel is None:
|
| 509 |
+
gates["3_prompt_control"] = _gate(None, "missing minimal-pair scores")
|
| 510 |
+
else:
|
| 511 |
+
ok = (move >= g["attribute_move"]) and (rel >= g["relation_accuracy"])
|
| 512 |
+
gates["3_prompt_control"] = _gate(ok, "attribute-move={:.2f} (>= {}), relation={:.2f} (>= {})".format(move, g["attribute_move"], rel, g["relation_accuracy"]))
|
| 513 |
+
|
| 514 |
+
hp = metrics.get("holdout_probe_acc")
|
| 515 |
+
if hp is None:
|
| 516 |
+
gates["4_compositional"] = _gate(None, "no probe checkpoint / heldout scores")
|
| 517 |
+
else:
|
| 518 |
+
gates["4_compositional"] = _gate(hp >= g["holdout_probe"], "held-out combo probe acc={:.2f} (>= {})".format(hp, g["holdout_probe"]))
|
| 519 |
+
|
| 520 |
+
health = metrics.get("health") or {}
|
| 521 |
+
s_eff, s = health.get("s_eff"), health.get("S")
|
| 522 |
+
alive = health.get("alive_texel_frac")
|
| 523 |
+
if s_eff is None or alive is None or s is None:
|
| 524 |
+
gates["5_health"] = _gate(None, "missing health stats")
|
| 525 |
+
else:
|
| 526 |
+
ok = (s_eff >= g["s_eff_frac"] * s) and (alive >= g["alive_texel_frac"])
|
| 527 |
+
gates["5_health"] = _gate(ok, "S_eff={:.0f} (>= {:.0f}), alive texels={:.0%} (>= {:.0%})".format(s_eff, g["s_eff_frac"] * s, alive, g["alive_texel_frac"]))
|
| 528 |
+
return gates
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def write_report(metrics: Dict[str, object], gates: Dict[str, Dict[str, str]],
|
| 532 |
+
out_dir: str) -> None:
|
| 533 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 534 |
+
|
| 535 |
+
def _default(o):
|
| 536 |
+
if isinstance(o, (np.integer,)):
|
| 537 |
+
return int(o)
|
| 538 |
+
if isinstance(o, (np.floating,)):
|
| 539 |
+
return float(o)
|
| 540 |
+
if isinstance(o, np.ndarray):
|
| 541 |
+
return o.tolist()
|
| 542 |
+
return str(o)
|
| 543 |
+
|
| 544 |
+
with open(os.path.join(out_dir, "metrics.json"), "w") as f:
|
| 545 |
+
json.dump({"metrics": metrics, "gates": gates}, f, indent=2, default=_default)
|
| 546 |
+
lines = ["# SPRIG v0.1 — Final Report", "", "## Success-criteria gates", ""]
|
| 547 |
+
lines.append("| gate | status | detail |")
|
| 548 |
+
lines.append("|---|---|---|")
|
| 549 |
+
for name, g in gates.items():
|
| 550 |
+
lines.append("| {} | **{}** | {} |".format(name, g["status"], g["detail"]))
|
| 551 |
+
lines += ["", "## Key metrics", "", "```json"]
|
| 552 |
+
lines.append(json.dumps(metrics, indent=2, default=_default))
|
| 553 |
+
lines.append("```")
|
| 554 |
+
with open(os.path.join(out_dir, "final_report.md"), "w") as f:
|
| 555 |
+
f.write("\n".join(lines) + "\n")
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
# ------------------------------------------------------------- orchestrator
|
| 559 |
+
|
| 560 |
+
def run_report(
|
| 561 |
+
ckpt_path: Optional[str],
|
| 562 |
+
data_dir: str,
|
| 563 |
+
out_dir: str,
|
| 564 |
+
device: str = "cpu",
|
| 565 |
+
model=None,
|
| 566 |
+
probe_ckpt: Optional[str] = None,
|
| 567 |
+
n_bank_seeds: int = 8,
|
| 568 |
+
n_pair_seeds: int = 64,
|
| 569 |
+
max_bpd_images: int = 2000,
|
| 570 |
+
max_parse_images: int = 512,
|
| 571 |
+
b0_steps: int = 2000,
|
| 572 |
+
) -> Dict[str, object]:
|
| 573 |
+
"""Run the full evaluation suite on a checkpoint.
|
| 574 |
+
|
| 575 |
+
Pass `model` directly to skip checkpoint loading (used by tests/harness).
|
| 576 |
+
Returns the metrics dict; writes metrics.json, final_report.md and the
|
| 577 |
+
sample grids into out_dir.
|
| 578 |
+
"""
|
| 579 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 580 |
+
if model is None:
|
| 581 |
+
model = load_model(ckpt_path, device)
|
| 582 |
+
|
| 583 |
+
val_dir = os.path.join(data_dir, "val")
|
| 584 |
+
if not os.path.isdir(val_dir):
|
| 585 |
+
val_dir = os.path.join(data_dir, "val_fast")
|
| 586 |
+
val = MemmapSplit(val_dir)
|
| 587 |
+
t5_dir = os.path.join(data_dir, "t5")
|
| 588 |
+
|
| 589 |
+
metrics: Dict[str, object] = {}
|
| 590 |
+
|
| 591 |
+
# --- likelihood
|
| 592 |
+
bpd_stats = eval_bpd(model, val, device, max_images=max_bpd_images)
|
| 593 |
+
tier_ge1_index = bpd_stats.pop("tier_ge1_index")
|
| 594 |
+
metrics.update(bpd_stats)
|
| 595 |
+
|
| 596 |
+
null_path = os.path.join(t5_dir, "null.f16")
|
| 597 |
+
metrics["delta_c"] = (
|
| 598 |
+
eval_delta_c(model, val, load_null_emb(t5_dir), device)
|
| 599 |
+
if os.path.exists(null_path) else None
|
| 600 |
+
)
|
| 601 |
+
metrics["caption_swap_win_frac"] = eval_caption_swap(model, val, device)
|
| 602 |
+
|
| 603 |
+
# --- B0 baseline (fit on train images when available, else val)
|
| 604 |
+
train_imgs_path = os.path.join(data_dir, "train", "images.u8")
|
| 605 |
+
if os.path.exists(train_imgs_path):
|
| 606 |
+
n_fit = os.path.getsize(train_imgs_path) // (64 * 64 * 3)
|
| 607 |
+
fit_imgs = np.memmap(train_imgs_path, dtype=np.uint8, mode="r",
|
| 608 |
+
shape=(n_fit, 64, 64, 3))[: 4096]
|
| 609 |
+
else:
|
| 610 |
+
fit_imgs = np.asarray(val.images[: min(len(val), 4096)])
|
| 611 |
+
eval_idx = tier_ge1_index if len(tier_ge1_index) else np.arange(min(len(val), 512))
|
| 612 |
+
eval_imgs = np.asarray(val.images)[eval_idx[:512]]
|
| 613 |
+
metrics["b0_bpd"] = eval_b0(np.asarray(fit_imgs), eval_imgs, steps=b0_steps, device=device)
|
| 614 |
+
|
| 615 |
+
# --- parse metrics
|
| 616 |
+
parse_dir = os.path.join(data_dir, "parse_eval")
|
| 617 |
+
metrics["tree"] = (
|
| 618 |
+
eval_tree_metrics(model, MemmapSplit(parse_dir), device, max_parse_images)
|
| 619 |
+
if os.path.isdir(parse_dir) else None
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
# --- prompt bank grids
|
| 623 |
+
bank_npz = os.path.join(t5_dir, "promptbank.npz")
|
| 624 |
+
prompt_embs: Optional[List[torch.Tensor]] = None
|
| 625 |
+
if os.path.exists(bank_npz):
|
| 626 |
+
prompt_embs = load_prompt_npz(bank_npz)
|
| 627 |
+
sample_prompt_bank_grid(
|
| 628 |
+
model, prompt_embs, os.path.join(out_dir, "prompt_bank_grid.jpg"),
|
| 629 |
+
device, n_seeds=n_bank_seeds,
|
| 630 |
+
)
|
| 631 |
+
layout_material_grid(
|
| 632 |
+
model, prompt_embs[8], os.path.join(out_dir, "layout_material_grid.jpg"), device
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
# --- minimal pairs
|
| 636 |
+
pairs_npz = os.path.join(t5_dir, "minimal_pairs.npz")
|
| 637 |
+
if os.path.exists(pairs_npz):
|
| 638 |
+
pair_embs = load_prompt_npz(pairs_npz)
|
| 639 |
+
metrics["prompt_swap"] = eval_minimal_pairs(
|
| 640 |
+
model, pair_embs, device, n_seeds=n_pair_seeds, probe_ckpt=probe_ckpt
|
| 641 |
+
)
|
| 642 |
+
else:
|
| 643 |
+
metrics["prompt_swap"] = None
|
| 644 |
+
|
| 645 |
+
# --- held-out combos via probe
|
| 646 |
+
metrics["holdout_probe_acc"] = None
|
| 647 |
+
if probe_ckpt is not None and prompt_embs is not None:
|
| 648 |
+
heldout = eval_heldout_combos(model, prompt_embs, probe_ckpt, device)
|
| 649 |
+
metrics["heldout_per_combo"] = heldout["per_combo"]
|
| 650 |
+
metrics["holdout_probe_acc"] = heldout["holdout_probe_acc"]
|
| 651 |
+
|
| 652 |
+
# --- health
|
| 653 |
+
metrics["health"] = eval_health(model, val, device)
|
| 654 |
+
|
| 655 |
+
gates = evaluate_gates(metrics)
|
| 656 |
+
write_report(metrics, gates, out_dir)
|
| 657 |
+
return metrics
|
sprig/eval/tree_metrics.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Parse metrics vs ground-truth region trees (identifiability-aware).
|
| 2 |
+
|
| 3 |
+
Node schema (duck-typed; both are accepted everywhere a tree is expected):
|
| 4 |
+
- GT JSON node (meta.jsonl "tree", written by sprig/data/procgen/sampler.py):
|
| 5 |
+
internal {"rect": [x0,y0,x1,y1] px (x1/y1 exclusive), "axis": "V"|"H",
|
| 6 |
+
"cut": px, "children": [lo, hi]}; leaf {"rect", "leaf": true,
|
| 7 |
+
"obj": int|null, "fill": ...} — a leaf is an object-role leaf iff "obj"
|
| 8 |
+
is not null (an inline "object" dict or "role" == "object" also works).
|
| 9 |
+
"axis"/"cut" fields are ignored: cut geometry is derived from the children
|
| 10 |
+
rects, which makes the metrics robust to axis naming conventions.
|
| 11 |
+
- Model ParseNode (sprig/model/sprig.py): attributes rect, children (list or
|
| 12 |
+
None), axis, cut_px, symbol, texel.
|
| 13 |
+
|
| 14 |
+
`parse` arguments may be a single root node or the list returned by
|
| 15 |
+
`model.map_parse` (all nodes are collected either way).
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from typing import Dict, List, Optional, Sequence, Tuple
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
Rect = Tuple[int, int, int, int]
|
| 24 |
+
CutSeg = Tuple[str, float, float, float] # (axis "V"|"H", position, ext_lo, ext_hi)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------- accessors
|
| 28 |
+
|
| 29 |
+
def _rect(node) -> Rect:
|
| 30 |
+
r = node["rect"] if isinstance(node, dict) else node.rect
|
| 31 |
+
x0, y0, x1, y1 = (int(v) for v in r)
|
| 32 |
+
return (x0, y0, x1, y1)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _children(node) -> List:
|
| 36 |
+
if isinstance(node, dict):
|
| 37 |
+
ch = node.get("children")
|
| 38 |
+
else:
|
| 39 |
+
ch = getattr(node, "children", None)
|
| 40 |
+
return list(ch) if ch else []
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _object(node) -> Optional[dict]:
|
| 44 |
+
"""Object payload of an object-role leaf, else None.
|
| 45 |
+
|
| 46 |
+
Accepts the sampler schema (leaf {"obj": <int index>|null}), an inline
|
| 47 |
+
{"object": {...}} dict, or a "role": "object" marker."""
|
| 48 |
+
if isinstance(node, dict):
|
| 49 |
+
if node.get("object") is not None:
|
| 50 |
+
return node["object"]
|
| 51 |
+
if node.get("obj") is not None:
|
| 52 |
+
return {"index": node["obj"]}
|
| 53 |
+
if node.get("role") == "object":
|
| 54 |
+
return {}
|
| 55 |
+
return None
|
| 56 |
+
return getattr(node, "object", None)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def collect_nodes(tree_or_nodes) -> List:
|
| 60 |
+
"""Flatten a root node / list of nodes into a deduped list of all nodes."""
|
| 61 |
+
roots = tree_or_nodes if isinstance(tree_or_nodes, (list, tuple)) else [tree_or_nodes]
|
| 62 |
+
out: List = []
|
| 63 |
+
seen = set()
|
| 64 |
+
stack = list(roots)
|
| 65 |
+
while stack:
|
| 66 |
+
n = stack.pop()
|
| 67 |
+
if id(n) in seen:
|
| 68 |
+
continue
|
| 69 |
+
seen.add(id(n))
|
| 70 |
+
out.append(n)
|
| 71 |
+
stack.extend(_children(n))
|
| 72 |
+
return out
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def leaves(tree_or_nodes) -> List:
|
| 76 |
+
return [n for n in collect_nodes(tree_or_nodes) if not _children(n)]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def cut_segments(tree_or_nodes) -> List[CutSeg]:
|
| 80 |
+
"""All cut segments, derived from each internal node's children rects."""
|
| 81 |
+
segs: List[CutSeg] = []
|
| 82 |
+
for n in collect_nodes(tree_or_nodes):
|
| 83 |
+
ch = _children(n)
|
| 84 |
+
if len(ch) != 2:
|
| 85 |
+
continue
|
| 86 |
+
(ax0, ay0, ax1, ay1), (bx0, by0, bx1, by1) = _rect(ch[0]), _rect(ch[1])
|
| 87 |
+
if ax1 == bx0 and ay0 == by0 and ay1 == by1: # vertical line at x=ax1
|
| 88 |
+
segs.append(("V", float(ax1), float(ay0), float(ay1)))
|
| 89 |
+
elif bx1 == ax0 and ay0 == by0 and ay1 == by1:
|
| 90 |
+
segs.append(("V", float(bx1), float(ay0), float(ay1)))
|
| 91 |
+
elif ay1 == by0 and ax0 == bx0 and ax1 == bx1: # horizontal line at y=ay1
|
| 92 |
+
segs.append(("H", float(ay1), float(ax0), float(ax1)))
|
| 93 |
+
elif by1 == ay0 and ax0 == bx0 and ax1 == bx1:
|
| 94 |
+
segs.append(("H", float(by1), float(ax0), float(ax1)))
|
| 95 |
+
return segs
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ------------------------------------------------------------------ metrics
|
| 99 |
+
|
| 100 |
+
def _iou(a: Rect, b: Rect) -> float:
|
| 101 |
+
ix = max(0, min(a[2], b[2]) - max(a[0], b[0]))
|
| 102 |
+
iy = max(0, min(a[3], b[3]) - max(a[1], b[1]))
|
| 103 |
+
inter = ix * iy
|
| 104 |
+
if inter == 0:
|
| 105 |
+
return 0.0
|
| 106 |
+
area_a = (a[2] - a[0]) * (a[3] - a[1])
|
| 107 |
+
area_b = (b[2] - b[0]) * (b[3] - b[1])
|
| 108 |
+
return inter / float(area_a + area_b - inter)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def object_cell_recall(parse, gt, iou_thresh: float = 0.8) -> float:
|
| 112 |
+
"""Fraction of GT object-role leaves whose rect is matched (IoU >= thresh)
|
| 113 |
+
by ANY node rect of the parse (internal or leaf). Vacuously 1.0 when the
|
| 114 |
+
GT tree has no object leaves."""
|
| 115 |
+
gt_obj_rects = [_rect(n) for n in leaves(gt) if _object(n) is not None]
|
| 116 |
+
if not gt_obj_rects:
|
| 117 |
+
return 1.0
|
| 118 |
+
parse_rects = [_rect(n) for n in collect_nodes(parse)]
|
| 119 |
+
hit = 0
|
| 120 |
+
for gr in gt_obj_rects:
|
| 121 |
+
if any(_iou(gr, pr) >= iou_thresh for pr in parse_rects):
|
| 122 |
+
hit += 1
|
| 123 |
+
return hit / float(len(gt_obj_rects))
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _cut_contrast(image: np.ndarray, seg: CutSeg, band: int = 2) -> float:
|
| 127 |
+
"""Max-channel abs difference of mean colors on the two sides of a cut."""
|
| 128 |
+
img = np.asarray(image, dtype=np.float64)
|
| 129 |
+
axis, pos, lo, hi = seg
|
| 130 |
+
p, a, b = int(round(pos)), int(round(lo)), int(round(hi))
|
| 131 |
+
if axis == "V":
|
| 132 |
+
left = img[a:b, max(0, p - band):p, :]
|
| 133 |
+
right = img[a:b, p:min(img.shape[1], p + band), :]
|
| 134 |
+
else:
|
| 135 |
+
left = img[max(0, p - band):p, a:b, :]
|
| 136 |
+
right = img[p:min(img.shape[0], p + band), a:b, :]
|
| 137 |
+
if left.size == 0 or right.size == 0:
|
| 138 |
+
return 0.0
|
| 139 |
+
return float(
|
| 140 |
+
np.abs(left.reshape(-1, 3).mean(axis=0) - right.reshape(-1, 3).mean(axis=0)).max()
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _visible(segs: Sequence[CutSeg], image: np.ndarray, contrast_thresh: float) -> List[CutSeg]:
|
| 145 |
+
return [s for s in segs if _cut_contrast(image, s) > contrast_thresh]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _seg_match(a: CutSeg, b: CutSeg, tol_px: float) -> bool:
|
| 149 |
+
if a[0] != b[0] or abs(a[1] - b[1]) > tol_px:
|
| 150 |
+
return False
|
| 151 |
+
overlap = min(a[3], b[3]) - max(a[2], b[2])
|
| 152 |
+
shorter = min(a[3] - a[2], b[3] - b[2])
|
| 153 |
+
return shorter > 0 and overlap >= 0.5 * shorter
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def visible_cut_f1(
|
| 157 |
+
parse,
|
| 158 |
+
gt,
|
| 159 |
+
image: np.ndarray,
|
| 160 |
+
contrast_thresh: float = 20.0,
|
| 161 |
+
tol_px: float = 1.5,
|
| 162 |
+
) -> float:
|
| 163 |
+
"""Boundary F1 over VISIBLE cut segments.
|
| 164 |
+
|
| 165 |
+
Both GT and parse cut segments are filtered by actual cross-cut mean-color
|
| 166 |
+
contrast in `image` (> contrast_thresh, max over RGB channels); visible
|
| 167 |
+
parse segments are greedily one-to-one matched to visible GT segments
|
| 168 |
+
(same axis, position within tol_px, >=50% extent overlap).
|
| 169 |
+
"""
|
| 170 |
+
gt_segs = _visible(cut_segments(gt), image, contrast_thresh)
|
| 171 |
+
pr_segs = _visible(cut_segments(parse), image, contrast_thresh)
|
| 172 |
+
if not gt_segs and not pr_segs:
|
| 173 |
+
return 1.0
|
| 174 |
+
if not gt_segs or not pr_segs:
|
| 175 |
+
return 0.0
|
| 176 |
+
used = [False] * len(gt_segs)
|
| 177 |
+
tp = 0
|
| 178 |
+
for ps in pr_segs:
|
| 179 |
+
cands = [
|
| 180 |
+
(abs(ps[1] - gs[1]), j)
|
| 181 |
+
for j, gs in enumerate(gt_segs)
|
| 182 |
+
if not used[j] and _seg_match(ps, gs, tol_px)
|
| 183 |
+
]
|
| 184 |
+
if cands:
|
| 185 |
+
used[min(cands)[1]] = True
|
| 186 |
+
tp += 1
|
| 187 |
+
prec = tp / float(len(pr_segs))
|
| 188 |
+
rec = tp / float(len(gt_segs))
|
| 189 |
+
return 0.0 if tp == 0 else 2 * prec * rec / (prec + rec)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def leaf_assignment_map(tree_or_nodes, canvas: int = 64) -> np.ndarray:
|
| 193 |
+
"""[canvas,canvas] int32 map: pixel -> index of the leaf covering it."""
|
| 194 |
+
out = np.full((canvas, canvas), -1, dtype=np.int32)
|
| 195 |
+
for i, leaf in enumerate(leaves(tree_or_nodes)):
|
| 196 |
+
x0, y0, x1, y1 = _rect(leaf)
|
| 197 |
+
out[y0:y1, x0:x1] = i
|
| 198 |
+
return out
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def adjusted_rand_index(a: np.ndarray, b: np.ndarray) -> float:
|
| 202 |
+
"""ARI between two integer label maps of identical shape (numpy only)."""
|
| 203 |
+
a = np.asarray(a).ravel()
|
| 204 |
+
b = np.asarray(b).ravel()
|
| 205 |
+
_, ai = np.unique(a, return_inverse=True)
|
| 206 |
+
_, bi = np.unique(b, return_inverse=True)
|
| 207 |
+
na, nb = ai.max() + 1, bi.max() + 1
|
| 208 |
+
cont = np.bincount(ai * nb + bi, minlength=na * nb).reshape(na, nb).astype(np.float64)
|
| 209 |
+
n = cont.sum()
|
| 210 |
+
|
| 211 |
+
def _comb2(x: np.ndarray) -> np.ndarray:
|
| 212 |
+
return x * (x - 1) / 2.0
|
| 213 |
+
|
| 214 |
+
sum_ij = _comb2(cont).sum()
|
| 215 |
+
sum_a = _comb2(cont.sum(axis=1)).sum()
|
| 216 |
+
sum_b = _comb2(cont.sum(axis=0)).sum()
|
| 217 |
+
total = _comb2(np.array(n))
|
| 218 |
+
expected = sum_a * sum_b / total if total > 0 else 0.0
|
| 219 |
+
max_index = 0.5 * (sum_a + sum_b)
|
| 220 |
+
denom = max_index - expected
|
| 221 |
+
if denom == 0:
|
| 222 |
+
return 1.0 if sum_ij == max_index else 0.0
|
| 223 |
+
return float((sum_ij - expected) / denom)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def leaf_ari(parse, gt, canvas: int = 64) -> float:
|
| 227 |
+
"""Adjusted Rand index between parse and GT pixel->leaf assignment maps."""
|
| 228 |
+
return adjusted_rand_index(
|
| 229 |
+
leaf_assignment_map(parse, canvas), leaf_assignment_map(gt, canvas)
|
| 230 |
+
)
|
sprig/model/__init__.py
ADDED
|
File without changes
|
sprig/model/atlas.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Texel atlas renderer and leaf emission scoring (DESIGN.md section 4, atlas.py).
|
| 2 |
+
|
| 3 |
+
The renderer amortizes the terminal decoder into a canonical per-texel atlas of
|
| 4 |
+
discretized-logistic parameters: atlas [B, T_v, 40, 16, 16] (channel layout in
|
| 5 |
+
sprig/model/dl.py). A trainable per-texel additive bias grid [T_v, 40, 16, 16]
|
| 6 |
+
is added to the renderer output — the resurrection-writable parameterization
|
| 7 |
+
(F3.3 / M1.2): dead texels are revived by directly overwriting their bias rows.
|
| 8 |
+
|
| 9 |
+
FiLM mapping (illumination field Phi [B, 8, 16, 16] over the canvas), sampled
|
| 10 |
+
bilinearly at each leaf's canvas-center position -> phi [..., 8]:
|
| 11 |
+
|
| 12 |
+
scale_c = 1 + phi[c] for c in {0: R, 1: G, 2: B}
|
| 13 |
+
shift_c = phi[3 + c] for c in {0: R, 1: G, 2: B}
|
| 14 |
+
phi[6:8] reserved (unused in v0.1)
|
| 15 |
+
|
| 16 |
+
Each DL *mean* channel group (R/G/B means, identically across the 4 mixture
|
| 17 |
+
components) is modulated as mean' = mean * scale_c + shift_c. Log-scales,
|
| 18 |
+
weights and coupling coefficients are not modulated.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import math
|
| 23 |
+
import os
|
| 24 |
+
from typing import Optional, Tuple
|
| 25 |
+
|
| 26 |
+
import torch
|
| 27 |
+
import torch.nn as nn
|
| 28 |
+
import torch.nn.functional as F
|
| 29 |
+
|
| 30 |
+
from sprig.model import dl
|
| 31 |
+
from sprig.model.gmt import CrossAttnBlock, caption_padding_mask
|
| 32 |
+
|
| 33 |
+
ATLAS_RES = 16
|
| 34 |
+
ATLAS_WIDTH = 256
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_LOG_HALF_RANGE = math.log(127.5)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _ell_chunk_math(
|
| 41 |
+
pooled_g: torch.Tensor,
|
| 42 |
+
crops_c: torch.Tensor,
|
| 43 |
+
w_c: torch.Tensor,
|
| 44 |
+
sc_c: torch.Tensor,
|
| 45 |
+
sh_c: torch.Tensor,
|
| 46 |
+
) -> torch.Tensor:
|
| 47 |
+
"""One region-chunk of emission scores -> [B, ng, T_v] fp32.
|
| 48 |
+
|
| 49 |
+
Runs under gradient checkpointing: the per-pixel intermediates are
|
| 50 |
+
recomputed in backward instead of stored (storing them across all chunks
|
| 51 |
+
is >90GB at batch 256). Exactly the dl.dl_logprob math with FiLM'd means
|
| 52 |
+
(fp32 throughout), but unrolled over the 4 mixture components x 3 RGB
|
| 53 |
+
channels so every intermediate is a [B,ng,T_v,h,w] slab instead of the
|
| 54 |
+
[B,ng,T_v,4,3,h,w] block — torch.compile then fuses the whole chunk
|
| 55 |
+
without materializing multi-GiB buffers (the tensorized form made
|
| 56 |
+
inductor allocate the full block, ~18 GiB at B=128 leaf_chunk=49).
|
| 57 |
+
"""
|
| 58 |
+
# NOTE: a bf16 variant (inputs cast down, fp32 pixel-sum) was tried and
|
| 59 |
+
# REVERTED: eager bf16 shifts ell by tens of nats (bf16 ulp at |ell|~1e3
|
| 60 |
+
# is ~8) and fails the 0.1% loss parity gate by ~40x.
|
| 61 |
+
p = pooled_g.float() # [B,T_v,40,h,w]
|
| 62 |
+
x = crops_c.float() # [B,ng,3,h,w]
|
| 63 |
+
sc = sc_c.float() # [B,ng,3]
|
| 64 |
+
sh = sh_c.float()
|
| 65 |
+
# Mixture-weight log-softmax over the 4 weight channels (pooled-sized).
|
| 66 |
+
wl = [p[:, :, 10 * j] for j in range(dl.N_COMP)] # [B,T_v,h,w] each
|
| 67 |
+
wm = torch.logaddexp(torch.logaddexp(wl[0], wl[1]),
|
| 68 |
+
torch.logaddexp(wl[2], wl[3])) # logsumexp_j
|
| 69 |
+
x_ch = [x[:, :, c].unsqueeze(2) for c in range(3)] # [B,ng,1,h,w]
|
| 70 |
+
|
| 71 |
+
mix: Optional[torch.Tensor] = None
|
| 72 |
+
for j in range(dl.N_COMP):
|
| 73 |
+
acc: Optional[torch.Tensor] = None # sum_c log P_j(c)
|
| 74 |
+
for c in range(3):
|
| 75 |
+
mu = p[:, :, 10 * j + 1 + c].unsqueeze(1) # [B,1,T_v,h,w]
|
| 76 |
+
ls = p[:, :, 10 * j + 4 + c].clamp(
|
| 77 |
+
dl.LOGSCALE_MIN, dl.LOGSCALE_MAX).unsqueeze(1)
|
| 78 |
+
m = mu * sc[:, :, c, None, None, None] + sh[:, :, c, None, None, None]
|
| 79 |
+
if c == 1:
|
| 80 |
+
al = torch.tanh(p[:, :, 10 * j + 7]).unsqueeze(1)
|
| 81 |
+
m = m + al * x_ch[0]
|
| 82 |
+
elif c == 2:
|
| 83 |
+
be = torch.tanh(p[:, :, 10 * j + 8]).unsqueeze(1)
|
| 84 |
+
ga = torch.tanh(p[:, :, 10 * j + 9]).unsqueeze(1)
|
| 85 |
+
m = m + be * x_ch[0] + ga * x_ch[1]
|
| 86 |
+
xc = x_ch[c]
|
| 87 |
+
centered = xc - m # [B,ng,T_v,h,w]
|
| 88 |
+
inv_std = torch.exp(-ls)
|
| 89 |
+
plus_in = inv_std * (centered + dl._HALF_BIN)
|
| 90 |
+
min_in = inv_std * (centered - dl._HALF_BIN)
|
| 91 |
+
log_cdf_plus = plus_in - F.softplus(plus_in)
|
| 92 |
+
log_one_minus_cdf_min = -F.softplus(min_in)
|
| 93 |
+
cdf_delta = torch.sigmoid(plus_in) - torch.sigmoid(min_in)
|
| 94 |
+
mid_in = inv_std * centered
|
| 95 |
+
log_pdf_mid = mid_in - ls - 2.0 * F.softplus(mid_in)
|
| 96 |
+
log_prob_mid = torch.where(
|
| 97 |
+
cdf_delta > 1e-5,
|
| 98 |
+
torch.log(cdf_delta.clamp(min=1e-12)),
|
| 99 |
+
log_pdf_mid - _LOG_HALF_RANGE,
|
| 100 |
+
)
|
| 101 |
+
lp = torch.where(
|
| 102 |
+
xc < -0.999,
|
| 103 |
+
log_cdf_plus,
|
| 104 |
+
torch.where(xc > 0.999, log_one_minus_cdf_min, log_prob_mid),
|
| 105 |
+
)
|
| 106 |
+
acc = lp if acc is None else acc + lp
|
| 107 |
+
pc = acc + (wl[j] - wm).unsqueeze(1) # log w_j + log P_j
|
| 108 |
+
mix = pc if mix is None else torch.logaddexp(mix, pc)
|
| 109 |
+
# Per-pixel importance weights (object-pixel up-weighting; all-ones in the
|
| 110 |
+
# unweighted/eval path — multiplying by exactly 1.0 is bit-identical fp32,
|
| 111 |
+
# so likelihood parity is preserved).
|
| 112 |
+
return (mix * w_c.float().unsqueeze(2)).sum(dim=(-1, -2)).float() # [B,ng,T_v]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
_ELL_COMPILED = None # lazily-built torch.compile wrapper of _ell_chunk_math
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _ell_chunk_fn(device: torch.device):
|
| 119 |
+
"""Fused (torch.compile) emission-chunk kernel on CUDA; the eager
|
| 120 |
+
reference math elsewhere. The eager path is memory-bound on dozens of
|
| 121 |
+
unfused fp32 elementwise kernel passes per chunk; fusing them is a >3x
|
| 122 |
+
end-to-end step win. Same math, same dtypes — kill switch:
|
| 123 |
+
SPRIG_COMPILE=0."""
|
| 124 |
+
global _ELL_COMPILED
|
| 125 |
+
if device.type != "cuda" or os.environ.get("SPRIG_COMPILE", "1") == "0":
|
| 126 |
+
return _ell_chunk_math
|
| 127 |
+
if _ELL_COMPILED is None:
|
| 128 |
+
try:
|
| 129 |
+
# One graph per (group h/w, chunk rows, batch) shape; a process
|
| 130 |
+
# that probes several batch sizes exceeds dynamo's default limit
|
| 131 |
+
# of 8 and would silently fall back to slow eager for the rest.
|
| 132 |
+
import torch._dynamo.config as _dcfg
|
| 133 |
+
if getattr(_dcfg, "recompile_limit", 0) < 64:
|
| 134 |
+
_dcfg.recompile_limit = 64
|
| 135 |
+
if getattr(_dcfg, "cache_size_limit", 0) < 64:
|
| 136 |
+
_dcfg.cache_size_limit = 64
|
| 137 |
+
_ELL_COMPILED = torch.compile(_ell_chunk_math, dynamic=False)
|
| 138 |
+
except Exception:
|
| 139 |
+
_ELL_COMPILED = _ell_chunk_math
|
| 140 |
+
return _ELL_COMPILED
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def film_scale_shift(phi: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 144 |
+
"""phi [..., 8] -> (scale [..., 3], shift [..., 3]) per RGB mean group."""
|
| 145 |
+
return 1.0 + phi[..., 0:3], phi[..., 3:6]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def phi_at_leaf_centers(
|
| 149 |
+
Phi: torch.Tensor, rects: torch.Tensor, canvas_px: int
|
| 150 |
+
) -> torch.Tensor:
|
| 151 |
+
"""Bilinearly sample Phi [B, 8, 16, 16] at leaf-center canvas positions.
|
| 152 |
+
|
| 153 |
+
rects int [n, 4] (x0, y0, x1, y1 in px) -> phi [B, n, 8].
|
| 154 |
+
"""
|
| 155 |
+
B = Phi.shape[0]
|
| 156 |
+
cx = (rects[:, 0] + rects[:, 2]).float() / (2.0 * canvas_px) * 2.0 - 1.0
|
| 157 |
+
cy = (rects[:, 1] + rects[:, 3]).float() / (2.0 * canvas_px) * 2.0 - 1.0
|
| 158 |
+
grid = torch.stack([cx, cy], dim=-1).to(Phi.device) # [n, 2] (x, y)
|
| 159 |
+
grid = grid.view(1, -1, 1, 2).expand(B, -1, -1, -1)
|
| 160 |
+
out = F.grid_sample(Phi.float(), grid, mode="bilinear", align_corners=False)
|
| 161 |
+
return out.squeeze(-1).permute(0, 2, 1) # [B, n, 8]
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
class TexelAtlas(nn.Module):
|
| 165 |
+
"""cfg is duck-typed: needs T_v, d, caption_dim (optional: atlas_heads,
|
| 166 |
+
leaf_chunk)."""
|
| 167 |
+
|
| 168 |
+
def __init__(self, cfg) -> None:
|
| 169 |
+
super().__init__()
|
| 170 |
+
self.cfg = cfg
|
| 171 |
+
T_v, d = cfg.T_v, cfg.d
|
| 172 |
+
heads = getattr(cfg, "atlas_heads", 4)
|
| 173 |
+
self.leaf_chunk = int(getattr(cfg, "leaf_chunk", 16))
|
| 174 |
+
|
| 175 |
+
self.E_T = nn.Parameter(torch.randn(T_v, d) * 0.02)
|
| 176 |
+
self.q_proj = nn.Linear(d, ATLAS_WIDTH)
|
| 177 |
+
self.cap_proj = nn.Linear(cfg.caption_dim, ATLAS_WIDTH)
|
| 178 |
+
self.blocks = nn.ModuleList(
|
| 179 |
+
[CrossAttnBlock(ATLAS_WIDTH, heads) for _ in range(2)]
|
| 180 |
+
)
|
| 181 |
+
self.ln_out = nn.LayerNorm(ATLAS_WIDTH)
|
| 182 |
+
self.seed = nn.Linear(ATLAS_WIDTH, ATLAS_WIDTH * 4 * 4)
|
| 183 |
+
self.conv1 = nn.Conv2d(ATLAS_WIDTH, 128, kernel_size=3, padding=1)
|
| 184 |
+
self.conv2 = nn.Conv2d(128, dl.N_CH, kernel_size=3, padding=1)
|
| 185 |
+
# Resurrection-writable per-texel bias grid.
|
| 186 |
+
self.bias_grid = nn.Parameter(torch.zeros(T_v, dl.N_CH, ATLAS_RES, ATLAS_RES))
|
| 187 |
+
|
| 188 |
+
def render(self, emb: torch.Tensor, emb_len: torch.Tensor) -> torch.Tensor:
|
| 189 |
+
"""emb [B, L, 768], emb_len [B] -> atlas [B, T_v, 40, 16, 16]."""
|
| 190 |
+
B, L, _ = emb.shape
|
| 191 |
+
T_v = self.E_T.shape[0]
|
| 192 |
+
kv = self.cap_proj(emb.float())
|
| 193 |
+
kpm = caption_padding_mask(emb_len, L, emb.device)
|
| 194 |
+
|
| 195 |
+
h = self.q_proj(self.E_T).unsqueeze(0).expand(B, -1, -1) # [B, T_v, 256]
|
| 196 |
+
for blk in self.blocks:
|
| 197 |
+
h = blk(h, kv, kpm)
|
| 198 |
+
h = self.ln_out(h)
|
| 199 |
+
|
| 200 |
+
z = self.seed(h).reshape(B * T_v, ATLAS_WIDTH, 4, 4)
|
| 201 |
+
z = F.gelu(self.conv1(z))
|
| 202 |
+
z = F.interpolate(z, scale_factor=2, mode="nearest") # [.., 128, 8, 8]
|
| 203 |
+
z = self.conv2(z)
|
| 204 |
+
z = F.interpolate(z, scale_factor=2, mode="nearest") # [.., 40, 16, 16]
|
| 205 |
+
atlas = z.reshape(B, T_v, dl.N_CH, ATLAS_RES, ATLAS_RES)
|
| 206 |
+
return atlas + self.bias_grid.unsqueeze(0)
|
| 207 |
+
|
| 208 |
+
def score_leaves(
|
| 209 |
+
self,
|
| 210 |
+
atlas: torch.Tensor,
|
| 211 |
+
images: torch.Tensor,
|
| 212 |
+
lattice,
|
| 213 |
+
Phi: torch.Tensor,
|
| 214 |
+
chunk: Optional[int] = None,
|
| 215 |
+
pix_weight: Optional[torch.Tensor] = None,
|
| 216 |
+
) -> torch.Tensor:
|
| 217 |
+
"""Emission log-likelihoods for every (leaf region, texel) pair.
|
| 218 |
+
|
| 219 |
+
atlas [B, T_v, 40, 16, 16], images u8 [B, C, C, 3] (C = canvas),
|
| 220 |
+
Phi [B, 8, 16, 16] -> ell fp32 [B, n_leaf_regions, T_v], summed over
|
| 221 |
+
the region's pixels, in lattice.leaf_ids slot order. Vectorized per
|
| 222 |
+
leaf shape group, chunked over regions to bound memory.
|
| 223 |
+
|
| 224 |
+
pix_weight (optional) fp32 [B, C, C]: per-pixel importance weights on
|
| 225 |
+
the log-likelihood (object-pixel up-weighting during training). None
|
| 226 |
+
means all-ones (exact NLL) — used by log_marginal/eval/parsing.
|
| 227 |
+
"""
|
| 228 |
+
B, T_v = atlas.shape[0], atlas.shape[1]
|
| 229 |
+
canvas = lattice.canvas_px
|
| 230 |
+
step = int(chunk) if chunk is not None else self.leaf_chunk
|
| 231 |
+
|
| 232 |
+
x = dl.u8_to_unit(images).permute(0, 3, 1, 2).contiguous() # [B,3,C,C]
|
| 233 |
+
x_flat = x.reshape(B, 3, canvas * canvas)
|
| 234 |
+
if pix_weight is None:
|
| 235 |
+
w_flat = torch.ones(B, canvas * canvas, dtype=torch.float32,
|
| 236 |
+
device=atlas.device)
|
| 237 |
+
else:
|
| 238 |
+
w_flat = pix_weight.to(atlas.device, torch.float32).reshape(
|
| 239 |
+
B, canvas * canvas)
|
| 240 |
+
|
| 241 |
+
n_leaf = lattice.n_leaf_regions
|
| 242 |
+
ell = torch.zeros(B, n_leaf, T_v, dtype=torch.float32, device=atlas.device)
|
| 243 |
+
|
| 244 |
+
leaf_rects = lattice.regions[lattice.leaf_ids]
|
| 245 |
+
phi_leaf = phi_at_leaf_centers(Phi, leaf_rects, canvas) # [B,n_leaf,8]
|
| 246 |
+
scale, shift = film_scale_shift(phi_leaf) # [B,n_leaf,3]
|
| 247 |
+
|
| 248 |
+
use_ckpt = torch.is_grad_enabled() and (
|
| 249 |
+
atlas.requires_grad or Phi.requires_grad)
|
| 250 |
+
chunk_fn = _ell_chunk_fn(atlas.device)
|
| 251 |
+
|
| 252 |
+
groups = getattr(lattice, "shape_groups", None)
|
| 253 |
+
if groups is None: # foreign/stub lattice: derive on the fly
|
| 254 |
+
groups = []
|
| 255 |
+
for (h, w), slots in lattice.leaf_shape_groups().items():
|
| 256 |
+
slots = slots.to(atlas.device)
|
| 257 |
+
rects = lattice.regions[lattice.leaf_ids[slots]] # [n_g,4]
|
| 258 |
+
dy = torch.arange(h, device=atlas.device)
|
| 259 |
+
dx = torch.arange(w, device=atlas.device)
|
| 260 |
+
yy = rects[:, 1].view(-1, 1, 1).to(atlas.device) + dy.view(1, -1, 1)
|
| 261 |
+
xx = rects[:, 0].view(-1, 1, 1).to(atlas.device) + dx.view(1, 1, -1)
|
| 262 |
+
groups.append((h, w, slots, (yy * canvas + xx).reshape(-1)))
|
| 263 |
+
|
| 264 |
+
for h, w, slots, pix in groups:
|
| 265 |
+
if (h, w) == (ATLAS_RES, ATLAS_RES):
|
| 266 |
+
pooled = atlas # identity pool
|
| 267 |
+
else:
|
| 268 |
+
pooled = F.adaptive_avg_pool2d(
|
| 269 |
+
atlas.reshape(B * T_v, dl.N_CH, ATLAS_RES, ATLAS_RES), (h, w)
|
| 270 |
+
).reshape(B, T_v, dl.N_CH, h, w)
|
| 271 |
+
|
| 272 |
+
n_g = slots.shape[0]
|
| 273 |
+
crops = x_flat[:, :, pix].reshape(B, 3, n_g, h, w).permute(0, 2, 1, 3, 4)
|
| 274 |
+
w_crops = w_flat[:, pix].reshape(B, n_g, h, w)
|
| 275 |
+
|
| 276 |
+
sc = scale[:, slots] # [B,n_g,3]
|
| 277 |
+
sh = shift[:, slots]
|
| 278 |
+
|
| 279 |
+
for i0 in range(0, n_g, step):
|
| 280 |
+
i1 = min(n_g, i0 + step)
|
| 281 |
+
args = (pooled, crops[:, i0:i1].contiguous(),
|
| 282 |
+
w_crops[:, i0:i1].contiguous(),
|
| 283 |
+
sc[:, i0:i1].contiguous(), sh[:, i0:i1].contiguous())
|
| 284 |
+
if use_ckpt:
|
| 285 |
+
tot = torch.utils.checkpoint.checkpoint(
|
| 286 |
+
chunk_fn, *args, use_reentrant=False)
|
| 287 |
+
else:
|
| 288 |
+
tot = chunk_fn(*args)
|
| 289 |
+
ell[:, slots[i0:i1], :] = tot
|
| 290 |
+
|
| 291 |
+
return ell
|
sprig/model/dl.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Discretized-logistic mixture emissions (PixelCNN++ style) with RGB coupling.
|
| 2 |
+
|
| 3 |
+
Parameter layout (the "40 channels", DESIGN.md section 4): 4 mixture components,
|
| 4 |
+
each a contiguous block of 10 channels, so channel index = 10*j + t for
|
| 5 |
+
component j in 0..3 and t in:
|
| 6 |
+
|
| 7 |
+
t = 0 mixture-weight logit
|
| 8 |
+
t = 1..3 raw means for (R, G, B) (image scaled to [-1, 1])
|
| 9 |
+
t = 4..6 log-scales for (R, G, B) (clamped to [-7, 2])
|
| 10 |
+
t = 7..9 channel-coupling coeffs (alpha: G|R, beta: B|R, gamma: B|G),
|
| 11 |
+
squashed by tanh inside this module.
|
| 12 |
+
|
| 13 |
+
Coupled means (per component): m_R = mu_R; m_G = mu_G + alpha * x_R;
|
| 14 |
+
m_B = mu_B + beta * x_R + gamma * x_G, where x_R/x_G are OBSERVED values in
|
| 15 |
+
[-1, 1] (teacher-forced coupling, exactly as in PixelCNN++).
|
| 16 |
+
|
| 17 |
+
All math is done in fp32 regardless of input dtype.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import math
|
| 22 |
+
from typing import Tuple
|
| 23 |
+
|
| 24 |
+
import torch
|
| 25 |
+
import torch.nn.functional as F
|
| 26 |
+
|
| 27 |
+
N_COMP = 4
|
| 28 |
+
CH_PER_COMP = 10
|
| 29 |
+
N_CH = N_COMP * CH_PER_COMP # 40
|
| 30 |
+
|
| 31 |
+
WEIGHT_IDX = [10 * j for j in range(N_COMP)]
|
| 32 |
+
MEAN_IDX = [10 * j + 1 + c for j in range(N_COMP) for c in range(3)]
|
| 33 |
+
LOGSCALE_IDX = [10 * j + 4 + c for j in range(N_COMP) for c in range(3)]
|
| 34 |
+
COEFF_IDX = [10 * j + 7 + c for j in range(N_COMP) for c in range(3)]
|
| 35 |
+
|
| 36 |
+
LOGSCALE_MIN = -7.0
|
| 37 |
+
LOGSCALE_MAX = 2.0
|
| 38 |
+
_HALF_BIN = 1.0 / 255.0 # half bin width in [-1, 1] scale (256 levels)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def unpack_dl_params(
|
| 42 |
+
params: torch.Tensor,
|
| 43 |
+
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 44 |
+
"""Split raw params [..., 40, H, W] into mixture pieces.
|
| 45 |
+
|
| 46 |
+
Returns (logit_w [..., 4, H, W], means [..., 4, 3, H, W],
|
| 47 |
+
log_scales [..., 4, 3, H, W] clamped, coeffs [..., 4, 3, H, W] tanh-ed).
|
| 48 |
+
"""
|
| 49 |
+
if params.shape[-3] != N_CH:
|
| 50 |
+
raise ValueError("expected %d param channels, got %d" % (N_CH, params.shape[-3]))
|
| 51 |
+
p = params.float()
|
| 52 |
+
lead = p.shape[:-3]
|
| 53 |
+
hw = p.shape[-2:]
|
| 54 |
+
p = p.reshape(lead + (N_COMP, CH_PER_COMP) + hw)
|
| 55 |
+
logit_w = p[..., 0, :, :]
|
| 56 |
+
means = p[..., 1:4, :, :]
|
| 57 |
+
log_scales = p[..., 4:7, :, :].clamp(LOGSCALE_MIN, LOGSCALE_MAX)
|
| 58 |
+
coeffs = torch.tanh(p[..., 7:10, :, :])
|
| 59 |
+
return logit_w, means, log_scales, coeffs
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def couple_means(
|
| 63 |
+
means: torch.Tensor, coeffs: torch.Tensor, x: torch.Tensor
|
| 64 |
+
) -> torch.Tensor:
|
| 65 |
+
"""Coupled per-component means given observed pixels.
|
| 66 |
+
|
| 67 |
+
means/coeffs: [..., 4, 3, H, W]; x: broadcastable to [..., 3, H, W],
|
| 68 |
+
values in [-1, 1]. Returns coupled means [..., 4, 3, H, W].
|
| 69 |
+
"""
|
| 70 |
+
xb = x.float().unsqueeze(-4) # [..., 1, 3, H, W]
|
| 71 |
+
x_r = xb[..., 0, :, :]
|
| 72 |
+
x_g = xb[..., 1, :, :]
|
| 73 |
+
m_r = means[..., 0, :, :]
|
| 74 |
+
m_g = means[..., 1, :, :] + coeffs[..., 0, :, :] * x_r
|
| 75 |
+
m_b = means[..., 2, :, :] + coeffs[..., 1, :, :] * x_r + coeffs[..., 2, :, :] * x_g
|
| 76 |
+
m_r, m_g, m_b = torch.broadcast_tensors(m_r, m_g, m_b)
|
| 77 |
+
return torch.stack([m_r, m_g, m_b], dim=-3)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def dl_channel_logprobs(
|
| 81 |
+
means: torch.Tensor,
|
| 82 |
+
log_scales: torch.Tensor,
|
| 83 |
+
coeffs: torch.Tensor,
|
| 84 |
+
x: torch.Tensor,
|
| 85 |
+
) -> torch.Tensor:
|
| 86 |
+
"""Per-component, per-channel discretized-logistic log P(bin of x).
|
| 87 |
+
|
| 88 |
+
means/log_scales/coeffs: [..., 4, 3, H, W] (log_scales already clamped,
|
| 89 |
+
coeffs already tanh-ed — i.e. outputs of unpack_dl_params). x broadcastable
|
| 90 |
+
to [..., 3, H, W] in [-1, 1] on the 256-level grid. Returns [..., 4, 3, H, W].
|
| 91 |
+
Edge bins (0 and 255) integrate the full tails, so per-channel bin
|
| 92 |
+
probabilities sum to 1.
|
| 93 |
+
"""
|
| 94 |
+
xb = x.float().unsqueeze(-4)
|
| 95 |
+
m = couple_means(means, coeffs, x)
|
| 96 |
+
centered = xb - m
|
| 97 |
+
inv_std = torch.exp(-log_scales)
|
| 98 |
+
plus_in = inv_std * (centered + _HALF_BIN)
|
| 99 |
+
min_in = inv_std * (centered - _HALF_BIN)
|
| 100 |
+
|
| 101 |
+
log_cdf_plus = plus_in - F.softplus(plus_in) # log sigmoid(plus_in)
|
| 102 |
+
log_one_minus_cdf_min = -F.softplus(min_in) # log(1 - sigmoid(min_in))
|
| 103 |
+
cdf_delta = torch.sigmoid(plus_in) - torch.sigmoid(min_in)
|
| 104 |
+
mid_in = inv_std * centered
|
| 105 |
+
log_pdf_mid = mid_in - log_scales - 2.0 * F.softplus(mid_in)
|
| 106 |
+
|
| 107 |
+
log_prob_mid = torch.where(
|
| 108 |
+
cdf_delta > 1e-5,
|
| 109 |
+
torch.log(cdf_delta.clamp(min=1e-12)),
|
| 110 |
+
log_pdf_mid - math.log(127.5),
|
| 111 |
+
)
|
| 112 |
+
logp = torch.where(
|
| 113 |
+
xb < -0.999,
|
| 114 |
+
log_cdf_plus,
|
| 115 |
+
torch.where(xb > 0.999, log_one_minus_cdf_min, log_prob_mid),
|
| 116 |
+
)
|
| 117 |
+
return logp
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def dl_logprob(params: torch.Tensor, x: torch.Tensor) -> torch.Tensor:
|
| 121 |
+
"""Full mixture log-prob per pixel.
|
| 122 |
+
|
| 123 |
+
params [..., 40, H, W] raw; x broadcastable to [..., 3, H, W] in [-1, 1].
|
| 124 |
+
Returns fp32 [..., H, W]: log p(x_pixel) = logsumexp_j (log w_j +
|
| 125 |
+
sum_channels log P_j(channel)).
|
| 126 |
+
"""
|
| 127 |
+
logit_w, means, log_scales, coeffs = unpack_dl_params(params)
|
| 128 |
+
ch = dl_channel_logprobs(means, log_scales, coeffs, x) # [..., 4, 3, H, W]
|
| 129 |
+
per_comp = ch.sum(dim=-3) # [..., 4, H, W]
|
| 130 |
+
log_w = F.log_softmax(logit_w, dim=-3)
|
| 131 |
+
return torch.logsumexp(log_w + per_comp, dim=-3)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def dl_mean_pixels(params: torch.Tensor) -> torch.Tensor:
|
| 135 |
+
"""Deterministic 'DL-mean' rendering: per pixel pick the argmax-weight
|
| 136 |
+
component, then roll out the coupled means sequentially
|
| 137 |
+
(R = mu_R, G = mu_G + a R, B = mu_B + b R + c G), clamped to [-1, 1].
|
| 138 |
+
|
| 139 |
+
params [..., 40, H, W] -> pixels [..., 3, H, W] fp32 in [-1, 1].
|
| 140 |
+
"""
|
| 141 |
+
logit_w, means, log_scales, coeffs = unpack_dl_params(params)
|
| 142 |
+
idx = logit_w.argmax(dim=-3) # [..., H, W]
|
| 143 |
+
gather_idx = idx.unsqueeze(-3).unsqueeze(-4).expand(
|
| 144 |
+
means.shape[:-4] + (1, 3) + means.shape[-2:]
|
| 145 |
+
)
|
| 146 |
+
m = means.gather(-4, gather_idx).squeeze(-4) # [..., 3, H, W]
|
| 147 |
+
cf = coeffs.gather(-4, gather_idx).squeeze(-4)
|
| 148 |
+
r = m[..., 0, :, :].clamp(-1.0, 1.0)
|
| 149 |
+
g = (m[..., 1, :, :] + cf[..., 0, :, :] * r).clamp(-1.0, 1.0)
|
| 150 |
+
b = (m[..., 2, :, :] + cf[..., 1, :, :] * r + cf[..., 2, :, :] * g).clamp(-1.0, 1.0)
|
| 151 |
+
return torch.stack([r, g, b], dim=-3)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def u8_to_unit(x_u8: torch.Tensor) -> torch.Tensor:
|
| 155 |
+
"""uint8 [0,255] -> fp32 [-1, 1] on the 256-level grid."""
|
| 156 |
+
return x_u8.float() / 127.5 - 1.0
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def unit_to_u8(x: torch.Tensor) -> torch.Tensor:
|
| 160 |
+
"""fp32 [-1, 1] -> uint8, rounding to the nearest of the 256 levels."""
|
| 161 |
+
return ((x.clamp(-1.0, 1.0) + 1.0) * 127.5).round().clamp(0, 255).to(torch.uint8)
|
sprig/model/gmt.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Grammar Modulation Transformer (DESIGN.md section 4, gmt.py).
|
| 2 |
+
|
| 3 |
+
Symbol embeddings are queries; caption tokens are keys/values. There is no
|
| 4 |
+
symbol-symbol self-attention. Outputs the caption-conditioned quantities the
|
| 5 |
+
grammar needs: H, U (p(k|A,c) logits), cut-type logits, illumination field Phi,
|
| 6 |
+
plus the static tables V, W, P_T and the factorized termination head.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import NamedTuple
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn as nn
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
|
| 16 |
+
N_CUT_TYPES = 14
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class CrossAttnBlock(nn.Module):
|
| 20 |
+
"""Pre-LN {cross-attention(queries -> memory), FFN width*4} block."""
|
| 21 |
+
|
| 22 |
+
def __init__(self, width: int, n_heads: int) -> None:
|
| 23 |
+
super().__init__()
|
| 24 |
+
self.ln_q = nn.LayerNorm(width)
|
| 25 |
+
self.ln_kv = nn.LayerNorm(width)
|
| 26 |
+
self.attn = nn.MultiheadAttention(width, n_heads, batch_first=True)
|
| 27 |
+
self.ln_f = nn.LayerNorm(width)
|
| 28 |
+
self.ffn = nn.Sequential(
|
| 29 |
+
nn.Linear(width, 4 * width), nn.GELU(), nn.Linear(4 * width, width)
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
def forward(
|
| 33 |
+
self,
|
| 34 |
+
q: torch.Tensor,
|
| 35 |
+
kv: torch.Tensor,
|
| 36 |
+
key_padding_mask: torch.Tensor,
|
| 37 |
+
) -> torch.Tensor:
|
| 38 |
+
kvn = self.ln_kv(kv)
|
| 39 |
+
a, _ = self.attn(
|
| 40 |
+
self.ln_q(q), kvn, kvn,
|
| 41 |
+
key_padding_mask=key_padding_mask, need_weights=False,
|
| 42 |
+
)
|
| 43 |
+
h = q + a
|
| 44 |
+
return h + self.ffn(self.ln_f(h))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def caption_padding_mask(emb_len: torch.Tensor, L: int, device: torch.device) -> torch.Tensor:
|
| 48 |
+
"""bool [B, L], True = padding (ignored by attention). Position 0 is always
|
| 49 |
+
kept valid as a guard against fully-masked rows (NaN attention)."""
|
| 50 |
+
idx = torch.arange(L, device=device)
|
| 51 |
+
mask = idx.unsqueeze(0) >= emb_len.to(device).long().unsqueeze(1)
|
| 52 |
+
mask[:, 0] = False
|
| 53 |
+
return mask
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class GMTOut(NamedTuple):
|
| 57 |
+
H: torch.Tensor # [B, S, d]
|
| 58 |
+
U: torch.Tensor # [B, S, R] p(k|A,c) logits
|
| 59 |
+
cut_logits: torch.Tensor # [B, R, 14] cut-type logits per component
|
| 60 |
+
Phi: torch.Tensor # [B, 8, 16, 16] illumination field
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class GrammarModulationTransformer(nn.Module):
|
| 64 |
+
"""cfg is duck-typed: needs S, R, T_v, d, n_heads, d_t, caption_dim, n_geom."""
|
| 65 |
+
|
| 66 |
+
def __init__(self, cfg) -> None:
|
| 67 |
+
super().__init__()
|
| 68 |
+
self.cfg = cfg
|
| 69 |
+
S, R, T_v, d = cfg.S, cfg.R, cfg.T_v, cfg.d
|
| 70 |
+
if d % cfg.n_heads != 0:
|
| 71 |
+
raise ValueError("d must be divisible by n_heads")
|
| 72 |
+
|
| 73 |
+
self.E_N = nn.Parameter(torch.randn(S, d) * 0.02)
|
| 74 |
+
self.cap_proj = nn.Linear(cfg.caption_dim, d)
|
| 75 |
+
self.blocks = nn.ModuleList(
|
| 76 |
+
[CrossAttnBlock(d, cfg.n_heads) for _ in range(4)]
|
| 77 |
+
)
|
| 78 |
+
self.ln_out = nn.LayerNorm(d)
|
| 79 |
+
|
| 80 |
+
# Heads.
|
| 81 |
+
self.W_u = nn.Linear(d, R)
|
| 82 |
+
self.mlp_h = nn.Sequential(nn.Linear(d, cfg.d_t), nn.GELU(), nn.Linear(cfg.d_t, cfg.d_t))
|
| 83 |
+
self.mlp_g = nn.Sequential(nn.Linear(cfg.n_geom, cfg.d_t), nn.GELU(), nn.Linear(cfg.d_t, cfg.d_t))
|
| 84 |
+
self.term_bias = nn.Parameter(torch.zeros(S))
|
| 85 |
+
|
| 86 |
+
# Cut-type head: component embeddings cross-attend once to the caption.
|
| 87 |
+
self.e_k = nn.Parameter(torch.randn(R, d) * 0.02)
|
| 88 |
+
self.cut_block = CrossAttnBlock(d, cfg.n_heads)
|
| 89 |
+
self.cut_ln = nn.LayerNorm(d)
|
| 90 |
+
self.cut_out = nn.Linear(d, N_CUT_TYPES)
|
| 91 |
+
|
| 92 |
+
# Static grammar tables (caption-independent).
|
| 93 |
+
self.P_T = nn.Parameter(torch.randn(R, T_v) * 0.02)
|
| 94 |
+
self.V = nn.Parameter(torch.randn(R, S) * 0.02)
|
| 95 |
+
self.W = nn.Parameter(torch.randn(R, S) * 0.02)
|
| 96 |
+
|
| 97 |
+
# Illumination field: masked-mean-pooled caption -> MLP -> 2x deconvs.
|
| 98 |
+
self.phi_mlp = nn.Sequential(nn.Linear(d, 128), nn.GELU(), nn.Linear(128, 32 * 4 * 4))
|
| 99 |
+
self.phi_deconv1 = nn.ConvTranspose2d(32, 16, kernel_size=4, stride=2, padding=1)
|
| 100 |
+
self.phi_deconv2 = nn.ConvTranspose2d(16, 8, kernel_size=4, stride=2, padding=1)
|
| 101 |
+
# Zero-init the final deconv so Phi == 0 at init (FiLM starts at identity).
|
| 102 |
+
nn.init.zeros_(self.phi_deconv2.weight)
|
| 103 |
+
nn.init.zeros_(self.phi_deconv2.bias)
|
| 104 |
+
|
| 105 |
+
def forward(self, emb: torch.Tensor, emb_len: torch.Tensor) -> GMTOut:
|
| 106 |
+
"""emb [B, L, 768] (any float dtype), emb_len [B] int -> GMTOut."""
|
| 107 |
+
B, L, _ = emb.shape
|
| 108 |
+
kv = self.cap_proj(emb.float()) # [B, L, d]
|
| 109 |
+
kpm = caption_padding_mask(emb_len, L, emb.device)
|
| 110 |
+
|
| 111 |
+
h = self.E_N.unsqueeze(0).expand(B, -1, -1)
|
| 112 |
+
for blk in self.blocks:
|
| 113 |
+
h = blk(h, kv, kpm)
|
| 114 |
+
H = self.ln_out(h) # [B, S, d]
|
| 115 |
+
|
| 116 |
+
U = self.W_u(H) # [B, S, R]
|
| 117 |
+
|
| 118 |
+
q_k = self.e_k.unsqueeze(0).expand(B, -1, -1)
|
| 119 |
+
hk = self.cut_block(q_k, kv, kpm)
|
| 120 |
+
cut_logits = self.cut_out(self.cut_ln(hk)) # [B, R, 14]
|
| 121 |
+
|
| 122 |
+
keep = (~kpm).float().unsqueeze(-1) # [B, L, 1]
|
| 123 |
+
pooled = (kv * keep).sum(1) / keep.sum(1).clamp(min=1.0)
|
| 124 |
+
z = self.phi_mlp(pooled).reshape(B, 32, 4, 4)
|
| 125 |
+
z = F.gelu(self.phi_deconv1(z))
|
| 126 |
+
Phi = self.phi_deconv2(z) # [B, 8, 16, 16]
|
| 127 |
+
|
| 128 |
+
return GMTOut(H=H, U=U, cut_logits=cut_logits, Phi=Phi)
|
| 129 |
+
|
| 130 |
+
def termination_logits(self, H: torch.Tensor, phi_geom: torch.Tensor) -> torch.Tensor:
|
| 131 |
+
"""Factorized termination head:
|
| 132 |
+
term_logit[b, r, A] = MLP_h(H)[b, A, :] . MLP_g(phi_geom)[r, :] + bias_A.
|
| 133 |
+
|
| 134 |
+
H [B, S, d], phi_geom [N_reg, n_geom] -> logits [B, N_reg, S] fp32.
|
| 135 |
+
"""
|
| 136 |
+
hs = self.mlp_h(H) # [B, S, d_t]
|
| 137 |
+
gs = self.mlp_g(phi_geom.float()) # [N_reg, d_t]
|
| 138 |
+
term = torch.einsum("bsd,nd->bns", hs.float(), gs)
|
| 139 |
+
return term + self.term_bias.view(1, 1, -1)
|
sprig/model/sprig.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SPRIGModel — wires GMT + TexelAtlas + inside DP (contracts C2/C3/C4).
|
| 2 |
+
|
| 3 |
+
DP access: all calls to sprig/dp/inside.py go through the small adapters at the
|
| 4 |
+
bottom of this file (`_dp`, `_call_dp`), keyed by the DESIGN.md section 5
|
| 5 |
+
argument names (ell_leaf, term_logits, cut_logits, U_logmix, logV, logW,
|
| 6 |
+
lattice, temper_kappa; plus log_PT for the texel prior). Tests may override the
|
| 7 |
+
DP module via `_DP_MODULE`.
|
| 8 |
+
|
| 9 |
+
Conventions:
|
| 10 |
+
- Axiom (root) nonterminal is symbol 0.
|
| 11 |
+
- Rule-logit temperature `tau` (buffer) divides all rule/termination logits
|
| 12 |
+
(U, cut-type, V, W, P_T, termination) per DESIGN.md section 2.
|
| 13 |
+
- Emission tempering `eta` (buffer): kappa(r) = max(1, area_px(r)^eta);
|
| 14 |
+
`log_marginal(..., report_mode=True)` forces eta = 0 (C2).
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import inspect
|
| 19 |
+
import math
|
| 20 |
+
from dataclasses import dataclass, field
|
| 21 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 22 |
+
|
| 23 |
+
import torch
|
| 24 |
+
import torch.nn as nn
|
| 25 |
+
import torch.nn.functional as F
|
| 26 |
+
|
| 27 |
+
from sprig.dp.lattice import Lattice, get_lattice
|
| 28 |
+
from sprig.model import dl
|
| 29 |
+
from sprig.model.atlas import ATLAS_RES, TexelAtlas, film_scale_shift, phi_at_leaf_centers
|
| 30 |
+
from sprig.model.gmt import GrammarModulationTransformer
|
| 31 |
+
|
| 32 |
+
_MATERIAL_SEED_OFFSET = 0x9E3779B9
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class SPRIGConfig:
|
| 37 |
+
S: int = 1024
|
| 38 |
+
R: int = 64
|
| 39 |
+
T_v: int = 256
|
| 40 |
+
d: int = 384
|
| 41 |
+
canvas: int = 64
|
| 42 |
+
grid: int = 8
|
| 43 |
+
leaf_max: int = 16
|
| 44 |
+
n_heads: int = 6
|
| 45 |
+
d_t: int = 64
|
| 46 |
+
caption_dim: int = 768
|
| 47 |
+
n_geom: int = 64
|
| 48 |
+
atlas_heads: int = 4
|
| 49 |
+
leaf_chunk: int = 16
|
| 50 |
+
axiom: int = 0
|
| 51 |
+
texel_hinge_weight: float = 1.0
|
| 52 |
+
symbol_hinge_weight: float = 0.5
|
| 53 |
+
# Object-pixel importance weight on the emission log-likelihood during
|
| 54 |
+
# training (diagnosis fix 1: objects are <10% of pixels, so unweighted NLL
|
| 55 |
+
# lets background fidelity dominate and no object texels ever form).
|
| 56 |
+
# Requires the dataloader to provide batch["objmask"] u8 [B,C,C].
|
| 57 |
+
# 1.0 = exact NLL (eval/log_marginal always use exact NLL regardless).
|
| 58 |
+
emission_obj_weight: float = 1.0
|
| 59 |
+
# Second-order grad through the DP so the under-use hinges actually train
|
| 60 |
+
# (usage comes from autograd.grad of logZ; without create_graph the hinge
|
| 61 |
+
# would be constant w.r.t. parameters).
|
| 62 |
+
hinge_create_graph: bool = True
|
| 63 |
+
resurrect_threshold_frac: float = 0.1 # of uniform usage 1/T_v
|
| 64 |
+
|
| 65 |
+
def __post_init__(self) -> None:
|
| 66 |
+
if self.d % self.n_heads != 0:
|
| 67 |
+
raise ValueError("d must be divisible by n_heads")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class ParseNode:
|
| 72 |
+
rect: Tuple[int, int, int, int]
|
| 73 |
+
axis: Optional[int] # None for leaves; 0 = vertical cut, 1 = horizontal
|
| 74 |
+
cut_px: Optional[int]
|
| 75 |
+
symbol: int
|
| 76 |
+
texel: Optional[int] # None for internal nodes
|
| 77 |
+
children: List["ParseNode"] = field(default_factory=list)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class SPRIGModel(nn.Module):
|
| 81 |
+
def __init__(self, cfg: SPRIGConfig) -> None:
|
| 82 |
+
super().__init__()
|
| 83 |
+
self.cfg = cfg
|
| 84 |
+
self.gmt = GrammarModulationTransformer(cfg)
|
| 85 |
+
self.atlas = TexelAtlas(cfg)
|
| 86 |
+
self.lattice: Lattice = get_lattice(cfg.canvas, cfg.grid, cfg.leaf_max)
|
| 87 |
+
self.register_buffer("tau", torch.tensor(1.0))
|
| 88 |
+
self.register_buffer("eta", torch.tensor(0.0))
|
| 89 |
+
|
| 90 |
+
# ------------------------------------------------------------------ utils
|
| 91 |
+
|
| 92 |
+
def _lat(self, device: torch.device) -> Lattice:
|
| 93 |
+
self.lattice.to(device)
|
| 94 |
+
return self.lattice
|
| 95 |
+
|
| 96 |
+
def _kappa(self, eta: float, device: torch.device) -> torch.Tensor:
|
| 97 |
+
"""kappa(r) = max(1, area_px(r)^eta) over leaf slots -> fp32 [n_leaf]."""
|
| 98 |
+
lat = self._lat(device)
|
| 99 |
+
area = lat.area_px[lat.leaf_ids].float()
|
| 100 |
+
if eta <= 0.0:
|
| 101 |
+
return torch.ones_like(area)
|
| 102 |
+
return torch.clamp(area ** eta, min=1.0)
|
| 103 |
+
|
| 104 |
+
def _conditionals(
|
| 105 |
+
self,
|
| 106 |
+
emb: torch.Tensor,
|
| 107 |
+
emb_len: torch.Tensor,
|
| 108 |
+
images: Optional[torch.Tensor] = None,
|
| 109 |
+
pix_weight: Optional[torch.Tensor] = None,
|
| 110 |
+
) -> Dict[str, torch.Tensor]:
|
| 111 |
+
"""All caption-conditioned quantities, temperature already applied."""
|
| 112 |
+
lat = self._lat(emb.device)
|
| 113 |
+
out = self.gmt(emb, emb_len)
|
| 114 |
+
tau = self.tau.clamp(min=1e-3)
|
| 115 |
+
cond: Dict[str, torch.Tensor] = {}
|
| 116 |
+
cond["H"] = out.H
|
| 117 |
+
cond["Phi"] = out.Phi
|
| 118 |
+
cond["U_logmix"] = F.log_softmax(out.U / tau, dim=-1) # [B,S,R]
|
| 119 |
+
cond["log_PT"] = F.log_softmax(self.gmt.P_T / tau, dim=-1) # [R,T_v]
|
| 120 |
+
cond["logV"] = F.log_softmax(self.gmt.V / tau, dim=-1) # [R,S]
|
| 121 |
+
cond["logW"] = F.log_softmax(self.gmt.W / tau, dim=-1) # [R,S]
|
| 122 |
+
cond["term_logits"] = self.gmt.termination_logits(out.H, lat.phi_geom) / tau
|
| 123 |
+
cond["cut_logits"] = out.cut_logits / tau # [B,R,14]
|
| 124 |
+
cond["atlas"] = self.atlas.render(emb, emb_len)
|
| 125 |
+
if images is not None:
|
| 126 |
+
cond["ell"] = self.atlas.score_leaves(
|
| 127 |
+
cond["atlas"], images, lat, out.Phi, pix_weight=pix_weight)
|
| 128 |
+
return cond
|
| 129 |
+
|
| 130 |
+
def _texel_prior_log(self, cond: Dict[str, torch.Tensor]) -> torch.Tensor:
|
| 131 |
+
"""log p(T|A,c) = logsumexp_k(log p(k|A,c) + log p(T|k)) -> [B,S,T_v]."""
|
| 132 |
+
return torch.logsumexp(
|
| 133 |
+
cond["U_logmix"].unsqueeze(-1) + cond["log_PT"].unsqueeze(0).unsqueeze(0),
|
| 134 |
+
dim=2,
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
def _dp_kwargs(
|
| 138 |
+
self, cond: Dict[str, torch.Tensor], kappa: torch.Tensor
|
| 139 |
+
) -> Dict[str, Any]:
|
| 140 |
+
return dict(
|
| 141 |
+
ell_leaf=cond["ell"],
|
| 142 |
+
term_logits=cond["term_logits"],
|
| 143 |
+
cut_logits=cond["cut_logits"],
|
| 144 |
+
U_logmix=cond["U_logmix"],
|
| 145 |
+
logV=cond["logV"],
|
| 146 |
+
logW=cond["logW"],
|
| 147 |
+
lattice=self.lattice,
|
| 148 |
+
temper_kappa=kappa,
|
| 149 |
+
log_PT=cond["log_PT"],
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
def _inside(self, cond: Dict[str, torch.Tensor], kappa: torch.Tensor) -> torch.Tensor:
|
| 153 |
+
mod = _dp()
|
| 154 |
+
fn = getattr(mod, "inside_logZ", None) or getattr(mod, "inside")
|
| 155 |
+
res = _call_dp(fn, self._dp_kwargs(cond, kappa))
|
| 156 |
+
if isinstance(res, (tuple, list)):
|
| 157 |
+
return res[-1] # (beta, logZ) per DESIGN section 5
|
| 158 |
+
return res
|
| 159 |
+
|
| 160 |
+
# -------------------------------------------------------------- contracts
|
| 161 |
+
|
| 162 |
+
def log_marginal(
|
| 163 |
+
self,
|
| 164 |
+
image: torch.Tensor,
|
| 165 |
+
emb: torch.Tensor,
|
| 166 |
+
emb_len: torch.Tensor,
|
| 167 |
+
report_mode: bool = False,
|
| 168 |
+
) -> torch.Tensor:
|
| 169 |
+
"""C2: exact log p(x|c) [B]; report_mode forces eta = 0."""
|
| 170 |
+
eta = 0.0 if report_mode else float(self.eta)
|
| 171 |
+
cond = self._conditionals(emb, emb_len, images=image)
|
| 172 |
+
kappa = self._kappa(eta, emb.device)
|
| 173 |
+
return self._inside(cond, kappa)
|
| 174 |
+
|
| 175 |
+
def loss(self, batch: Dict[str, torch.Tensor]) -> Tuple[torch.Tensor, Dict[str, float]]:
|
| 176 |
+
"""DESIGN section 6: tempered NLL (nats/subpixel) + under-use hinges.
|
| 177 |
+
|
| 178 |
+
Usage vectors are posterior expected counts obtained from
|
| 179 |
+
torch.autograd.grad(logZ.sum(), [ell, U_logmix], retain_graph=True):
|
| 180 |
+
grad wrt ell (x kappa) = expected (leaf, texel) counts; grad wrt
|
| 181 |
+
U_logmix summed over k = expected node counts per symbol (the texel
|
| 182 |
+
prior is mixed from U_logmix, so termination counts are included).
|
| 183 |
+
"""
|
| 184 |
+
images = batch["image"]
|
| 185 |
+
emb = batch["emb"]
|
| 186 |
+
emb_len = batch["emb_len"]
|
| 187 |
+
cfg = self.cfg
|
| 188 |
+
pix_weight = None
|
| 189 |
+
if cfg.emission_obj_weight != 1.0 and batch.get("objmask") is not None:
|
| 190 |
+
pix_weight = 1.0 + (cfg.emission_obj_weight - 1.0) * batch[
|
| 191 |
+
"objmask"].to(images.device, torch.float32)
|
| 192 |
+
cond = self._conditionals(emb, emb_len, images=images,
|
| 193 |
+
pix_weight=pix_weight)
|
| 194 |
+
kappa = self._kappa(float(self.eta), emb.device)
|
| 195 |
+
logZ = self._inside(cond, kappa)
|
| 196 |
+
|
| 197 |
+
n_subpix = 3 * cfg.canvas * cfg.canvas
|
| 198 |
+
nll = (-logZ / n_subpix).mean()
|
| 199 |
+
|
| 200 |
+
create = cfg.hinge_create_graph and torch.is_grad_enabled()
|
| 201 |
+
g_ell, g_u = torch.autograd.grad(
|
| 202 |
+
logZ.sum(),
|
| 203 |
+
[cond["ell"], cond["U_logmix"]],
|
| 204 |
+
retain_graph=True,
|
| 205 |
+
create_graph=create,
|
| 206 |
+
)
|
| 207 |
+
texel_counts = (g_ell * kappa.view(1, -1, 1)).sum(dim=(0, 1)) # [T_v]
|
| 208 |
+
symbol_counts = g_u.sum(dim=(0, 2)) # [S]
|
| 209 |
+
texel_usage = texel_counts / texel_counts.sum().clamp(min=1e-12)
|
| 210 |
+
symbol_usage = symbol_counts / symbol_counts.sum().clamp(min=1e-12)
|
| 211 |
+
|
| 212 |
+
texel_hinge = F.relu(1.0 / (4.0 * cfg.T_v) - texel_usage).sum()
|
| 213 |
+
symbol_hinge = F.relu(1.0 / (4.0 * cfg.S) - symbol_usage).sum()
|
| 214 |
+
total = (
|
| 215 |
+
nll
|
| 216 |
+
+ cfg.texel_hinge_weight * texel_hinge
|
| 217 |
+
+ cfg.symbol_hinge_weight * symbol_hinge
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
with torch.no_grad():
|
| 221 |
+
tu = texel_usage.detach()
|
| 222 |
+
su = symbol_usage.detach()
|
| 223 |
+
metrics = {
|
| 224 |
+
"loss": float(total),
|
| 225 |
+
"nll": float(nll),
|
| 226 |
+
"bpd": float(nll) / math.log(2.0),
|
| 227 |
+
"logZ_mean": float(logZ.mean()),
|
| 228 |
+
"texel_hinge": float(texel_hinge),
|
| 229 |
+
"symbol_hinge": float(symbol_hinge),
|
| 230 |
+
"texel_alive_frac": float((tu >= 1.0 / (4.0 * cfg.T_v)).float().mean()),
|
| 231 |
+
"symbol_eff": float(torch.exp(-(su.clamp(min=1e-12) * su.clamp(min=1e-12).log()).sum())),
|
| 232 |
+
"mean_leaves": float((g_ell.detach() * kappa.view(1, -1, 1)).sum() / images.shape[0]),
|
| 233 |
+
}
|
| 234 |
+
return total, metrics
|
| 235 |
+
|
| 236 |
+
def posterior_usage(
|
| 237 |
+
self, image: torch.Tensor, emb: torch.Tensor, emb_len: torch.Tensor
|
| 238 |
+
) -> Dict[str, Any]:
|
| 239 |
+
"""C3: expected-count diagnostics from posterior marginals.
|
| 240 |
+
|
| 241 |
+
node_entropy = occupancy-weighted entropy of the conditional
|
| 242 |
+
split posterior at each region (choices: terminate + each concrete
|
| 243 |
+
cut). mean_depth is the area-based proxy E[log2(canvas_area /
|
| 244 |
+
leaf_area)] over leaf marginals.
|
| 245 |
+
"""
|
| 246 |
+
cfg = self.cfg
|
| 247 |
+
with torch.enable_grad():
|
| 248 |
+
cond = self._conditionals(emb, emb_len, images=image)
|
| 249 |
+
kappa = self._kappa(float(self.eta), emb.device)
|
| 250 |
+
marg = _call_dp(_dp().posterior_marginals, self._dp_kwargs(cond, kappa))
|
| 251 |
+
|
| 252 |
+
lat = self._lat(emb.device)
|
| 253 |
+
node = marg["node"].float() # [B, N_reg, S]
|
| 254 |
+
term = marg["term"].float() # [B, N_reg, S]
|
| 255 |
+
cut = marg["cut"].float() # [B, M_total] concatenated level rows
|
| 256 |
+
texel = marg["texel"].float() # [B, n_leaf, T_v]
|
| 257 |
+
rule = marg["rule"].float() # [B, S, R]
|
| 258 |
+
|
| 259 |
+
symbol_counts = node.sum(dim=(0, 1))
|
| 260 |
+
texel_counts = texel.sum(dim=(0, 1))
|
| 261 |
+
symbol_usage = symbol_counts / symbol_counts.sum().clamp(min=1e-12)
|
| 262 |
+
texel_usage = texel_counts / texel_counts.sum().clamp(min=1e-12)
|
| 263 |
+
|
| 264 |
+
# Occupancy-weighted entropy of conditional split posteriors.
|
| 265 |
+
B = node.shape[0]
|
| 266 |
+
occ = node.sum(dim=-1) # [B, N_reg]
|
| 267 |
+
term_tot = term.sum(dim=-1) # [B, N_reg]
|
| 268 |
+
parent_of_row = torch.cat([lv.parent_ids for lv in lat.levels]).to(emb.device)
|
| 269 |
+
eps = 1e-12
|
| 270 |
+
occ_safe = occ.clamp(min=eps)
|
| 271 |
+
q_term = (term_tot / occ_safe).clamp(min=0.0)
|
| 272 |
+
plogp_term = torch.where(
|
| 273 |
+
q_term > eps, q_term * q_term.clamp(min=eps).log(), torch.zeros_like(q_term)
|
| 274 |
+
)
|
| 275 |
+
q_cut = cut / occ_safe[:, parent_of_row]
|
| 276 |
+
plogp_cut_rows = torch.where(
|
| 277 |
+
q_cut > eps, q_cut * q_cut.clamp(min=eps).log(), torch.zeros_like(q_cut)
|
| 278 |
+
)
|
| 279 |
+
plogp_cut = torch.zeros_like(occ).index_add_(
|
| 280 |
+
1, parent_of_row, plogp_cut_rows
|
| 281 |
+
)
|
| 282 |
+
ent_r = -(plogp_term + plogp_cut) # [B, N_reg]
|
| 283 |
+
w = occ.clamp(min=0.0)
|
| 284 |
+
node_entropy = float((w * ent_r).sum() / w.sum().clamp(min=eps))
|
| 285 |
+
|
| 286 |
+
# Magnitude monitors.
|
| 287 |
+
ell = cond["ell"].detach().float()
|
| 288 |
+
emit_mag = float(
|
| 289 |
+
((texel * ell).sum() / texel.sum().clamp(min=eps)).abs()
|
| 290 |
+
)
|
| 291 |
+
rule_mag = float(
|
| 292 |
+
((rule * cond["U_logmix"].detach()).sum() / rule.sum().clamp(min=eps)).abs()
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
leaf_area = lat.area_px[lat.leaf_ids].float()
|
| 296 |
+
depth_proxy = torch.log2(float(cfg.canvas * cfg.canvas) / leaf_area)
|
| 297 |
+
leaf_marg = texel.sum(dim=-1) # [B, n_leaf]
|
| 298 |
+
mean_depth = float(
|
| 299 |
+
(leaf_marg * depth_proxy.unsqueeze(0)).sum() / leaf_marg.sum().clamp(min=eps)
|
| 300 |
+
)
|
| 301 |
+
mean_leaves = float(leaf_marg.sum() / B)
|
| 302 |
+
|
| 303 |
+
return {
|
| 304 |
+
"symbol_usage": symbol_usage.detach(),
|
| 305 |
+
"texel_usage": texel_usage.detach(),
|
| 306 |
+
"node_entropy": node_entropy,
|
| 307 |
+
"emit_mag": emit_mag,
|
| 308 |
+
"rule_mag": rule_mag,
|
| 309 |
+
"mean_depth": mean_depth,
|
| 310 |
+
"mean_leaves": mean_leaves,
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
def map_parse(
|
| 314 |
+
self, image: torch.Tensor, emb: torch.Tensor, emb_len: torch.Tensor
|
| 315 |
+
) -> List[ParseNode]:
|
| 316 |
+
"""C3: Viterbi (max-semiring) parse per batch element."""
|
| 317 |
+
with torch.no_grad():
|
| 318 |
+
cond = self._conditionals(emb, emb_len, images=image)
|
| 319 |
+
kappa = self._kappa(float(self.eta), emb.device)
|
| 320 |
+
res = _call_dp(_dp().viterbi, self._dp_kwargs(cond, kappa))
|
| 321 |
+
_score, trees = res
|
| 322 |
+
return [self._to_parse_node(t) for t in trees]
|
| 323 |
+
|
| 324 |
+
def _to_parse_node(self, node: Tuple) -> ParseNode:
|
| 325 |
+
"""Convert the DP viterbi tree tuple (region_id, symbol, texel, axis,
|
| 326 |
+
cut_px, children) into a ParseNode."""
|
| 327 |
+
rid, sym, texel, axis, cut_px, children = node
|
| 328 |
+
rect = tuple(int(v) for v in self.lattice.regions[rid].tolist())
|
| 329 |
+
return ParseNode(
|
| 330 |
+
rect=rect,
|
| 331 |
+
axis=None if axis is None else int(axis),
|
| 332 |
+
cut_px=None if cut_px is None else int(cut_px),
|
| 333 |
+
symbol=int(sym),
|
| 334 |
+
texel=None if texel is None else int(texel),
|
| 335 |
+
children=[self._to_parse_node(ch) for ch in children],
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
# --------------------------------------------------------------- sampling
|
| 339 |
+
|
| 340 |
+
@torch.no_grad()
|
| 341 |
+
def sample(
|
| 342 |
+
self,
|
| 343 |
+
emb: torch.Tensor,
|
| 344 |
+
emb_len: torch.Tensor,
|
| 345 |
+
seed_struct: int,
|
| 346 |
+
seed_material: int,
|
| 347 |
+
n: int,
|
| 348 |
+
) -> Tuple[torch.Tensor, List[ParseNode]]:
|
| 349 |
+
"""C4: ancestral sampling, breadth-parallel over the frontier.
|
| 350 |
+
|
| 351 |
+
Two RNG streams: structural draws (termination, k, cut, B, C) from
|
| 352 |
+
seed_struct; material draws (texel choice) from seed_material. Pixels
|
| 353 |
+
are rendered as DL means (no pixel noise). Returns
|
| 354 |
+
(images u8 [n, canvas, canvas, 3] on CPU, list of n ParseNode roots).
|
| 355 |
+
"""
|
| 356 |
+
images, trees, _scores = self._sample_scored(emb, emb_len, seed_struct, seed_material, n)
|
| 357 |
+
return images, trees
|
| 358 |
+
|
| 359 |
+
@torch.no_grad()
|
| 360 |
+
def sample_bestof(
|
| 361 |
+
self, emb: torch.Tensor, emb_len: torch.Tensor, K: int, seed: int
|
| 362 |
+
) -> Tuple[torch.Tensor, List[ParseNode]]:
|
| 363 |
+
"""C4: sample K derivations, rerank by joint log p(tree, rendered
|
| 364 |
+
image | c), return the best as (images u8 [1, canvas, canvas, 3], [tree])."""
|
| 365 |
+
images, trees, scores = self._sample_scored(
|
| 366 |
+
emb, emb_len, int(seed), int(seed) + _MATERIAL_SEED_OFFSET, K
|
| 367 |
+
)
|
| 368 |
+
best = int(torch.tensor(scores).argmax())
|
| 369 |
+
return images[best : best + 1], [trees[best]]
|
| 370 |
+
|
| 371 |
+
@torch.no_grad()
|
| 372 |
+
def _sample_scored(
|
| 373 |
+
self,
|
| 374 |
+
emb: torch.Tensor,
|
| 375 |
+
emb_len: torch.Tensor,
|
| 376 |
+
seed_struct: int,
|
| 377 |
+
seed_material: int,
|
| 378 |
+
n: int,
|
| 379 |
+
) -> Tuple[torch.Tensor, List[ParseNode], List[float]]:
|
| 380 |
+
cfg = self.cfg
|
| 381 |
+
device = next(self.parameters()).device
|
| 382 |
+
if emb.dim() == 2:
|
| 383 |
+
emb = emb.unsqueeze(0)
|
| 384 |
+
if not torch.is_tensor(emb_len):
|
| 385 |
+
emb_len = torch.tensor([emb_len])
|
| 386 |
+
emb_len = emb_len.reshape(-1)[:1]
|
| 387 |
+
emb = emb[:1].to(device)
|
| 388 |
+
|
| 389 |
+
cond = self._conditionals(emb, emb_len)
|
| 390 |
+
lat = self._lat(device)
|
| 391 |
+
|
| 392 |
+
# Single-caption conditionals on CPU for generator-driven draws.
|
| 393 |
+
u_logmix = cond["U_logmix"][0].float().cpu() # [S,R]
|
| 394 |
+
term_logits = cond["term_logits"][0].float().cpu() # [N,S]
|
| 395 |
+
cut_logits = cond["cut_logits"][0].float().cpu() # [R,14]
|
| 396 |
+
logv = cond["logV"].float().cpu() # [R,S]
|
| 397 |
+
logw = cond["logW"].float().cpu() # [R,S]
|
| 398 |
+
texel_prior = self._texel_prior_log(cond)[0].float().cpu() # [S,T_v]
|
| 399 |
+
atlas0 = cond["atlas"][0].float().cpu() # [T_v,40,16,16]
|
| 400 |
+
phi0 = cond["Phi"][:1].float().cpu() # [1,8,16,16]
|
| 401 |
+
|
| 402 |
+
pooled_cache: Dict[Tuple[int, int], torch.Tensor] = {}
|
| 403 |
+
for (h, w), _slots in lat.leaf_shape_groups().items():
|
| 404 |
+
pooled_cache[(h, w)] = F.adaptive_avg_pool2d(atlas0, (h, w))
|
| 405 |
+
|
| 406 |
+
gen_s = torch.Generator().manual_seed(int(seed_struct) & 0x7FFFFFFFFFFFFFFF)
|
| 407 |
+
gen_m = torch.Generator().manual_seed(int(seed_material) & 0x7FFFFFFFFFFFFFFF)
|
| 408 |
+
|
| 409 |
+
regions_cpu = lat.regions.cpu()
|
| 410 |
+
leaf_mask = lat.leaf_mask.cpu()
|
| 411 |
+
must_term = lat.must_terminate.cpu()
|
| 412 |
+
type_present = lat.type_present.cpu()
|
| 413 |
+
|
| 414 |
+
images_out: List[torch.Tensor] = []
|
| 415 |
+
trees: List[ParseNode] = []
|
| 416 |
+
scores: List[float] = []
|
| 417 |
+
for _ in range(n):
|
| 418 |
+
root = ParseNode(
|
| 419 |
+
rect=tuple(int(v) for v in regions_cpu[lat.root_id].tolist()),
|
| 420 |
+
axis=None, cut_px=None, symbol=cfg.axiom, texel=None,
|
| 421 |
+
)
|
| 422 |
+
frontier: List[Tuple[int, int, ParseNode]] = [(lat.root_id, cfg.axiom, root)]
|
| 423 |
+
leaves: List[Tuple[Tuple[int, int, int, int], int]] = []
|
| 424 |
+
logp = 0.0
|
| 425 |
+
while frontier:
|
| 426 |
+
nxt: List[Tuple[int, int, ParseNode]] = []
|
| 427 |
+
for rid, sym, pn in frontier:
|
| 428 |
+
can_term = bool(leaf_mask[rid])
|
| 429 |
+
forced_term = bool(must_term[rid])
|
| 430 |
+
if forced_term:
|
| 431 |
+
terminate = True
|
| 432 |
+
elif not can_term:
|
| 433 |
+
terminate = False
|
| 434 |
+
else:
|
| 435 |
+
p_term = torch.sigmoid(term_logits[rid, sym])
|
| 436 |
+
u = torch.rand((), generator=gen_s)
|
| 437 |
+
terminate = bool(u < p_term)
|
| 438 |
+
logp += float(torch.log(p_term if terminate else 1.0 - p_term))
|
| 439 |
+
if terminate:
|
| 440 |
+
probs = torch.exp(texel_prior[sym])
|
| 441 |
+
t = int(torch.multinomial(probs, 1, generator=gen_m))
|
| 442 |
+
logp += float(texel_prior[sym, t])
|
| 443 |
+
pn.texel = t
|
| 444 |
+
leaves.append((pn.rect, t))
|
| 445 |
+
else:
|
| 446 |
+
k = int(torch.multinomial(torch.exp(u_logmix[sym]), 1, generator=gen_s))
|
| 447 |
+
logp += float(u_logmix[sym, k])
|
| 448 |
+
# Concrete cut: masked cut-type softmax, mass split
|
| 449 |
+
# uniformly across same-type concrete cuts.
|
| 450 |
+
cuts = lat.cuts_of_region[rid]
|
| 451 |
+
tl = cut_logits[k].masked_fill(~type_present[rid], float("-inf"))
|
| 452 |
+
tls = F.log_softmax(tl, dim=-1)
|
| 453 |
+
row_logp = torch.tensor(
|
| 454 |
+
[float(tls[c[4]]) - c[5] for c in cuts]
|
| 455 |
+
)
|
| 456 |
+
ci = int(torch.multinomial(torch.exp(row_logp), 1, generator=gen_s))
|
| 457 |
+
logp += float(row_logp[ci])
|
| 458 |
+
axis, px, lo, hi, _t, _lc = cuts[ci]
|
| 459 |
+
b_sym = int(torch.multinomial(torch.exp(logv[k]), 1, generator=gen_s))
|
| 460 |
+
c_sym = int(torch.multinomial(torch.exp(logw[k]), 1, generator=gen_s))
|
| 461 |
+
logp += float(logv[k, b_sym]) + float(logw[k, c_sym])
|
| 462 |
+
pn.axis, pn.cut_px = int(axis), int(px)
|
| 463 |
+
ch_lo = ParseNode(
|
| 464 |
+
rect=tuple(int(v) for v in regions_cpu[lo].tolist()),
|
| 465 |
+
axis=None, cut_px=None, symbol=b_sym, texel=None,
|
| 466 |
+
)
|
| 467 |
+
ch_hi = ParseNode(
|
| 468 |
+
rect=tuple(int(v) for v in regions_cpu[hi].tolist()),
|
| 469 |
+
axis=None, cut_px=None, symbol=c_sym, texel=None,
|
| 470 |
+
)
|
| 471 |
+
pn.children = [ch_lo, ch_hi]
|
| 472 |
+
nxt.append((lo, b_sym, ch_lo))
|
| 473 |
+
nxt.append((hi, c_sym, ch_hi))
|
| 474 |
+
frontier = nxt
|
| 475 |
+
|
| 476 |
+
img, emit_ll = self._render_leaves(leaves, pooled_cache, phi0)
|
| 477 |
+
images_out.append(img)
|
| 478 |
+
trees.append(root)
|
| 479 |
+
scores.append(logp + emit_ll)
|
| 480 |
+
|
| 481 |
+
return torch.stack(images_out, dim=0), trees, scores
|
| 482 |
+
|
| 483 |
+
def _render_leaves(
|
| 484 |
+
self,
|
| 485 |
+
leaves: List[Tuple[Tuple[int, int, int, int], int]],
|
| 486 |
+
pooled_cache: Dict[Tuple[int, int], torch.Tensor],
|
| 487 |
+
phi0: torch.Tensor,
|
| 488 |
+
) -> Tuple[torch.Tensor, float]:
|
| 489 |
+
"""Paint DL-mean pixels for each leaf; also return the total emission
|
| 490 |
+
log-likelihood of the rendered (quantized) pixels — the emission part
|
| 491 |
+
of the joint score used by sample_bestof."""
|
| 492 |
+
cfg = self.cfg
|
| 493 |
+
canvas = torch.zeros(3, cfg.canvas, cfg.canvas)
|
| 494 |
+
emit_ll = 0.0
|
| 495 |
+
for (x0, y0, x1, y1), texel in leaves:
|
| 496 |
+
h, w = y1 - y0, x1 - x0
|
| 497 |
+
p = pooled_cache[(h, w)][texel].clone() # [40,h,w]
|
| 498 |
+
rect_t = torch.tensor([[x0, y0, x1, y1]])
|
| 499 |
+
phi = phi_at_leaf_centers(phi0, rect_t, cfg.canvas)[0, 0] # [8]
|
| 500 |
+
scale, shift = film_scale_shift(phi)
|
| 501 |
+
for c in range(3):
|
| 502 |
+
idx = [10 * j + 1 + c for j in range(dl.N_COMP)]
|
| 503 |
+
p[idx] = p[idx] * scale[c] + shift[c]
|
| 504 |
+
pix = dl.dl_mean_pixels(p.unsqueeze(0))[0] # [3,h,w]
|
| 505 |
+
pix_u8 = dl.unit_to_u8(pix)
|
| 506 |
+
canvas[:, y0:y1, x0:x1] = dl.u8_to_unit(pix_u8)
|
| 507 |
+
emit_ll += float(
|
| 508 |
+
dl.dl_logprob(p.unsqueeze(0), dl.u8_to_unit(pix_u8).unsqueeze(0)).sum()
|
| 509 |
+
)
|
| 510 |
+
img_u8 = dl.unit_to_u8(canvas).permute(1, 2, 0).contiguous() # [C,C,3] u8
|
| 511 |
+
return img_u8, emit_ll
|
| 512 |
+
|
| 513 |
+
# ----------------------------------------------------------- resurrection
|
| 514 |
+
|
| 515 |
+
@torch.no_grad()
|
| 516 |
+
def resurrect_texels(
|
| 517 |
+
self,
|
| 518 |
+
usage: torch.Tensor,
|
| 519 |
+
images: torch.Tensor,
|
| 520 |
+
generator: Optional[torch.Generator] = None,
|
| 521 |
+
threshold: Optional[float] = None,
|
| 522 |
+
noise_std: float = 0.01,
|
| 523 |
+
obj_mask: Optional[torch.Tensor] = None,
|
| 524 |
+
) -> int:
|
| 525 |
+
"""F3.3: overwrite the bias grid of under-used texels with a
|
| 526 |
+
training-image 16x16 crop converted to DL-mean params (small noise on
|
| 527 |
+
the other channels) and perturb the E_T row. Returns #resurrected.
|
| 528 |
+
|
| 529 |
+
usage: [T_v] normalized posterior usage; images: u8 [B, canvas, canvas, 3].
|
| 530 |
+
obj_mask (optional) [B, canvas, canvas] bool/u8: when given, crops are
|
| 531 |
+
centered on object pixels (diagnosis fix 3 — random crops are ~90%
|
| 532 |
+
background, so resurrection used to reseed dead texels with yet more
|
| 533 |
+
background material).
|
| 534 |
+
"""
|
| 535 |
+
cfg = self.cfg
|
| 536 |
+
thr = threshold if threshold is not None else cfg.resurrect_threshold_frac / cfg.T_v
|
| 537 |
+
dead = torch.nonzero(usage.cpu() < thr, as_tuple=False).reshape(-1)
|
| 538 |
+
if dead.numel() == 0:
|
| 539 |
+
return 0
|
| 540 |
+
gen = generator
|
| 541 |
+
B = images.shape[0]
|
| 542 |
+
hi_y = cfg.canvas - ATLAS_RES
|
| 543 |
+
dev = self.atlas.bias_grid.device
|
| 544 |
+
obj_pix: List[Tuple[int, torch.Tensor]] = []
|
| 545 |
+
if obj_mask is not None:
|
| 546 |
+
m = obj_mask.detach().cpu()
|
| 547 |
+
for b in range(B):
|
| 548 |
+
nz = torch.nonzero(m[b] > 0, as_tuple=False) # [k, 2] (y, x)
|
| 549 |
+
if nz.numel():
|
| 550 |
+
obj_pix.append((b, nz))
|
| 551 |
+
for t in dead.tolist():
|
| 552 |
+
if obj_pix:
|
| 553 |
+
b, nz = obj_pix[int(torch.randint(0, len(obj_pix), (1,), generator=gen))]
|
| 554 |
+
cy, cx = nz[int(torch.randint(0, nz.shape[0], (1,), generator=gen))].tolist()
|
| 555 |
+
y0 = min(max(cy - ATLAS_RES // 2, 0), hi_y)
|
| 556 |
+
x0 = min(max(cx - ATLAS_RES // 2, 0), hi_y)
|
| 557 |
+
else:
|
| 558 |
+
b = int(torch.randint(0, B, (1,), generator=gen))
|
| 559 |
+
y0 = int(torch.randint(0, hi_y + 1, (1,), generator=gen))
|
| 560 |
+
x0 = int(torch.randint(0, hi_y + 1, (1,), generator=gen))
|
| 561 |
+
crop = images[b, y0 : y0 + ATLAS_RES, x0 : x0 + ATLAS_RES].cpu()
|
| 562 |
+
crop_unit = dl.u8_to_unit(crop).permute(2, 0, 1) # [3,16,16]
|
| 563 |
+
bias = noise_std * torch.randn(dl.N_CH, ATLAS_RES, ATLAS_RES, generator=gen)
|
| 564 |
+
for j in range(dl.N_COMP):
|
| 565 |
+
bias[10 * j + 1 : 10 * j + 4] = crop_unit
|
| 566 |
+
self.atlas.bias_grid.data[t] = bias.to(dev)
|
| 567 |
+
self.atlas.E_T.data[t] += noise_std * torch.randn(
|
| 568 |
+
cfg.d, generator=gen
|
| 569 |
+
).to(self.atlas.E_T.device)
|
| 570 |
+
return int(dead.numel())
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
# ------------------------------------------------------------------ DP access
|
| 574 |
+
|
| 575 |
+
_DP_MODULE: Optional[Any] = None # test hook: module-like override
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
def _dp() -> Any:
|
| 579 |
+
if _DP_MODULE is not None:
|
| 580 |
+
return _DP_MODULE
|
| 581 |
+
from sprig.dp import inside
|
| 582 |
+
return inside
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
def _call_dp(fn: Any, kwargs: Dict[str, Any]) -> Any:
|
| 586 |
+
"""Call a DP function by DESIGN section 5 keyword names, dropping any
|
| 587 |
+
kwargs the target does not accept (e.g. log_PT if the DP mixes the texel
|
| 588 |
+
prior itself)."""
|
| 589 |
+
sig = inspect.signature(fn)
|
| 590 |
+
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
| 591 |
+
return fn(**kwargs)
|
| 592 |
+
accepted = {k: v for k, v in kwargs.items() if k in sig.parameters}
|
| 593 |
+
return fn(**accepted)
|
texel_atlas.png
ADDED
|
Git LFS Details
|