# sprite-gen — Implemented Architecture
> Status: reference (describes the code as it actually is, v1.56.x, 2026-07-11).
> Canonical behavior contract lives in [`../SKILL.md`](../SKILL.md); this doc
> explains *how* the scripts realize that contract. If this doc and `SKILL.md`
> ever disagree, `SKILL.md` wins and this doc is the bug.
## 1. One sentence
A character image plus a numeric request becomes a transparent sprite atlas by
generating **one horizontal strip per animation state**, cleaning chroma,
extracting poses as connected components, refitting each pose into a fixed cell
(optionally through the deterministic pixel-unfake path), and packing the
cells into a single sheet described by a runtime manifest.
```mermaid
flowchart TD
REQ["sprite-request.json
(numeric SSoT)"] --> PREP["prepare_sprite_run.py"]
PREP --> GUIDES["references/layout-guides/<state>.png
prompts/<state>.txt"]
GUIDES --> GEN["sprite-gen gen
(codex or grok; one image per state — the only AI step)"]
GEN --> RAW["raw/<state>.png
(one horizontal strip per state)"]
RAW --> EXTRACT["extract_sprite_row_frames.py
chroma removal → connected components
→ fit_to_cell or pixel-unfake path"]
EXTRACT --> FRAMES["frames/<state>/frame-N.png (+ frame-N.plain.png twin)
frames/frames-manifest.json"]
FRAMES --> CURATE["serve_curation.py (optional webview)
curation.json (non-destructive sidecar)"]
FRAMES --> COMPOSE["compose_sprite_atlas.py"]
CURATE -. "selected / transforms / pixel_unfake" .-> COMPOSE
COMPOSE --> ATLAS["sprite-sheet-alpha.png + manifest.json
(frame_layout = runtime SSoT)"]
FRAMES --> QA["preview_animation.py
qa/<state>-contact.png · qa/<state>.gif"]
```
## 2. Stage ownership (each script does one job)
The canonical stage → script → input → output table is owned by
[`run-contract.md`](run-contract.md) §1. This section explains the shared-import
and locking behavior *behind* that table.
The scripts are explicit pipeline commands, not hidden imports. The shared
imports are `curation.py` (schema + transform math + the `frame_variant`/
`frame_filename` resolver, kept single-source so the webview server and the
compose scripts cannot drift) and `runio.py` (safe run-dir IO: a single-writer
lock file `.sprite-gen.lock` taken by the extract/compose/export/unpack
writers, plus atomic temp+`os.replace` writes for frames, atlas, manifests, and
inspect/score/loop reports). The lock enforces the "one worker owns one
character folder" rule at runtime — a second writer on the same run dir fails
loudly with the holder's pid; a dead holder's lock is reclaimed automatically.
`curation.json` stays outside the pipeline write lock: the webview writes it
atomically and compose reads one consistent snapshot, so human edit sessions never
block on a running compose. The curation write is, however, serialized against a
`--force` re-import publish via the separate publish rwlock (`read_guard`/
`publish_guard`), and a curation POST whose echoed run generation (`runRevision`)
no longer matches is rejected — a stale autosave cannot apply old selections to a
re-imported run's frames, even when it keeps the same state names (run-contract.md §4).
The automatic correction loop is intentionally split into three owners:
`inspect.py` measures deterministic signals (frame count, RGB histogram, dHash,
motion presence, centroid jitter, existing extraction warnings), `score.py`
turns only that report into scores and correction hints, and
`correction_loop.py` preserves the best candidate while optionally invoking an
explicit provider command. In dry-run mode it never generates new pixels; it
only proves the defect → score → hint path.
## 3. The numeric SSoT: `sprite-request.json`
Every run starts here. It owns the recipe consumed by both prompts and scripts:
```json
{
"version": 1,
"kind": "sprite-gen-request",
"engine": "component-row",
"character": { "id": "howl", "description": "...", "base_image": "base-source.png" },
"cell": { "shape": "square", "size": 256, "safe_margin": 24 },
"chroma_key": { "name": "magenta", "hex": "#FF00FF", "rgb": [255, 0, 255] },
"chroma": { "mode": "rgb" },
"states": { "idle": { "frames": 4, "fps": 4, "loop": true, "action": "..." } },
"fit": { "pixel_unfake": true, "logical_height": 64, "palette_size": 24, "align_x": "foot-centroid", "align_y": "bottom", "segmentation": "components" },
"style": "...",
"motion_phase_guides": false
}
```
- `character.base_image` is the canonical identity source for the *pre-idle*
phase only (see §5).
- `chroma_key` is chosen by sampling the base image (`--chroma-key auto`) unless
forced. The key is picked **away from the subject's dominant hue** because
extraction eats chroma-adjacent tint (behavior contract:
[`chroma-alpha.md`](chroma-alpha.md)).
- `states` is a free map; `frames`/`fps`/`loop`/`action` per state.
- `fit` is optional (absent = legacy behavior): `resample`/`align_x`/`align_y`
tuning, or the full deterministic `pixel_unfake` mode (§6.1). Behavior
contract: [`pixel-unfake.md`](pixel-unfake.md).
- `fit.segmentation` is `components` by default; opt-in `projection` is the
fused-pose recovery path (§6). The extract CLI can explicitly override it.
- `chroma.mode` is `rgb` by default; opt-in `ycbcr` is owned by
[`chroma-alpha.md`](chroma-alpha.md). The extract and inspect CLIs can
explicitly override it.
- `motion_phase_guides` only does something for 8-frame locomotion states.
## 4. The cell model (read this — it is the most misunderstood part)
There is exactly **one** `cell` object, and it drives **all three** geometry
stages identically:
1. **Generation** — `draw_guide()` renders a guide of `frames * cell_width`
wide × `cell_height` tall, and `row_prompt()` tells the model to treat the
strip as "invisible `cell_width`×`cell_height` frame slots".
2. **Extraction** — `fit_to_cell()` refits each extracted pose into a
`cell_width`×`cell_height` transparent cell.
3. **Atlas** — `compose_sprite_atlas.py` places every frame into a
`cell_width`×`cell_height` slot; the sheet is
`max_frames*cell_width` × `num_states*cell_height`.
### 4.1 Square vs rect — variabilized, but a single shape
`size` is a variable, not a hidden constant. The cell may be square or rect:
```json
"cell": { "shape": "square", "size": 256, "safe_margin": 24 }
"cell": { "shape": "rect", "width": 192, "height": 208, "safe_margin_x": 18, "safe_margin_y": 16 }
```
`normalize_cell()` derives `shape` from `width == height`. **Default is square
256.** Picking `rect` makes the cell taller (hatch-pet-style) — but that one
shape is then used end-to-end, generation *and* output alike.
### 4.2 Design note: one cell, NOT a two-stage tall→square transform
A common mental model is "generate in a tall/portrait cell to save image area,
then cut/place into a square cell for the final atlas." **The code does not do
this.** There is no separate `gen_cell` vs `atlas_cell`; the generated strip and
the final atlas share the same `cell`. If you generate rect, the atlas is rect;
if you generate square, the atlas is square.
What *is* true, and what makes a two-stage model only a small step away:
extraction is **content-based, not slot-based**. `connected_components()` finds
each pose blob, groups blobs by x-center, and `fit_to_cell()` crops the content
bbox, rescales it preserving aspect ratio (`scale = min(...) ≤ 1.0`), and
centers it in the target cell. So the refit already decouples a pose from the
strip's pixel dimensions — a future change could feed a tall `gen_cell` to the
guide/prompt and a square `atlas_cell` to `fit_to_cell`/compose without touching
the extraction core. Today, that separation is not wired.
## 5. Idle-anchor architecture (identity ownership)
Stage 0 is a BLOCKING gate (the gate question and the five lock criteria live
in [`../SKILL.md`](../SKILL.md)). The ownership rule:
```text
identity truth = accepted idle anchor
motion truth = layout guide + paired/basis row when needed
base truth = used only to create idle anchors, then dropped from row inputs
```
Base character images, original character sheets, and broad style references
are allowed **only** before idle anchors are accepted. Once an idle anchor
exists for the requested direction, later state rows must not attach the base
character image as insurance. Re-attaching base makes the row model solve
identity again and weakens the purpose of the idle-anchor workflow.
Reference ownership flow:
```mermaid
flowchart TD
REFS["USER REFS / CHARACTER SHEET"] --> BASE["0. BASE IDLE 생성
원본/캐릭터시트는 여기서만 사용
비율/스타일/색/소품 고정"]
BASE --> ANCHORS["1. 방향별 IDLE ANCHOR 생성
idle-front-right · idle-front-left
idle-back-right · idle-back-left"]
ANCHORS --> DROP["BASE CHARACTER 폐기
original refs 폐기 · character sheet 폐기
이후 row 입력 금지"]
DROP --> BASIS["2. BASIS ROW 생성
input: target-direction idle
+ target-state layout only
output: basis row"]
BASIS --> PAIRED["3. PAIRED ROW 생성
input: paired-direction idle
+ paired-state layout only + basis row
output: paired row"]
PAIRED --> ASSEMBLE["4. GIF / SHEET 조립
row crop · gif preview · frame-index QA"]
```
- The prompt text in `row_prompt()` enforces this ("Anchor lock" block):
identity comes from the anchor, the row owns motion only.
- **Which image is "the accepted anchor" is code, not judgment** (`sprite_gen/anchor.py`,
`sprite-gen anchor`): it resolves the human's pin (`curation.json` `anchors.`)
or, absent one, the anchor row's curated sequence head; bakes that instance with the
same primitives compose/export use (clone → pixel edits → transform → pixel-unfake
re-quantization); and writes the derived cache
`references/anchors/-anchor-x8.png` that row generation attaches. Reroll, the
generation plan, and the curation view's anchor chip all go through it, so the picture
the human approved on screen is the picture the next row inherits.
- Default/simple states (`idle`/`jump`/`attack`/`wave`) skip the multi-anchor
chain. Directional / 45° / locomotion use the chain documented in
[`directional-anchor-workflow.md`](directional-anchor-workflow.md).
## 6. Extraction internals (`extract_sprite_row_frames.py`)
1. `remove_chroma_background()` — drop near-key pixels (color-distance ball,
`--key-threshold` default 96), clear fully-transparent RGB, then solve
chroma-tinted boundary blends into despilled RGB + soft alpha. In-band
blends are limited to key-distance `<= 2`; out-of-band blends use
`--fringe-unmix-reach` (default 4). Small trapped spill clusters are
despilled in place, while deeper key-tinted subject material survives.
With request `chroma.mode: "ycbcr"` or CLI `--chroma-mode ycbcr`, the
extractor uses `remove_chroma_background_ycbcr()` instead: border-mode key
detection and chrominance-plane flood fill tolerate shaded keys and JPEG
chroma noise. RGB remains the default.
2. `connected_components()` — flood-fill alpha blobs, record bbox + area +
x-center.
3. `separate_fused_poses()` — only when `fit.segmentation: "projection"` or
`--segmentation projection` is selected, projection profiles plus dynamic
programming find the minimum-cost vertical cuts for poses joined by thin
bridges. The default `components` path stays byte-identical and fails
loudly when the declared pose count cannot be recovered.
4. `extract_component_frames()` — pick `frame_count` seed blobs by area, sort by
x-center, attach smaller noise blobs to the nearest seed; one group = one
pose. If it cannot find `frame_count` poses, the row is **blocked** (no
silent fallback). `--allow-slot-fallback` exists for debugging only and is
reported as `method: slots-explicit`.
5. `fit_to_cell()` — crop, aspect-preserving downscale (`resample`:
lanczos | nearest | kcentroid), `align_x`/`align_y` placement, into the
cell. On `fit.pixel_unfake` runs this legacy path still runs once per
frame to produce the `.plain.png` twin (§6.1); the canonical frame goes
through the pixel-unfake path instead.
6. `inspect_frames()` — per-frame QA: empty/sparse, edge bleed, chroma-adjacent
pixels, size outliers → `frames-manifest.json`.
This is intentionally closer to hatch-pet's cleanup than to a plain
`magick -transparent`.
### 6.1 Pixel-unfake path (`fit.pixel_unfake`, deterministic)
The behavior contract is [`pixel-unfake.md`](pixel-unfake.md); this is how
the code realizes it. The path is unfake.js/pixeldetector-style and contains
**no model call and no non-integer resampling** — same input, same output:
1. **Pitch detection** — `detect_pixel_pitch()` scores each candidate pitch by
edge-to-gridline alignment (the fraction of color boundaries landing within
±w of grid lines, minus the chance expectation `(2w+1)/p`) and takes the
argmax. Detection runs **per frame** and **each frame is snapped with its
own detected pitch** (Soohong 2026-07-20, plan `sprite-gen/per-frame-pixel-grid`:
forcing a strip-wide pitch onto a frame accumulates the measurement gap
across the width and cuts through block centers). The **median** across
confident frames (collapse-filtered) is kept only as the fallback for frames
whose own detection is inconclusive — applying it emits a per-frame warning,
and a frame whose own pitch deviates from the consensus is warned, never
overridden. If every frame fails, `fit.pitch_hint` (usually the base
image's detected pitch) is used. `estimate_pixel_grid_runlen()` then
derives an independent modal run-length estimate; `crosscheck_pitch_runlen()`
records divisor/harmonic or axis-ratio disagreement as observable warnings.
The run-length estimate is a crosscheck only and never silently replaces the
canonical grid score.
2. **Grid-phase snap** — the phase is **measured, not inferred from the pitch
detector**: `_best_phase()` scores real cell uniformity per frame and picks
the best offset, `refine_edges_to_boundaries()` then pulls each interior cut
onto a real colour boundary (±pitch/3), and `snap_by_edges()` collapses each
block to one output pixel via `_dominant_block_color()` voting. The
histogram phase that `_axis_refine()` returns alongside the pitch is **not**
used here — it can sit up to pitch/2 off the true phase, which is outside the
±pitch/3 recovery window and merged a character's eye from 4 rows into 3
(plan `sprite-gen/frame-pitch-consensus-eats-a-row`; see
[`pixel-unfake.md`](pixel-unfake.md) "위상은 근사가 아니라 실측으로 고른다").
`detail_bias` (default true) prefers a near-black minority cluster
(share ≥ 0.40, luma < 70/255) so eyes and 1px outlines survive dominant voting.
3. **Physical cap only — no conform squash** — `conform_row_logical()` keeps the
snapped native logical size and only enforces the physical cell cap
(kCentroid, `_kcentroid_downscale()`), warning observably when a frame hits
it. Squashing to a declared `logical_height` was removed (Soohong
2026-07-14/17: it merges cells and eats detail); `fit.conform` is now
rejected loudly if present in a request.
4. **Alpha binarization** — `binarize_alpha()`, applied on the outputs of steps
2-3 (snap, then cap) so every logical pixel is fully opaque or fully
transparent.
5. **Inter-frame registration** — `register_row_frames()` aligns frames on the
stable upper body (alpha overlap, small slack), so locomotion rows don't
jitter on per-frame content changes.
6. **Shared palette** — `build_shared_palette()` + `apply_palette()`: one
run-wide median-cut palette (`palette_size`) kills frame-to-frame color
flicker.
7. **Outline** — `enforce_outline()` draws a uniform 1px silhouette outline.
8. **Integer placement** — `fit_pixel_unfake()` / `row_placement()` upscale by
the integer factor `cell_height // logical_height` (NEAREST) and place with
offsets honoring `align_x`/`align_y`. `align_x: "alpha-centroid"` computes
the fringe-insensitive alpha-weighted centroid per frame (alpha ≤ 10 is
ignored), cancelling residual horizontal registration jitter without
flattening vertical motion arcs.
Alongside each canonical `frame-N.png`, the extractor writes two pre-pixel-
perfect twins (same strip through the legacy fit path; if twin production fails
it is a warning, only that twin is dropped): the cell-sized `frame-N.plain.png`
and the hi-res `orig/frame-N.png` (legacy fit at S×cell, `S = min(4, 1024//cell)`,
display-only). The curation webview toggles the display to `orig/` when present
(crisp "off = original", else `.plain.png`), while `curation.json.pixel_unfake:
false` makes compose/GIF/PNG export bake the cell-sized `.plain.png` variant —
missing plain files are a hard error, not a silent fallback. The hi-res twin
lives in a subdir so non-recursive `frame-*.png` globs (inspect, measure) never
pick it up. The chosen variant is recorded as `frame_variant` in reports and the
manifest. Display-contract SSoT: [`run-contract.md`](run-contract.md) §3.
## 7. Curation sidecar (`curation.json`)
Optional, **non-destructive**. Written by the webview (`serve_curation.py`),
consumed by `compose_sprite_atlas.py` and `compose_selected_cycle.py`. The
original `frames//frame-N.png` files are never rewritten.
```json
{ "version": 1, "kind": "sprite-gen-curation", "run_revision": "9f3c1a0b7e2d4c58",
"pixel_unfake": true,
"states": { "idle": { "selected": [0,2,3], "order": [0,2,3,1],
"transforms": { "0": { "rotate": 15, "scale": 1.2, "dx": 10, "dy": -8, "flipX": 0 } } } } }
```
- `run_revision` — **required** frame-generation stamp. `load_curation` applies the
sidecar only when it matches the current run; a mismatched **or missing** stamp is
stale and ignored (all-frames default, stderr-warned) — a re-import/re-extract or a
legacy/hand-written sidecar never applies to frames it isn't proven to belong to.
- `pixel_unfake` — top-level bake decision: `false` → compose/export bake the
`.plain.png` twins (§6.1); absent/`true` → canonical `frame-N.png`.
- `selected` — 0-based indices in play order; absent → all frames in order.
- `order` — webview-owned full display order (sequence row then candidate-pool
row) so reopening the curator restores both rows; compose ignores it and
keys off `selected`.
- `transforms` — per-index affine (`rotate`, `scale`, `dx`/`dy`, `shx`/`shy`
shear, `flipX`), applied at compose time **inside** the cell so atlas
geometry never changes.
- A state missing from the sidecar uses the all-frames identity default — an
explicit default, not a silent fallback.
- `curation.py` owns the schema + transform math (single source for server and
compose). Full field semantics: [`curation.md`](curation.md).
## 8. Runtime contract (`manifest.json`)
`manifest.json.frame_layout` is the runtime SSoT. Game code consumes absolute
rectangles and must not recover frame rects from alpha at runtime.
Required fields:
- `game_input: "sprite-sheet-alpha.png"`
- `degraded_static_fallback: false`
- `animation.rows.` → `{ row, frames, fps, loop }`
- `frame_layout.rows.[i]` → `{ x, y, w, h }` absolute atlas rects
- `frame_layout` also carries `sheetWidth/sheetHeight/cellWidth/cellHeight`
- `curation_applied` records whether a sidecar was baked; `frame_variant`
records which frame variant (canonical / plain) was baked
Static fallback is allowed only as explicit survival output when generation is
blocked; it must not create `sprite-sheet-alpha.png` and is not a pass.
## 9. Output directory (one worker owns one character folder)
The canonical run-dir folder tree (every input/output file, plus which files drive
the curation view) is owned by [`run-contract.md`](run-contract.md) §2. The
single-writer lock that enforces "one worker owns one character folder" at runtime is
`runio.py`'s `.sprite-gen.lock` (§2 above).
## 10. Inverse paths (editing finished assets)
- `unpack_atlas_run.py` rebuilds a curator-ready run dir from a finished atlas
(layout source priority: `--grid` > `--manifest` > auto-detect), or imports a
folder of separate PNGs via `--pngs-dir` (carries a sibling `meta.json` for
iso tile/anchor grid overlay).
- `export_curated_pngs.py` bakes the curation transform back into named PNGs
(the deliverable for imported still sets like furniture); the single atlas is
the deliverable for animation frames / runtime perf.
## 11. Relationship to hatch-pet
`sprite-gen` is inspired by the Apache-2.0 `hatch-pet` skill but ships no pet
assets. Shared DNA: one image-gen job **per row/state**, layout-guide grounding,
chroma cleanup, deterministic assembly scripts, contact-sheet/video QA.
Key differences:
| | hatch-pet | sprite-gen |
|---|---|---|
| Atlas | fixed 8×9, fixed 192×208 cell, 9 fixed pet states | variable state list, variable cell (square default 256) |
| Cell shape | fixed rect 192×208 | square or rect, one config end-to-end (§4) |
| Frame cut | slot-based geometry | content-based connected components + refit (+ pixel-unfake path) |
| Identity | canonical base reference reused on every row | idle-anchor ownership; base dropped after anchors |
| Curation | n/a | non-destructive `curation.json` + webview |
| Inverse | n/a | unpack atlas / import PNG pack / export stills |
| Packaging | `pet.json` + `spritesheet.webp` into `~/.codex/pets/` | runtime `manifest.json` `frame_layout` |
Both generate **row-by-row, one state per image-gen call** — neither produces a
whole atlas in a single generation.
## Related
- [`../SKILL.md`](../SKILL.md) — canonical behavior contract
- [`pixel-unfake.md`](pixel-unfake.md) — `fit`/`pixel_unfake` behavior contract
- [`curation.md`](curation.md) — webview usage + `curation.json` field semantics
- [`chroma-alpha.md`](chroma-alpha.md) — chroma key selection + alpha cleanup contract
- [`gen.md`](gen.md) — provider CLI, verified PNG/report contract, and `image-gen` shuttle boundary
- [`directional-anchor-workflow.md`](directional-anchor-workflow.md) — 방향성/45도 앵커 체인
- [`locomotion-curation.md`](locomotion-curation.md) — selected-cycle + clean GIF export