diff --git a/README.md b/README.md index 7aac044..20cff8b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Instead of pretending this is a resize problem, Repixelizer treats it as lattice Repixelizer was built to rescue fake pixel art, but it can also be used to generate pixel art directly from non-pixel source art when the shapes are clean and the local structure is doing something useful. -Top row: glossy non-pixel badge art repixelized at `128x128`. Bottom row: an AI fake-pixel interpretation of the same badge cleaned up with automatic lattice inference, which landed on `122x122` for this image. +Rows show the source art, a plain Lanczos downscale to the same target size, and the repixelized result. The bottom row also carries a sword-guard picture-in-picture, because that tiny region is one of the more honest tests of whether the tool is preserving local cell adjacency instead of just getting the vibes approximately correct. ![Repixelizer example comparison](docs/readme-assets/badge-example-sheet.png) @@ -118,6 +118,16 @@ Run the focused test suite with: .venv\Scripts\python -m pytest -q ``` +## Regenerating README Assets + +The README comparison sheet is generated from repo-tracked fixtures, not from random artifacts left lying around: + +```powershell +.venv\Scripts\python scripts\generate_readme_previews.py --vector-input tests\fixtures\real\ai-badge-vector.png --ai-input tests\fixtures\real\ai-badge-cleaned.png --out-sheet docs\readme-assets\badge-example-sheet.png --out-guard-crop docs\readme-assets\guard-right-crop-comparison.png --scratch-dir artifacts\readme-build --device cpu +``` + +That regenerates the README sheet, the standalone sword-guard closeup strip, and scratch outputs under `artifacts/readme-build/` so you can inspect the actual low-res results used to build the docs sample. + ## Diagnostic Closeups For docs or regression notes, you can generate reproducible source/output closeups from output-grid cell coordinates with: diff --git a/docs/readme-assets/badge-example-sheet.png b/docs/readme-assets/badge-example-sheet.png index 1ecaacd..55573c2 100644 Binary files a/docs/readme-assets/badge-example-sheet.png and b/docs/readme-assets/badge-example-sheet.png differ diff --git a/docs/readme-assets/guard-right-crop-comparison.png b/docs/readme-assets/guard-right-crop-comparison.png new file mode 100644 index 0000000..ce8491a Binary files /dev/null and b/docs/readme-assets/guard-right-crop-comparison.png differ diff --git a/scripts/generate_readme_previews.py b/scripts/generate_readme_previews.py new file mode 100644 index 0000000..bfa2d7c --- /dev/null +++ b/scripts/generate_readme_previews.py @@ -0,0 +1,386 @@ +from __future__ import annotations + +import argparse +import json +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw + +from repixelizer.baselines import lanczos_resize_baseline +from repixelizer.io import load_rgba, nearest_resize, save_rgba +from repixelizer.pipeline import run_pipeline + + +@dataclass(slots=True) +class RenderedPanel: + image: Image.Image + content_box: tuple[int, int, int, int] + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Regenerate the README comparison sheet and guard closeup from repo fixtures.") + parser.add_argument( + "--vector-input", + default="tests/fixtures/real/ai-badge-vector.png", + help="High-resolution non-pixel badge source.", + ) + parser.add_argument( + "--ai-input", + default="tests/fixtures/real/ai-badge-cleaned.png", + help="Cleaned AI fake-pixel badge source.", + ) + parser.add_argument( + "--out-sheet", + default="docs/readme-assets/badge-example-sheet.png", + help="Output path for the full README comparison sheet.", + ) + parser.add_argument( + "--out-guard-crop", + default="docs/readme-assets/guard-right-crop-comparison.png", + help="Output path for the standalone sword-guard closeup strip.", + ) + parser.add_argument( + "--scratch-dir", + default="artifacts/readme-build", + help="Directory for intermediate low-res outputs and diagnostics.", + ) + parser.add_argument( + "--vector-target-size", + type=int, + default=128, + help="Target size for the non-pixel source repixelization run.", + ) + parser.add_argument( + "--guard-cell-bbox", + nargs=4, + type=int, + default=(73, 20, 107, 44), + metavar=("X0", "Y0", "X1", "Y1"), + help="Sword-guard crop bounds in output-grid cell coordinates for the AI row.", + ) + parser.add_argument("--steps", type=int, default=48, help="Optimizer step budget for both runs.") + parser.add_argument("--device", default="cpu", choices=("auto", "cpu", "cuda"), help="Torch device for generation.") + return parser.parse_args() + + +def _to_image(rgba: np.ndarray) -> Image.Image: + rgba8 = np.clip(np.rint(rgba * 255.0), 0, 255).astype(np.uint8) + return Image.fromarray(rgba8, mode="RGBA") + + +def _checkerboard(width: int, height: int, tile: int = 12) -> Image.Image: + image = Image.new("RGBA", (width, height), (236, 236, 236, 255)) + draw = ImageDraw.Draw(image) + accent = (218, 218, 218, 255) + for y in range(0, height, tile): + for x in range(0, width, tile): + if ((x // tile) + (y // tile)) % 2: + draw.rectangle((x, y, min(width, x + tile), min(height, y + tile)), fill=accent) + return image + + +def _fit_rgba( + rgba: np.ndarray, + *, + target_width: int, + target_height: int, + pixelated: bool, +) -> tuple[Image.Image, tuple[int, int, int, int]]: + image = _to_image(rgba) + scale = min(target_width / max(1, image.width), target_height / max(1, image.height)) + resized_width = max(1, int(round(image.width * scale))) + resized_height = max(1, int(round(image.height * scale))) + resample = Image.Resampling.NEAREST if pixelated else Image.Resampling.LANCZOS + resized = image.resize((resized_width, resized_height), resample=resample) + offset_x = (target_width - resized_width) // 2 + offset_y = (target_height - resized_height) // 2 + return resized, (offset_x, offset_y, resized_width, resized_height) + + +def _render_panel( + label: str, + *, + rgba: np.ndarray, + pixelated: bool, + panel_width: int, + panel_height: int, + subtitle: str | None = None, +) -> RenderedPanel: + header_height = 38 if subtitle else 28 + gutter = 10 + canvas = Image.new("RGBA", (panel_width, panel_height), (18, 18, 20, 255)) + checker = _checkerboard(panel_width - gutter * 2, panel_height - header_height - gutter * 2) + canvas.alpha_composite(checker, (gutter, header_height + gutter)) + + fitted, (inner_x, inner_y, inner_w, inner_h) = _fit_rgba( + rgba, + target_width=checker.width, + target_height=checker.height, + pixelated=pixelated, + ) + content_x = gutter + inner_x + content_y = header_height + gutter + inner_y + canvas.alpha_composite(fitted, (content_x, content_y)) + + draw = ImageDraw.Draw(canvas) + draw.text((gutter, 6), label, fill=(255, 255, 255, 255)) + if subtitle: + draw.text((gutter, 22), subtitle, fill=(200, 200, 200, 255)) + draw.rounded_rectangle((0, 0, panel_width - 1, panel_height - 1), radius=10, outline=(72, 72, 80, 255), width=1) + return RenderedPanel(canvas, (content_x, content_y, inner_w, inner_h)) + + +def _draw_source_bbox(render: RenderedPanel, source_bbox: tuple[int, int, int, int], *, source_width: int, source_height: int) -> None: + x0, y0, x1, y1 = source_bbox + inner_x, inner_y, inner_w, inner_h = render.content_box + left = inner_x + int(round(inner_w * (x0 / max(1, source_width)))) + right = inner_x + int(round(inner_w * (x1 / max(1, source_width)))) + top = inner_y + int(round(inner_h * (y0 / max(1, source_height)))) + bottom = inner_y + int(round(inner_h * (y1 / max(1, source_height)))) + draw = ImageDraw.Draw(render.image) + for delta, color in ((0, (255, 245, 120, 255)), (1, (20, 20, 20, 255))): + draw.rectangle((left - delta, top - delta, right + delta, bottom + delta), outline=color, width=1) + + +def _build_guard_strip( + source_crop: np.ndarray, + lanczos_crop: np.ndarray, + repixelized_crop: np.ndarray, + *, + include_lanczos: bool = True, + panel_width: int = 220, + panel_height: int = 170, + title: str = "Sword guard close-up", +) -> Image.Image: + panels = [ + _render_panel("Source", rgba=source_crop, pixelated=True, panel_width=panel_width, panel_height=panel_height), + _render_panel("Repixelized", rgba=repixelized_crop, pixelated=True, panel_width=panel_width, panel_height=panel_height), + ] + if include_lanczos: + panels.insert( + 1, + _render_panel("Lanczos", rgba=lanczos_crop, pixelated=True, panel_width=panel_width, panel_height=panel_height), + ) + title_height = 24 + gap = 10 + width = sum(panel.image.width for panel in panels) + gap * (len(panels) + 1) + height = title_height + max(panel.image.height for panel in panels) + gap * 2 + canvas = Image.new("RGBA", (width, height), (14, 14, 16, 242)) + draw = ImageDraw.Draw(canvas) + draw.rounded_rectangle((0, 0, width - 1, height - 1), radius=12, fill=(14, 14, 16, 242), outline=(92, 92, 104, 255), width=1) + draw.text((gap, 4), title, fill=(255, 255, 255, 255)) + x = gap + for panel in panels: + canvas.alpha_composite(panel.image, (x, title_height + gap)) + x += panel.image.width + gap + return canvas + + +def _cell_bbox_to_source_bbox( + cell_bbox: tuple[int, int, int, int], + *, + source_width: int, + source_height: int, + target_width: int, + target_height: int, +) -> tuple[int, int, int, int]: + x0, y0, x1, y1 = cell_bbox + return ( + int(np.floor(x0 * source_width / max(1, target_width))), + int(np.floor(y0 * source_height / max(1, target_height))), + int(np.ceil(x1 * source_width / max(1, target_width))), + int(np.ceil(y1 * source_height / max(1, target_height))), + ) + + +def _crop_rgba(rgba: np.ndarray, bbox: tuple[int, int, int, int]) -> np.ndarray: + x0, y0, x1, y1 = bbox + return rgba[y0:y1, x0:x1] + + +def _save_summary(path: Path, payload: dict[str, object]) -> None: + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + +def main() -> None: + args = _parse_args() + scratch_dir = Path(args.scratch_dir) + scratch_dir.mkdir(parents=True, exist_ok=True) + + vector_input = Path(args.vector_input) + ai_input = Path(args.ai_input) + out_sheet = Path(args.out_sheet) + out_guard_crop = Path(args.out_guard_crop) + out_sheet.parent.mkdir(parents=True, exist_ok=True) + out_guard_crop.parent.mkdir(parents=True, exist_ok=True) + + vector_result = run_pipeline( + vector_input, + scratch_dir / "badge-vector-repixelized.png", + target_size=args.vector_target_size, + diagnostics_dir=scratch_dir / "vector-diag", + steps=args.steps, + device=args.device, + ) + ai_result = run_pipeline( + ai_input, + scratch_dir / "badge-ai-repixelized.png", + diagnostics_dir=scratch_dir / "ai-diag", + steps=args.steps, + device=args.device, + ) + + vector_source = load_rgba(vector_input) + ai_source = load_rgba(ai_input) + vector_lanczos = lanczos_resize_baseline(vector_source, width=args.vector_target_size, height=args.vector_target_size) + ai_lanczos = lanczos_resize_baseline( + ai_source, + width=ai_result.inference.target_width, + height=ai_result.inference.target_height, + ) + + save_rgba(scratch_dir / "badge-vector-lanczos.png", vector_lanczos) + save_rgba(scratch_dir / "badge-ai-lanczos.png", ai_lanczos) + + panel_width = 320 + panel_height = 360 + row_gap = 18 + col_gap = 18 + margin = 24 + row_title_height = 22 + + vector_panels = [ + _render_panel( + "Source", + rgba=vector_source, + pixelated=False, + panel_width=panel_width, + panel_height=panel_height, + subtitle="Glossy badge art", + ), + _render_panel( + "Lanczos", + rgba=vector_lanczos, + pixelated=True, + panel_width=panel_width, + panel_height=panel_height, + subtitle=f"{args.vector_target_size}x{args.vector_target_size}", + ), + _render_panel( + "Repixelized", + rgba=vector_result.output_rgba, + pixelated=True, + panel_width=panel_width, + panel_height=panel_height, + subtitle=f"{vector_result.inference.target_width}x{vector_result.inference.target_height}", + ), + ] + ai_panels = [ + _render_panel( + "Source", + rgba=ai_source, + pixelated=False, + panel_width=panel_width, + panel_height=panel_height, + subtitle="Cleaned AI fake pixel art", + ), + _render_panel( + "Lanczos", + rgba=ai_lanczos, + pixelated=True, + panel_width=panel_width, + panel_height=panel_height, + subtitle=f"{ai_result.inference.target_width}x{ai_result.inference.target_height}", + ), + _render_panel( + "Repixelized", + rgba=ai_result.output_rgba, + pixelated=True, + panel_width=panel_width, + panel_height=panel_height, + subtitle=f"{ai_result.inference.target_width}x{ai_result.inference.target_height} auto", + ), + ] + + guard_bbox = _cell_bbox_to_source_bbox( + tuple(args.guard_cell_bbox), + source_width=ai_source.shape[1], + source_height=ai_source.shape[0], + target_width=ai_result.inference.target_width, + target_height=ai_result.inference.target_height, + ) + ai_lanczos_preview = nearest_resize(ai_lanczos, width=ai_source.shape[1], height=ai_source.shape[0]) + ai_repixelized_preview = nearest_resize(ai_result.output_rgba, width=ai_source.shape[1], height=ai_source.shape[0]) + for panel in ai_panels: + _draw_source_bbox( + panel, + guard_bbox, + source_width=ai_source.shape[1], + source_height=ai_source.shape[0], + ) + + guard_strip = _build_guard_strip( + _crop_rgba(ai_source, guard_bbox), + _crop_rgba(ai_lanczos_preview, guard_bbox), + _crop_rgba(ai_repixelized_preview, guard_bbox), + ) + guard_strip.save(out_guard_crop) + guard_inset = _build_guard_strip( + _crop_rgba(ai_source, guard_bbox), + _crop_rgba(ai_lanczos_preview, guard_bbox), + _crop_rgba(ai_repixelized_preview, guard_bbox), + include_lanczos=False, + panel_width=190, + panel_height=142, + title="Sword guard", + ) + + width = margin * 2 + panel_width * 3 + col_gap * 2 + height = margin * 2 + row_title_height * 2 + panel_height * 2 + row_gap + canvas = Image.new("RGBA", (width, height), (11, 11, 13, 255)) + draw = ImageDraw.Draw(canvas) + draw.text((margin, margin - 2), "Non-pixel source -> 128x128", fill=(255, 255, 255, 255)) + draw.text((margin, margin + row_title_height + panel_height + row_gap - 2), "AI fake pixel art -> auto lattice", fill=(255, 255, 255, 255)) + + row1_y = margin + row_title_height + row2_y = row1_y + panel_height + row_gap + row_title_height + x = margin + for panel in vector_panels: + canvas.alpha_composite(panel.image, (x, row1_y)) + x += panel_width + col_gap + x = margin + for panel in ai_panels: + canvas.alpha_composite(panel.image, (x, row2_y)) + x += panel_width + col_gap + + inset_x = width - guard_inset.width - margin + inset_y = row2_y + panel_height - guard_inset.height - 18 + canvas.alpha_composite(guard_inset, (inset_x, inset_y)) + + out_sheet.parent.mkdir(parents=True, exist_ok=True) + canvas.save(out_sheet) + + _save_summary( + scratch_dir / "readme-preview-summary.json", + { + "vector_input": str(vector_input), + "ai_input": str(ai_input), + "vector_target_size": args.vector_target_size, + "ai_inferred_width": ai_result.inference.target_width, + "ai_inferred_height": ai_result.inference.target_height, + "guard_cell_bbox": list(args.guard_cell_bbox), + "guard_source_bbox": list(guard_bbox), + "out_sheet": str(out_sheet), + "out_guard_crop": str(out_guard_crop), + "scratch_dir": str(scratch_dir), + }, + ) + + print(str(out_sheet)) + print(str(out_guard_crop)) + + +if __name__ == "__main__": + main() diff --git a/src/repixelizer/baselines.py b/src/repixelizer/baselines.py index a26f67a..85bbc74 100644 --- a/src/repixelizer/baselines.py +++ b/src/repixelizer/baselines.py @@ -2,7 +2,7 @@ from __future__ import annotations import numpy as np -from .io import box_resize +from .io import box_resize, lanczos_resize from .palette import derive_palette @@ -10,6 +10,10 @@ def naive_resize_baseline(rgba: np.ndarray, width: int, height: int) -> np.ndarr return box_resize(rgba, width=width, height=height) +def lanczos_resize_baseline(rgba: np.ndarray, width: int, height: int) -> np.ndarray: + return lanczos_resize(rgba, width=width, height=height) + + def _nearest_palette_color(pixel: np.ndarray, palette: np.ndarray) -> np.ndarray: distances = np.sum((palette - pixel[None, :]) ** 2, axis=1) return palette[int(np.argmin(distances))] diff --git a/src/repixelizer/compare.py b/src/repixelizer/compare.py index d9bcc34..8d61221 100644 --- a/src/repixelizer/compare.py +++ b/src/repixelizer/compare.py @@ -6,7 +6,7 @@ from typing import Any import numpy as np from PIL import Image, ImageDraw -from .baselines import error_diffusion_baseline, naive_resize_baseline +from .baselines import error_diffusion_baseline, lanczos_resize_baseline, naive_resize_baseline from .diagnostics import write_compare_csv, write_json from .io import nearest_resize, save_rgba from .metrics import ( @@ -30,6 +30,7 @@ def _to_uint8(rgba: np.ndarray) -> np.ndarray: def _contact_sheet( source: np.ndarray, optimized: np.ndarray, + lanczos: np.ndarray, naive: np.ndarray, diffusion: np.ndarray, path: str | Path, @@ -38,6 +39,7 @@ def _contact_sheet( panels = [ ("Source", source), ("Optimized", nearest_resize(optimized, width=width, height=height)), + ("Lanczos", nearest_resize(lanczos, width=width, height=height)), ("Naive", nearest_resize(naive, width=width, height=height)), ("Diffusion", nearest_resize(diffusion, width=width, height=height)), ] @@ -79,6 +81,7 @@ def run_compare( ) source = result.source_rgba palette = load_palette(palette_path) if palette_path else None + lanczos = lanczos_resize_baseline(source, width=result.inference.target_width, height=result.inference.target_height) naive = naive_resize_baseline(source, width=result.inference.target_width, height=result.inference.target_height) diffusion = error_diffusion_baseline( source, @@ -86,10 +89,11 @@ def run_compare( height=result.inference.target_height, palette=palette, ) - _contact_sheet(source, result.output_rgba, naive, diffusion, diagnostics_path / "compare-sheet.png") + _contact_sheet(source, result.output_rgba, lanczos, naive, diffusion, diagnostics_path / "compare-sheet.png") rows = [] for name, image in ( ("optimized", result.output_rgba), + ("lanczos", lanczos), ("naive", naive), ("diffusion", diffusion), ): diff --git a/src/repixelizer/io.py b/src/repixelizer/io.py index 19752d8..c0a2401 100644 --- a/src/repixelizer/io.py +++ b/src/repixelizer/io.py @@ -45,3 +45,9 @@ def bilinear_resize(rgba: np.ndarray, width: int, height: int) -> np.ndarray: image = Image.fromarray(np.clip(np.rint(rgba * 255.0), 0, 255).astype(np.uint8), mode="RGBA") resized = image.resize((width, height), resample=Image.Resampling.BILINEAR) return np.asarray(resized, dtype=np.float32) / 255.0 + + +def lanczos_resize(rgba: np.ndarray, width: int, height: int) -> np.ndarray: + image = Image.fromarray(np.clip(np.rint(rgba * 255.0), 0, 255).astype(np.uint8), mode="RGBA") + resized = image.resize((width, height), resample=Image.Resampling.LANCZOS) + return np.asarray(resized, dtype=np.float32) / 255.0 diff --git a/tests/fixtures/real/ai-badge-vector.png b/tests/fixtures/real/ai-badge-vector.png new file mode 100644 index 0000000..06d23d0 Binary files /dev/null and b/tests/fixtures/real/ai-badge-vector.png differ diff --git a/tests/test_compare.py b/tests/test_compare.py index a32414f..6d4d4b6 100644 --- a/tests/test_compare.py +++ b/tests/test_compare.py @@ -19,4 +19,4 @@ def test_compare_writes_comparison_artifacts(tmp_path: Path) -> None: assert output_path.exists() assert (diagnostics_dir / "compare-sheet.png").exists() assert (diagnostics_dir / "compare.csv").exists() - assert len(payload["rows"]) == 3 + assert len(payload["rows"]) == 4