diff --git a/README.md b/README.md index 52a484f..aa295c3 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ What exists now: - lattice inference with CUDA support - the `phase-field` reconstruction engine in `src/repixelizer/phase_field.py` - automatic diagnostics, comparisons, and benchmark runs +- spritesheet CLI mode that detects separated regions and repixelizes each region independently - a tuning harness for offline parameter sweeps - `source_fidelity` and `source_structure` metrics for lattice agreement and visible structure - a shippable explicit local-search solver config, with tuning notes in `docs/solver-tuning-lessons-2026-04-29.md` @@ -116,6 +117,18 @@ Run the optimizer plus baselines: repixelize compare input.png --out output.png --diagnostics-dir diagnostics ``` +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 --diagnostics-dir diagnostics +``` + +When `--sprite-count` is omitted, Repixelizer detects large separated foreground +regions. 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. + ## Corpus And Benchmarks Prepare a local Creative Commons corpus: @@ -173,6 +186,7 @@ That keeps benchmark assets, attribution exports, and tuning runs from polluting Core modules: - `inference.py`: target size inference +- `spritesheet.py`: spritesheet region detection and per-region pipeline orchestration - `phase_field.py`: explicit local-search phase-field solver - `metrics.py`: fidelity, adjacency, and motif metrics - `benchmark.py`: corpus benchmark runner diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index fe8b9c6..48dbe40 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -39,6 +39,7 @@ The hosted web shell has an auth seam: - `phase-field` is the only canonical reconstruction path. - The current explicit solver is fast enough for hosted interaction. - Compare mode, benchmark mode, diagnostics writing, GUI observer previews, and tuning harnesses exist. +- Spritesheet CLI mode detects separated foreground regions, runs the live pipeline per region, and packs the per-sprite outputs back into sheet order. - The repo reports both `source_fidelity` and `source_structure` so lattice agreement and visible structure can be inspected separately. - Hosted landing/app flow is in place and should be the immediate product path. diff --git a/docs/lean-optimizer-algorithm-map.md b/docs/lean-optimizer-algorithm-map.md index 406efea..9a5046d 100644 --- a/docs/lean-optimizer-algorithm-map.md +++ b/docs/lean-optimizer-algorithm-map.md @@ -11,6 +11,7 @@ The current spine is: The relevant source lives in: - `src/repixelizer/pipeline.py` +- `src/repixelizer/spritesheet.py` - `src/repixelizer/inference.py` - `src/repixelizer/analysis.py` - `src/repixelizer/phase_field.py` @@ -35,7 +36,37 @@ These are the pieces that actually matter: - `target_rgba`: final output grid after nearest-source sampling The remaining helpers load inputs, write diagnostics, or carry this state across -the CLI, GUI, and comparison harness. +the CLI, GUI, spritesheet wrapper, and comparison harness. + +## Spritesheet wrapper + +### Source + +- `run_spritesheet(...)` and `detect_sprite_regions(...)` in `src/repixelizer/spritesheet.py` +- CLI dispatch in `src/repixelizer/cli.py` + +### Ownership + +Spritesheet mode owns region detection and output packing only. It does not own +lattice inference, 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 + 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 + requested `--sprite-count`. +4. Sorts regions in reading order. +5. Crops each region and calls `run_pipeline_rgba(...)` on that crop. +6. 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. Automatic +inference also runs per crop. The whole sheet never receives one shared lattice. ## Diagram diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index ac8307c..90accda 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -35,6 +35,9 @@ Do not continue implementation automatically from a rehydrate-only request. - The signal is direct hierarchical flatness: multiscale luminance gradient/Laplacian energy, one normalization, inverted to flatness, then sharpened. - `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. + It calls `run_pipeline_rgba(...)` once per detected crop; pinned target sizes + apply per sprite, not to the whole sheet. ## Hosted/web state diff --git a/src/repixelizer/cli.py b/src/repixelizer/cli.py index 59e2994..c7acca7 100644 --- a/src/repixelizer/cli.py +++ b/src/repixelizer/cli.py @@ -8,6 +8,7 @@ from .compare import run_compare from .corpus import prepare_corpus from .gui import main as gui_main from .pipeline import run_pipeline +from .spritesheet import run_spritesheet from .tuning import tune_solver_hyperparams @@ -48,6 +49,14 @@ def build_parser() -> argparse.ArgumentParser: add_shared_arguments(run_parser) compare_parser = subparsers.add_parser("compare", help="Run the optimizer and comparison baselines.") add_shared_arguments(compare_parser) + spritesheet_parser = subparsers.add_parser("spritesheet", help="Run the optimizer separately on detected spritesheet regions.") + add_shared_arguments(spritesheet_parser) + spritesheet_parser.add_argument( + "--sprite-count", + type=int, + default=None, + help="Expected number of sprites. If omitted, large foreground regions are detected automatically.", + ) 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") @@ -124,7 +133,7 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: raw_argv = list(sys.argv[1:] if argv is None else argv) - if not raw_argv or raw_argv[0] not in {"run", "compare", "benchmark", "tune", "prepare-corpus", "gui", "-h", "--help"}: + if not raw_argv or raw_argv[0] not in {"run", "compare", "spritesheet", "benchmark", "tune", "prepare-corpus", "gui", "-h", "--help"}: raw_argv = ["run", *raw_argv] parser = build_parser() args = parser.parse_args(raw_argv) @@ -162,6 +171,24 @@ def main(argv: list[str] | None = None) -> int: workers=args.workers, ) return 0 + if command == "spritesheet": + run_spritesheet( + args.input, + args.out, + sprite_count=args.sprite_count, + target_size=args.target_size, + target_width=args.target_width, + target_height=args.target_height, + palette_path=args.palette, + palette_mode=args.palette_mode, + diagnostics_dir=args.diagnostics_dir, + seed=args.seed, + steps=args.steps, + device=args.device, + strip_background=args.strip_background, + enable_candidate_rerank=not args.skip_candidate_rerank, + ) + return 0 if command == "tune": tune_solver_hyperparams( args.corpus_dir, diff --git a/src/repixelizer/spritesheet.py b/src/repixelizer/spritesheet.py new file mode 100644 index 0000000..e5d496c --- /dev/null +++ b/src/repixelizer/spritesheet.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np + +from .diagnostics import write_json +from .io import load_rgba, save_rgba +from .pipeline import run_pipeline_rgba +from .preprocess import strip_edge_background +from .types import RunResult + + +@dataclass(frozen=True, slots=True) +class SpriteRegion: + index: int + left: int + top: int + right: int + bottom: int + foreground_pixels: int + + @property + def width(self) -> int: + return self.right - self.left + + @property + def height(self) -> int: + return self.bottom - self.top + + +@dataclass(slots=True) +class SpriteRun: + region: SpriteRegion + result: RunResult + + +@dataclass(slots=True) +class SpritesheetResult: + output_rgba: np.ndarray + sprites: list[SpriteRun] + diagnostics: dict[str, Any] + + +def run_spritesheet( + input_path: str | Path, + output_path: str | Path, + *, + sprite_count: int | None = None, + target_size: int | None = None, + target_width: int | None = None, + target_height: int | None = None, + palette_path: str | Path | None = None, + palette_mode: str = "off", + diagnostics_dir: str | Path | None = None, + seed: int = 7, + steps: int = 200, + device: str = "auto", + strip_background: bool = False, + enable_candidate_rerank: bool = True, +) -> 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) + if not regions: + raise ValueError("No sprite regions were detected in the spritesheet.") + + diagnostics_path = Path(diagnostics_dir) if diagnostics_dir else None + if diagnostics_path is not None: + diagnostics_path.mkdir(parents=True, exist_ok=True) + save_rgba(diagnostics_path / "detected-source.png", detection_source) + + sprite_runs: list[SpriteRun] = [] + for region in regions: + crop = detection_source[region.top : region.bottom, region.left : region.right].copy() + sprite_diagnostics = None + if diagnostics_path is not None: + sprite_diagnostics = diagnostics_path / f"sprite-{region.index:03d}" + result = run_pipeline_rgba( + crop, + target_size=target_size, + target_width=target_width, + target_height=target_height, + palette_path=palette_path, + palette_mode=palette_mode, + diagnostics_dir=sprite_diagnostics, + seed=seed + region.index, + steps=steps, + device=device, + strip_background=False, + enable_candidate_rerank=enable_candidate_rerank, + ) + sprite_runs.append(SpriteRun(region=region, result=result)) + + output = _pack_sprite_outputs(sprite_runs) + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + save_rgba(output_path, output) + + diagnostics = _spritesheet_summary( + source_shape=source.shape, + sprite_count_request=sprite_count, + sprite_runs=sprite_runs, + output_shape=output.shape, + ) + if diagnostics_path is not None: + write_json(diagnostics_path / "spritesheet.json", diagnostics) + return SpritesheetResult(output_rgba=output, sprites=sprite_runs, diagnostics=diagnostics) + + +def detect_sprite_regions( + rgba: np.ndarray, + *, + sprite_count: int | None = None, + padding: int = 2, + min_area_ratio: float = 0.0025, + max_auto_aspect: float = 5.0, +) -> list[SpriteRegion]: + mask = _foreground_mask(rgba) + components = _connected_components(mask) + if not components: + return [] + + image_area = max(1, rgba.shape[0] * rgba.shape[1]) + min_area = max(8, int(round(image_area * min_area_ratio))) + if sprite_count is None: + selected = [ + component + for component in components + if component.foreground_pixels >= min_area and _region_aspect(component) <= max_auto_aspect + ] + else: + if sprite_count <= 0: + raise ValueError("sprite_count must be positive.") + selected = sorted(components, key=lambda component: component.foreground_pixels, reverse=True)[:sprite_count] + if len(selected) < sprite_count: + raise ValueError(f"Detected only {len(selected)} sprite regions, but {sprite_count} were requested.") + + padded = [_pad_region(component, rgba.shape[1], rgba.shape[0], padding=padding) for component in selected] + return _reading_order(padded) + + +def _foreground_mask(rgba: np.ndarray) -> np.ndarray: + alpha = rgba[..., 3] + if np.any(alpha < 0.95): + return alpha > 0.05 + rgb = rgba[..., :3] + border = np.concatenate([rgb[0, :, :], rgb[-1, :, :], rgb[:, 0, :], rgb[:, -1, :]], axis=0) + background = np.median(border, axis=0) + distance = np.linalg.norm(rgb - background[None, None, :], axis=-1) + simple_mask = distance > (24.0 / 255.0) + foreground_fraction = float(np.mean(simple_mask)) + if 0.001 <= foreground_fraction <= 0.8: + return simple_mask + stripped = strip_edge_background(rgba) + stripped_alpha = stripped[..., 3] > 0.05 + if np.any(stripped_alpha): + return stripped_alpha + return simple_mask + + +def _connected_components(mask: np.ndarray) -> list[SpriteRegion]: + height, width = mask.shape + parent: list[int] = [] + rank: list[int] = [] + runs: list[tuple[int, int, int, int]] = [] + previous_row: list[tuple[int, int, int]] = [] + + def make_label() -> int: + label = len(parent) + parent.append(label) + rank.append(0) + return label + + def find(label: int) -> int: + while parent[label] != label: + parent[label] = parent[parent[label]] + label = parent[label] + return label + + def union(a: int, b: int) -> None: + root_a = find(a) + root_b = find(b) + if root_a == root_b: + return + if rank[root_a] < rank[root_b]: + parent[root_a] = root_b + elif rank[root_a] > rank[root_b]: + parent[root_b] = root_a + else: + parent[root_b] = root_a + rank[root_a] += 1 + + for y in range(height): + xs = np.flatnonzero(mask[y]) + if xs.size == 0: + previous_row = [] + continue + breaks = np.flatnonzero(np.diff(xs) > 1) + 1 + starts = np.concatenate([xs[:1], xs[breaks]]) + ends = np.concatenate([xs[breaks - 1], xs[-1:]]) + current_row: list[tuple[int, int, int]] = [] + prev_index = 0 + for start, end in zip(starts.tolist(), ends.tolist()): + label = make_label() + while prev_index < len(previous_row) and previous_row[prev_index][1] < start - 1: + prev_index += 1 + check_index = prev_index + while check_index < len(previous_row) and previous_row[check_index][0] <= end + 1: + union(label, previous_row[check_index][2]) + check_index += 1 + current_row.append((int(start), int(end), label)) + runs.append((y, int(start), int(end), label)) + previous_row = current_row + + aggregates: dict[int, list[int]] = {} + for y, start, end, label in runs: + root = find(label) + width = end - start + 1 + if root not in aggregates: + aggregates[root] = [start, y, end, y, width] + continue + aggregate = aggregates[root] + aggregate[0] = min(aggregate[0], start) + aggregate[1] = min(aggregate[1], y) + aggregate[2] = max(aggregate[2], end) + aggregate[3] = max(aggregate[3], y) + aggregate[4] += width + + return [ + SpriteRegion( + index=0, + left=left, + top=top, + right=right + 1, + bottom=bottom + 1, + foreground_pixels=count, + ) + for left, top, right, bottom, count in aggregates.values() + ] + + +def _pad_region(region: SpriteRegion, image_width: int, image_height: int, *, padding: int) -> SpriteRegion: + return SpriteRegion( + index=region.index, + left=max(0, region.left - padding), + top=max(0, region.top - padding), + right=min(image_width, region.right + padding), + bottom=min(image_height, region.bottom + padding), + foreground_pixels=region.foreground_pixels, + ) + + +def _region_aspect(region: SpriteRegion) -> float: + short_span = max(1, min(region.width, region.height)) + long_span = max(region.width, region.height) + return long_span / short_span + + +def _reading_order(regions: list[SpriteRegion]) -> list[SpriteRegion]: + if not regions: + return [] + median_height = float(np.median([region.height for region in regions])) + row_threshold = max(4.0, median_height * 0.5) + rows: list[list[SpriteRegion]] = [] + for region in sorted(regions, key=lambda item: (item.top + item.bottom) * 0.5): + center_y = (region.top + region.bottom) * 0.5 + for row in rows: + row_center = np.mean([(item.top + item.bottom) * 0.5 for item in row]) + if abs(center_y - row_center) <= row_threshold: + row.append(region) + break + else: + rows.append([region]) + + ordered: list[SpriteRegion] = [] + for row in rows: + ordered.extend(sorted(row, key=lambda item: item.left)) + return [ + SpriteRegion( + index=index, + left=region.left, + top=region.top, + right=region.right, + bottom=region.bottom, + foreground_pixels=region.foreground_pixels, + ) + for index, region in enumerate(ordered) + ] + + +def _pack_sprite_outputs(sprite_runs: list[SpriteRun], *, gutter: int = 1) -> np.ndarray: + rows = _group_sprite_runs_by_source_row(sprite_runs) + row_heights = [max(run.result.output_rgba.shape[0] for run in row) for row in rows] + row_widths = [sum(run.result.output_rgba.shape[1] for run in row) + gutter * max(0, len(row) - 1) for row in rows] + width = max(row_widths) + height = sum(row_heights) + gutter * max(0, len(rows) - 1) + output = np.zeros((height, width, 4), dtype=np.float32) + + y = 0 + for row, row_height in zip(rows, row_heights): + x = 0 + for run in sorted(row, key=lambda item: item.region.left): + sprite = run.result.output_rgba + output[y : y + sprite.shape[0], x : x + sprite.shape[1]] = sprite + x += sprite.shape[1] + gutter + y += row_height + gutter + return output + + +def _group_sprite_runs_by_source_row(sprite_runs: list[SpriteRun]) -> list[list[SpriteRun]]: + if not sprite_runs: + return [] + median_height = float(np.median([run.region.height for run in sprite_runs])) + row_threshold = max(4.0, median_height * 0.5) + rows: list[list[SpriteRun]] = [] + for run in sorted(sprite_runs, key=lambda item: (item.region.top + item.region.bottom) * 0.5): + center_y = (run.region.top + run.region.bottom) * 0.5 + for row in rows: + row_center = np.mean([(item.region.top + item.region.bottom) * 0.5 for item in row]) + if abs(center_y - row_center) <= row_threshold: + row.append(run) + break + else: + rows.append([run]) + return rows + + +def _spritesheet_summary( + *, + source_shape: tuple[int, ...], + sprite_count_request: int | None, + sprite_runs: list[SpriteRun], + output_shape: tuple[int, ...], +) -> dict[str, Any]: + return { + "mode": "spritesheet", + "source_width": int(source_shape[1]), + "source_height": int(source_shape[0]), + "output_width": int(output_shape[1]), + "output_height": int(output_shape[0]), + "sprite_count_request": sprite_count_request, + "sprite_count_detected": len(sprite_runs), + "sprites": [ + { + "index": run.region.index, + "source_box": [run.region.left, run.region.top, run.region.right, run.region.bottom], + "source_width": run.region.width, + "source_height": run.region.height, + "foreground_pixels": run.region.foreground_pixels, + "target_width": run.result.inference.target_width, + "target_height": run.result.inference.target_height, + "confidence": run.result.inference.confidence, + } + for run in sprite_runs + ], + } diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 02ed1ef..5bd4dce 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -31,3 +31,4 @@ {"ts": "2026-05-20T17:56:36+00:00", "type": "deployment", "status": "ok", "note": "Deployed Repixelizer hosted refresh-cookie support to Yggdrasil and verified repixelizer-gui.service active, public health/config checks passing, and installed access.py contains the Heimdall sessions/refresh call."} {"ts": "2026-05-24T10:01:54+00:00", "type": "implementation", "status": "ok", "note": "Added GameCult Patreon and Discord links to Repixelizer hosted/app headers using GameCult-site URLs; verified GUI tests and desktop/mobile header link boxes."} {"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."} diff --git a/state/map.yaml b/state/map.yaml index c79bb8f..8ff854b 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -46,6 +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 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. @@ -64,6 +65,7 @@ landed_baseline: - Hosted demo inference uses capped autocorr candidates and allows low-confidence preview rerank. - Diagnostic edge preview plus one explicit source-position solver with local search and lattice spring relaxation. - Compare mode, diagnostics writing, tuning, and GUI observer plumbing all working on the phase-field path. + - Spritesheet CLI mode detects separated foreground regions or selects the largest requested sprite count, then repixelizes each crop independently through `phase-field`. - `source_structure` reporting alongside `source_fidelity`. - Hosted landing page and Heimdall auth seam in the web layer. @@ -83,8 +85,8 @@ current_status: summary: > The repo has one live reconstruction engine, `phase-field`, wired through lattice inference, diagnostic edge preview, explicit local-search reconstruction, - cleanup, optional palette fitting, diagnostics, compare mode, tuning, and - GUI observer events. Automatic lattice inference uses autocorr candidates; + cleanup, optional palette fitting, diagnostics, compare mode, spritesheet + CLI mode, tuning, and GUI observer events. Automatic lattice inference uses autocorr candidates; low-confidence autocorr candidates can be preview-reranked. Hosted web access is auth-landed without smearing into the solver: `src/repixelizer/access.py` verifies Heimdall Ed25519 access tokens diff --git a/tests/test_cli.py b/tests/test_cli.py index 3d8f72b..25f2243 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -66,3 +66,45 @@ def test_gui_command_dispatches_to_gui_main(monkeypatch) -> None: exit_code = main(["gui", "--host", "127.0.0.1", "--port", "8123", "--reload"]) assert exit_code == 0 assert called == {"host": "127.0.0.1", "port": 8123, "reload": True} + + +def test_spritesheet_command_dispatches_to_spritesheet_runner(monkeypatch, tmp_path: Path) -> None: + called = {} + + def fake_run_spritesheet(input_path, output_path, **kwargs): + called["input_path"] = input_path + called["output_path"] = output_path + called["sprite_count"] = kwargs["sprite_count"] + called["target_width"] = kwargs["target_width"] + called["target_height"] = kwargs["target_height"] + called["enable_candidate_rerank"] = kwargs["enable_candidate_rerank"] + + monkeypatch.setattr("repixelizer.cli.run_spritesheet", fake_run_spritesheet) + input_path = tmp_path / "sheet.png" + output_path = tmp_path / "out.png" + + exit_code = main( + [ + "spritesheet", + str(input_path), + "--out", + str(output_path), + "--sprite-count", + "6", + "--target-width", + "32", + "--target-height", + "32", + "--skip-candidate-rerank", + ] + ) + + assert exit_code == 0 + assert called == { + "input_path": str(input_path), + "output_path": str(output_path), + "sprite_count": 6, + "target_width": 32, + "target_height": 32, + "enable_candidate_rerank": False, + } diff --git a/tests/test_spritesheet.py b/tests/test_spritesheet.py new file mode 100644 index 0000000..0e85450 --- /dev/null +++ b/tests/test_spritesheet.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +from repixelizer.io import save_rgba +from repixelizer.spritesheet import detect_sprite_regions, run_spritesheet +from repixelizer.types import CleanupArtifacts, InferenceResult, PhaseFieldSourceAnalysis, RunResult, SolverArtifacts + + +def _sheet_with_four_alpha_sprites() -> np.ndarray: + sheet = np.zeros((40, 50, 4), dtype=np.float32) + boxes = [ + (4, 5, 14, 15, (1.0, 0.0, 0.0)), + (30, 6, 42, 17, (0.0, 1.0, 0.0)), + (5, 25, 18, 36, (0.0, 0.0, 1.0)), + (31, 24, 44, 35, (1.0, 1.0, 0.0)), + ] + for left, top, right, bottom, color in boxes: + sheet[top:bottom, left:right, :3] = color + sheet[top:bottom, left:right, 3] = 1.0 + return sheet + + +def test_detect_sprite_regions_returns_reading_order_regions() -> None: + regions = detect_sprite_regions(_sheet_with_four_alpha_sprites()) + + assert len(regions) == 4 + assert [region.index for region in regions] == [0, 1, 2, 3] + assert regions[0].left < regions[1].left + assert regions[0].top < regions[2].top + + +def test_spritesheet_runs_pipeline_per_detected_region(tmp_path: Path, monkeypatch) -> None: + input_path = tmp_path / "sheet.png" + output_path = tmp_path / "out.png" + diagnostics_dir = tmp_path / "diagnostics" + save_rgba(input_path, _sheet_with_four_alpha_sprites()) + crop_shapes: list[tuple[int, int]] = [] + + def fake_run_pipeline_rgba(source, **kwargs): + crop_shapes.append(source.shape[:2]) + index = len(crop_shapes) + rgba = np.zeros((3, 4, 4), dtype=np.float32) + rgba[..., 0] = index / 4.0 + rgba[..., 3] = 1.0 + inference = InferenceResult(target_width=4, target_height=3, confidence=1.0) + solver = SolverArtifacts( + target_rgba=rgba, + uv_field=np.zeros((3, 4, 2), dtype=np.float32), + signal_strength=np.zeros((3, 4), 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((3, 4), dtype=np.float32)), + palette_result=None, + diagnostics={}, + ) + + monkeypatch.setattr("repixelizer.spritesheet.run_pipeline_rgba", fake_run_pipeline_rgba) + + result = run_spritesheet( + input_path, + output_path, + sprite_count=4, + diagnostics_dir=diagnostics_dir, + steps=0, + device="cpu", + ) + + assert output_path.exists() + assert (diagnostics_dir / "spritesheet.json").exists() + assert len(crop_shapes) == 4 + assert result.output_rgba.shape[:2] == (7, 9) + assert result.diagnostics["sprite_count_detected"] == 4 + + +def test_detect_sprite_regions_can_select_requested_largest_regions() -> None: + sheet = _sheet_with_four_alpha_sprites() + sheet[20, 24, :3] = 1.0 + sheet[20, 24, 3] = 1.0 + + regions = detect_sprite_regions(sheet, sprite_count=4) + + assert len(regions) == 4 + assert all(region.foreground_pixels > 1 for region in regions) + + +def test_auto_detect_ignores_wide_stray_components() -> None: + sheet = _sheet_with_four_alpha_sprites() + sheet[19:22, 2:40, :3] = 1.0 + sheet[19:22, 2:40, 3] = 1.0 + + regions = detect_sprite_regions(sheet) + + assert len(regions) == 4 + assert all(region.height > 5 for region in regions)