Add grid spritesheet exports
This commit is contained in:
parent
afd1076b7b
commit
c13f3b841c
9 changed files with 198 additions and 16 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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%."}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue