Add grid spritesheet exports

This commit is contained in:
Meta Vens 2026-05-31 02:14:03 +01:00
commit c13f3b841c
9 changed files with 198 additions and 16 deletions

View file

@ -121,6 +121,7 @@ Run a spritesheet as separate sprite regions:
```powershell
repixelize spritesheet sheet.png --out output.png --sprite-count 6 --target-width 32 --target-height 32
repixelize spritesheet sheet.png --out output.png --sheet-columns 3 --sheet-rows 2 --export-sprites-dir sprites
repixelize spritesheet sheet.png --out output.png --diagnostics-dir diagnostics
```
@ -130,6 +131,9 @@ robust shared texel density for the sheet, and pins each crop to that density
before reconstruction. Pinned target dimensions apply per sprite, not to the
whole sheet. Add `--strip-background` when the sheet has an opaque
edge-connected backdrop that should become transparent before cropping.
Use `--sheet-columns` and `--sheet-rows` for fixed-layout character sheets; grid
mode exports full cells and does not let disconnected limbs, weapons, shadows,
or effects become separate sprites.
## Corpus And Benchmarks

View file

@ -47,26 +47,31 @@ the CLI, GUI, spritesheet wrapper, and comparison harness.
### Ownership
Spritesheet mode owns region detection, sheet-level texel density selection, and
output packing only. It does not own reconstruction, cleanup, palette fitting,
or metrics.
Spritesheet mode owns sprite-region identity, sheet-level texel density
selection, optional individual exports, and output packing only. It does not own
reconstruction, cleanup, palette fitting, or metrics.
### What the source actually does
Spritesheet mode:
1. Loads one source sheet.
2. Builds a foreground mask from alpha, or from edge-connected background
2. If `--sheet-columns` and `--sheet-rows` are supplied, slices the sheet into
full grid cells in reading order. This is the conservative path for
character sheets where limbs, weapons, shadows, and effects can be
disconnected.
3. Otherwise builds a foreground mask from alpha, or from edge-connected background
stripping when `--strip-background` is requested. Fully opaque sheets use a
fast border-color foreground guess by default.
3. Detects connected foreground regions, optionally selecting the largest
4. Detects connected foreground regions, optionally selecting the largest
requested `--sprite-count`.
4. Sorts regions in reading order.
5. When no explicit target size is supplied, probes each crop with autocorr
5. Sorts regions in reading order.
6. When no explicit target size is supplied, probes each crop with autocorr
inference, chooses the median source-pixels-per-texel density for the whole
sheet, and pins each crop to dimensions derived from that shared density.
6. Crops each region and calls `run_pipeline_rgba(...)` on that crop.
7. Packs per-sprite outputs back into source-row order and writes a
7. Crops each region and calls `run_pipeline_rgba(...)` on that crop.
8. Writes individual sprite PNGs when `--export-sprites-dir` is supplied.
9. Packs per-sprite outputs back into source-row order and writes a
`spritesheet.json` diagnostic summary when diagnostics are enabled.
Pinned target width, height, or size applies to each sprite crop and disables

View file

@ -36,10 +36,12 @@ Do not continue implementation automatically from a rehydrate-only request.
- `source_structure` exists alongside `source_fidelity` so visible structure and lattice agreement can be inspected separately.
- Cleanup is alpha snap plus diagnostics.
- `src/repixelizer/spritesheet.py` owns spritesheet region detection and packing.
In automatic mode it probes crop sizes, chooses one shared
source-pixels-per-texel density for the sheet, then calls
`run_pipeline_rgba(...)` once per detected crop. Pinned target sizes apply per
sprite and bypass shared-density sizing.
Explicit grid mode slices full cells with `--sheet-columns` and
`--sheet-rows`; this is the conservative path for character sheets. Automatic
mode probes crop sizes, chooses one shared source-pixels-per-texel density
for the sheet, then calls `run_pipeline_rgba(...)` once per crop. Pinned
target sizes apply per sprite and bypass shared-density sizing. Individual
sprite PNGs are written when `--export-sprites-dir` is supplied.
## Hosted/web state

View file

@ -57,6 +57,13 @@ def build_parser() -> argparse.ArgumentParser:
default=None,
help="Expected number of sprites. If omitted, large foreground regions are detected automatically.",
)
spritesheet_parser.add_argument("--sheet-columns", type=int, default=None, help="Split the sheet into this many columns.")
spritesheet_parser.add_argument("--sheet-rows", type=int, default=None, help="Split the sheet into this many rows.")
spritesheet_parser.add_argument(
"--export-sprites-dir",
default=None,
help="Directory for individual per-sprite PNG exports.",
)
benchmark_parser = subparsers.add_parser("benchmark", help="Run the round-trip corpus benchmark.")
benchmark_parser.add_argument("--corpus-dir", default="examples/corpus", help="Corpus root containing originals/")
benchmark_parser.add_argument("--out-dir", default="artifacts/benchmark", help="Directory for benchmark outputs")
@ -176,6 +183,9 @@ def main(argv: list[str] | None = None) -> int:
args.input,
args.out,
sprite_count=args.sprite_count,
sheet_columns=args.sheet_columns,
sheet_rows=args.sheet_rows,
export_sprites_dir=args.export_sprites_dir,
target_size=args.target_size,
target_width=args.target_width,
target_height=args.target_height,

View file

@ -50,6 +50,9 @@ def run_spritesheet(
output_path: str | Path,
*,
sprite_count: int | None = None,
sheet_columns: int | None = None,
sheet_rows: int | None = None,
export_sprites_dir: str | Path | None = None,
target_size: int | None = None,
target_width: int | None = None,
target_height: int | None = None,
@ -64,7 +67,12 @@ def run_spritesheet(
) -> SpritesheetResult:
source = load_rgba(input_path)
detection_source = strip_edge_background(source) if strip_background else source.copy()
regions = detect_sprite_regions(detection_source, sprite_count=sprite_count)
regions = resolve_sprite_regions(
detection_source,
sprite_count=sprite_count,
sheet_columns=sheet_columns,
sheet_rows=sheet_rows,
)
if not regions:
raise ValueError("No sprite regions were detected in the spritesheet.")
@ -103,6 +111,9 @@ def run_spritesheet(
)
sprite_runs.append(SpriteRun(region=region, result=result))
if export_sprites_dir is not None:
_write_individual_sprites(export_sprites_dir, input_path, sprite_runs)
output = _pack_sprite_outputs(sprite_runs)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
save_rgba(output_path, output)
@ -110,6 +121,9 @@ def run_spritesheet(
diagnostics = _spritesheet_summary(
source_shape=source.shape,
sprite_count_request=sprite_count,
sheet_columns=sheet_columns,
sheet_rows=sheet_rows,
export_sprites_dir=export_sprites_dir,
sprite_runs=sprite_runs,
output_shape=output.shape,
shared_density=_shared_density_summary(regions, inferred_targets),
@ -119,6 +133,48 @@ def run_spritesheet(
return SpritesheetResult(output_rgba=output, sprites=sprite_runs, diagnostics=diagnostics)
def resolve_sprite_regions(
rgba: np.ndarray,
*,
sprite_count: int | None = None,
sheet_columns: int | None = None,
sheet_rows: int | None = None,
) -> list[SpriteRegion]:
if sheet_columns is None and sheet_rows is None:
return detect_sprite_regions(rgba, sprite_count=sprite_count)
if sheet_columns is None or sheet_rows is None:
raise ValueError("sheet_columns and sheet_rows must be supplied together.")
if sprite_count is not None and sprite_count != sheet_columns * sheet_rows:
raise ValueError("sprite_count must match sheet_columns * sheet_rows when a sheet grid is supplied.")
return grid_sprite_regions(rgba, columns=sheet_columns, rows=sheet_rows)
def grid_sprite_regions(rgba: np.ndarray, *, columns: int, rows: int) -> list[SpriteRegion]:
if columns <= 0 or rows <= 0:
raise ValueError("sheet grid dimensions must be positive.")
height, width = rgba.shape[:2]
regions: list[SpriteRegion] = []
for row in range(rows):
top = round(row * height / rows)
bottom = round((row + 1) * height / rows)
for column in range(columns):
left = round(column * width / columns)
right = round((column + 1) * width / columns)
cell = rgba[top:bottom, left:right]
foreground_pixels = int(np.count_nonzero(_foreground_mask(cell))) if cell.size else 0
regions.append(
SpriteRegion(
index=len(regions),
left=left,
top=top,
right=right,
bottom=bottom,
foreground_pixels=foreground_pixels,
)
)
return regions
def detect_sprite_regions(
rgba: np.ndarray,
*,
@ -337,10 +393,21 @@ def _group_sprite_runs_by_source_row(sprite_runs: list[SpriteRun]) -> list[list[
return rows
def _write_individual_sprites(export_sprites_dir: str | Path, input_path: str | Path, sprite_runs: list[SpriteRun]) -> None:
export_path = Path(export_sprites_dir)
export_path.mkdir(parents=True, exist_ok=True)
stem = Path(input_path).stem
for run in sprite_runs:
save_rgba(export_path / f"{stem}-sprite-{run.region.index:03d}.png", run.result.output_rgba)
def _spritesheet_summary(
*,
source_shape: tuple[int, ...],
sprite_count_request: int | None,
sheet_columns: int | None,
sheet_rows: int | None,
export_sprites_dir: str | Path | None,
sprite_runs: list[SpriteRun],
output_shape: tuple[int, ...],
shared_density: dict[str, Any] | None,
@ -352,6 +419,9 @@ def _spritesheet_summary(
"output_width": int(output_shape[1]),
"output_height": int(output_shape[0]),
"sprite_count_request": sprite_count_request,
"sheet_columns": sheet_columns,
"sheet_rows": sheet_rows,
"export_sprites_dir": None if export_sprites_dir is None else str(export_sprites_dir),
"sprite_count_detected": len(sprite_runs),
"sprites": [
{

View file

@ -33,3 +33,4 @@
{"ts": "2026-05-24T10:03:31+00:00", "type": "deployment", "status": "ok", "note": "Deployed Repixelizer header community links to Yggdrasil; service check passed and hosted root HTML contains Patreon and Discord links."}
{"ts": "2026-05-30T22:47:57+00:00", "type": "implementation", "status": "ok", "note": "Added spritesheet CLI mode: detect separated foreground regions or select requested largest components, call run_pipeline_rgba per crop, pack outputs in source-row order, and write spritesheet diagnostics; fixture auto-detects six sprites."}
{"ts": "2026-05-30T23:39:55+00:00", "type": "implementation", "status": "ok", "note": "Spritesheet automatic sizing now uses a sheet-level median texel density before reconstruction; on the 12 generated spritesheets in 019e7aa3-4bbb-7302-bafd-273f2aa31e53, worst per-sheet density drift improved from 42.21% to 1.786% across 153 sprites."}
{"ts": "2026-05-31T01:13:17+00:00", "type": "implementation", "status": "ok", "note": "Spritesheet mode now supports explicit grid ownership via sheet_columns/sheet_rows plus individual sprite export. The 12 generated character sheets were rerun as 3x2 grids, producing 72 exports with no component chopping and worst within-sheet density deviation 0.124%."}

View file

@ -46,7 +46,7 @@ accepted_design:
- `phase-field` in `src/repixelizer/phase_field.py` is the only live reconstruction engine.
- The working pipeline is `source image -> lattice inference -> diagnostic edge preview -> fixed lattice centers -> hierarchical flatness signal -> explicit local search plus lattice relaxation -> nearest source sample -> cleanup -> optional palette fitting -> diagnostics`.
- `src/repixelizer/pipeline.py` owns orchestration and candidate rerank selection.
- `src/repixelizer/spritesheet.py` owns spritesheet region detection, shared texel density sizing, and output packing; each detected crop still runs through the canonical `run_pipeline_rgba` solver path.
- `src/repixelizer/spritesheet.py` owns spritesheet region identity, explicit grid slicing, shared texel density sizing, optional individual sprite export, and output packing; each detected crop still runs through the canonical `run_pipeline_rgba` solver path.
- `src/repixelizer/analysis.py` provides the normalized diagnostic edge preview used by observer/GUI/diagnostic paths; it does not steer the solver.
- `src/repixelizer/phase_field.py` owns the live source-position field: initialize from fixed centers, choose local preferred positions from a 5x5 candidate window, blend toward them, relax lattice springs, then nearest-sample the source.
- Hosted demo inference uses autocorr lattice candidates, capped by the hosted output limit, with low-confidence preview rerank available.

View file

@ -75,6 +75,9 @@ def test_spritesheet_command_dispatches_to_spritesheet_runner(monkeypatch, tmp_p
called["input_path"] = input_path
called["output_path"] = output_path
called["sprite_count"] = kwargs["sprite_count"]
called["sheet_columns"] = kwargs["sheet_columns"]
called["sheet_rows"] = kwargs["sheet_rows"]
called["export_sprites_dir"] = kwargs["export_sprites_dir"]
called["target_width"] = kwargs["target_width"]
called["target_height"] = kwargs["target_height"]
called["enable_candidate_rerank"] = kwargs["enable_candidate_rerank"]
@ -91,6 +94,12 @@ def test_spritesheet_command_dispatches_to_spritesheet_runner(monkeypatch, tmp_p
str(output_path),
"--sprite-count",
"6",
"--sheet-columns",
"3",
"--sheet-rows",
"2",
"--export-sprites-dir",
str(tmp_path / "sprites"),
"--target-width",
"32",
"--target-height",
@ -104,6 +113,9 @@ def test_spritesheet_command_dispatches_to_spritesheet_runner(monkeypatch, tmp_p
"input_path": str(input_path),
"output_path": str(output_path),
"sprite_count": 6,
"sheet_columns": 3,
"sheet_rows": 2,
"export_sprites_dir": str(tmp_path / "sprites"),
"target_width": 32,
"target_height": 32,
"enable_candidate_rerank": False,

View file

@ -5,7 +5,9 @@ from pathlib import Path
import numpy as np
from repixelizer.io import save_rgba
from repixelizer.spritesheet import detect_sprite_regions, run_spritesheet
import pytest
from repixelizer.spritesheet import detect_sprite_regions, grid_sprite_regions, resolve_sprite_regions, run_spritesheet
from repixelizer.types import CleanupArtifacts, InferenceResult, PhaseFieldSourceAnalysis, RunResult, SolverArtifacts
@ -157,3 +159,79 @@ def test_spritesheet_auto_mode_pins_shared_density(tmp_path: Path, monkeypatch)
assert len(pinned_targets) == 4
assert all(width is not None and height is not None for width, height in pinned_targets)
assert result.diagnostics["shared_density"]["max_relative_deviation"] < 0.12
def test_grid_sprite_regions_use_full_cells_in_reading_order() -> None:
sheet = np.zeros((40, 60, 4), dtype=np.float32)
regions = grid_sprite_regions(sheet, columns=3, rows=2)
assert len(regions) == 6
assert [region.index for region in regions] == [0, 1, 2, 3, 4, 5]
assert [regions[0].left, regions[0].top, regions[0].right, regions[0].bottom] == [0, 0, 20, 20]
assert [regions[5].left, regions[5].top, regions[5].right, regions[5].bottom] == [40, 20, 60, 40]
def test_grid_sprite_regions_reject_conflicting_sprite_count() -> None:
sheet = np.zeros((40, 60, 4), dtype=np.float32)
with pytest.raises(ValueError, match="sprite_count"):
resolve_sprite_regions(sheet, sprite_count=4, sheet_columns=3, sheet_rows=2)
def test_spritesheet_grid_mode_exports_individual_sprites(tmp_path: Path, monkeypatch) -> None:
input_path = tmp_path / "sheet.png"
output_path = tmp_path / "out.png"
export_dir = tmp_path / "sprites"
sheet = np.zeros((20, 30, 4), dtype=np.float32)
sheet[..., 3] = 1.0
save_rgba(input_path, sheet)
crop_shapes: list[tuple[int, int]] = []
def fake_run_pipeline_rgba(source, **kwargs):
crop_shapes.append(source.shape[:2])
rgba = np.zeros((2, 3, 4), dtype=np.float32)
rgba[..., 0] = len(crop_shapes) / 6.0
rgba[..., 3] = 1.0
inference = InferenceResult(target_width=3, target_height=2, confidence=1.0)
solver = SolverArtifacts(
target_rgba=rgba,
uv_field=np.zeros((2, 3, 2), dtype=np.float32),
signal_strength=np.zeros((2, 3), dtype=np.float32),
initial_rgba=rgba.copy(),
loss_history=[],
)
return RunResult(
source_rgba=source,
output_rgba=rgba,
inference=inference,
analysis=PhaseFieldSourceAnalysis(edge_map=np.zeros(source.shape[:2], dtype=np.float32)),
solver=solver,
cleanup=CleanupArtifacts(cleaned_rgba=rgba, isolated_heatmap=np.zeros((2, 3), dtype=np.float32)),
palette_result=None,
diagnostics={},
)
monkeypatch.setattr("repixelizer.spritesheet.run_pipeline_rgba", fake_run_pipeline_rgba)
monkeypatch.setattr(
"repixelizer.spritesheet.infer_autocorr_lattice",
lambda source, **kwargs: InferenceResult(target_width=3, target_height=2, confidence=1.0),
)
result = run_spritesheet(
input_path,
output_path,
sheet_columns=3,
sheet_rows=2,
export_sprites_dir=export_dir,
steps=0,
device="cpu",
)
assert output_path.exists()
assert len(crop_shapes) == 6
assert set(crop_shapes) == {(10, 10)}
assert len(list(export_dir.glob("sheet-sprite-*.png"))) == 6
assert result.diagnostics["sprite_count_detected"] == 6
assert result.diagnostics["sheet_columns"] == 3
assert result.diagnostics["sheet_rows"] == 2