From c13f3b841cc596fbd0392c5c681017ade04fb79e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 31 May 2026 02:14:03 +0100 Subject: [PATCH] Add grid spritesheet exports --- README.md | 4 ++ docs/lean-optimizer-algorithm-map.md | 23 ++++---- notes/fresh-workspace-handoff.md | 10 ++-- src/repixelizer/cli.py | 10 ++++ src/repixelizer/spritesheet.py | 72 ++++++++++++++++++++++++- state/evidence.jsonl | 1 + state/map.yaml | 2 +- tests/test_cli.py | 12 +++++ tests/test_spritesheet.py | 80 +++++++++++++++++++++++++++- 9 files changed, 198 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8514fbd..9b569c9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/lean-optimizer-algorithm-map.md b/docs/lean-optimizer-algorithm-map.md index 66f68df..3508ebd 100644 --- a/docs/lean-optimizer-algorithm-map.md +++ b/docs/lean-optimizer-algorithm-map.md @@ -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 diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 8bb2071..04b6c32 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -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 diff --git a/src/repixelizer/cli.py b/src/repixelizer/cli.py index c7acca7..d1fb581 100644 --- a/src/repixelizer/cli.py +++ b/src/repixelizer/cli.py @@ -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, diff --git a/src/repixelizer/spritesheet.py b/src/repixelizer/spritesheet.py index fdf8981..824161d 100644 --- a/src/repixelizer/spritesheet.py +++ b/src/repixelizer/spritesheet.py @@ -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": [ { diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 5360de9..db4419b 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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%."} diff --git a/state/map.yaml b/state/map.yaml index 7b91d77..80cbfd0 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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. diff --git a/tests/test_cli.py b/tests/test_cli.py index 25f2243..310982f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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, diff --git a/tests/test_spritesheet.py b/tests/test_spritesheet.py index 99f78d8..7c80512 100644 --- a/tests/test_spritesheet.py +++ b/tests/test_spritesheet.py @@ -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