Add spritesheet reconstruction mode

This commit is contained in:
Meta Vens 2026-05-30 23:53:04 +01:00
commit 68a7e0ab0d
10 changed files with 587 additions and 4 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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
],
}

View file

@ -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."}

View file

@ -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

View file

@ -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,
}

104
tests/test_spritesheet.py Normal file
View file

@ -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)