Add spritesheet reconstruction mode
This commit is contained in:
parent
11e997814c
commit
68a7e0ab0d
10 changed files with 587 additions and 4 deletions
14
README.md
14
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
358
src/repixelizer/spritesheet.py
Normal file
358
src/repixelizer/spritesheet.py
Normal 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
|
||||
],
|
||||
}
|
||||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
104
tests/test_spritesheet.py
Normal 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)
|
||||
Loading…
Reference in a new issue