| """Rendering for SpriteBench. |
| |
| Turns parsed sprites into PNG images for human review and for the vision judge. |
| Pure Pillow: nearest-neighbor upscaling, a neutral checkerboard behind |
| transparent cells, failure placeholder tiles, and labeled contact sheets. No |
| font files are used anywhere; everything relies on Pillow's built-in default |
| font so the module has no external asset dependencies. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from pathlib import Path |
| from typing import TYPE_CHECKING |
|
|
| from PIL import Image, ImageDraw, ImageFont |
|
|
| if TYPE_CHECKING: |
| from validate import Sprite |
|
|
| |
| CHECKER_LIGHT = (200, 200, 200) |
| CHECKER_DARK = (160, 160, 160) |
|
|
| |
| _FAILURE_BG = (90, 20, 20) |
| _FAILURE_FG = (235, 235, 235) |
| _MISSING_BG = (110, 110, 110) |
| _MISSING_FG = (210, 210, 210) |
| _SHEET_BG = (245, 245, 245) |
| _SHEET_FG = (20, 20, 20) |
| _SHEET_GRID = (180, 180, 180) |
|
|
|
|
| def _default_font() -> ImageFont.ImageFont: |
| """Pillow's built-in bitmap font (no external font file required).""" |
| return ImageFont.load_default() |
|
|
|
|
| def _text_size( |
| draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont |
| ) -> tuple[int, int]: |
| """Return (width, height) of a single line of text for the given font.""" |
| left, top, right, bottom = draw.textbbox((0, 0), text, font=font) |
| return right - left, bottom - top |
|
|
|
|
| def render_sprite(sprite: "Sprite", scale: int = 16) -> Image.Image: |
| """Render a sprite at integer nearest-neighbor scale. |
| |
| Each sprite pixel becomes a ``scale`` x ``scale`` block. Opaque cells are |
| filled with their palette RGB; transparent cells (palette value ``None``) |
| reveal a neutral checkerboard whose squares are exactly ``scale`` px and are |
| aligned to the sprite pixel grid (so each transparent pixel shows a single |
| flat checker square). |
| |
| Returns an RGB image of size ``(width * scale, height * scale)``. |
| """ |
| if scale < 1: |
| raise ValueError("scale must be >= 1") |
|
|
| width = sprite.width |
| height = sprite.height |
| out_w = width * scale |
| out_h = height * scale |
|
|
| image = Image.new("RGB", (out_w, out_h)) |
| pixels = image.load() |
|
|
| palette = sprite.palette |
| rows = sprite.rows |
|
|
| for gy in range(height): |
| row = rows[gy] |
| base_y = gy * scale |
| for gx in range(width): |
| key = row[gx] |
| color = palette.get(key) |
| if color is None: |
| |
| square = CHECKER_LIGHT if (gx + gy) % 2 == 0 else CHECKER_DARK |
| else: |
| square = (int(color[0]), int(color[1]), int(color[2])) |
| base_x = gx * scale |
| for dy in range(scale): |
| py = base_y + dy |
| for dx in range(scale): |
| pixels[base_x + dx, py] = square |
|
|
| return image |
|
|
|
|
| def save_render(sprite: "Sprite", path: Path, scale: int = 16) -> None: |
| """Render ``sprite`` and write it as a PNG to ``path`` (creates parents).""" |
| path = Path(path) |
| path.parent.mkdir(parents=True, exist_ok=True) |
| image = render_sprite(sprite, scale=scale) |
| image.save(path, format="PNG") |
|
|
|
|
| def _wrap_text( |
| draw: ImageDraw.ImageDraw, |
| text: str, |
| font: ImageFont.ImageFont, |
| max_width: int, |
| ) -> list[str]: |
| """Greedily wrap ``text`` into lines no wider than ``max_width`` pixels. |
| |
| Words longer than ``max_width`` are split character by character so text |
| never overflows the tile horizontally. |
| """ |
| lines: list[str] = [] |
| for paragraph in text.splitlines() or [""]: |
| words = paragraph.split(" ") |
| current = "" |
| for word in words: |
| candidate = word if not current else current + " " + word |
| cand_w, _ = _text_size(draw, candidate, font) |
| if cand_w <= max_width or not current: |
| |
| if not current: |
| |
| token_w, _ = _text_size(draw, word, font) |
| if token_w > max_width and len(word) > 1: |
| for piece in _break_long_word(draw, word, font, max_width): |
| lines.append(piece) |
| current = "" |
| continue |
| current = candidate |
| else: |
| lines.append(current) |
| current = word |
| lines.append(current) |
| return lines |
|
|
|
|
| def _break_long_word( |
| draw: ImageDraw.ImageDraw, |
| word: str, |
| font: ImageFont.ImageFont, |
| max_width: int, |
| ) -> list[str]: |
| """Split a single overlong word into pixel-width-bounded chunks.""" |
| pieces: list[str] = [] |
| current = "" |
| for ch in word: |
| candidate = current + ch |
| cand_w, _ = _text_size(draw, candidate, font) |
| if cand_w <= max_width or not current: |
| current = candidate |
| else: |
| pieces.append(current) |
| current = ch |
| if current: |
| pieces.append(current) |
| return pieces |
|
|
|
|
| def failure_tile(reason: str, px: int = 512) -> Image.Image: |
| """A dark-red square tile with the wrapped failure ``reason`` text. |
| |
| Used in contact sheets in place of a render when a sample failed Tier 1. |
| Uses Pillow's default font; text is wrapped to fit the tile width and |
| vertically centered. Long reasons are truncated with an ellipsis marker. |
| """ |
| if px < 1: |
| raise ValueError("px must be >= 1") |
|
|
| image = Image.new("RGB", (px, px), _FAILURE_BG) |
| draw = ImageDraw.Draw(image) |
| font = _default_font() |
|
|
| margin = max(4, px // 24) |
| max_text_w = px - 2 * margin |
| text = reason if reason else "unknown failure" |
|
|
| lines = _wrap_text(draw, text, font, max_text_w) |
|
|
| |
| _, line_h = _text_size(draw, "Ag", font) |
| line_h = max(line_h, 1) |
| spacing = max(2, line_h // 4) |
| step = line_h + spacing |
|
|
| max_lines = max(1, (px - 2 * margin) // step) |
| if len(lines) > max_lines: |
| lines = lines[:max_lines] |
| if lines: |
| lines[-1] = _ellipsize(draw, lines[-1], font, max_text_w) |
|
|
| total_h = len(lines) * step - spacing |
| y = max(margin, (px - total_h) // 2) |
| for line in lines: |
| line_w, _ = _text_size(draw, line, font) |
| x = max(margin, (px - line_w) // 2) |
| draw.text((x, y), line, fill=_FAILURE_FG, font=font) |
| y += step |
|
|
| return image |
|
|
|
|
| def _ellipsize( |
| draw: ImageDraw.ImageDraw, |
| line: str, |
| font: ImageFont.ImageFont, |
| max_width: int, |
| ) -> str: |
| """Trim ``line`` so that ``line + '...'`` fits within ``max_width`` pixels.""" |
| suffix = "..." |
| suffix_w, _ = _text_size(draw, suffix, font) |
| if suffix_w >= max_width: |
| return suffix |
| trimmed = line |
| while trimmed: |
| cand_w, _ = _text_size(draw, trimmed + suffix, font) |
| if cand_w <= max_width: |
| return trimmed + suffix |
| trimmed = trimmed[:-1] |
| return suffix |
|
|
|
|
| def _missing_tile(px: int) -> Image.Image: |
| """A gray placeholder tile for an absent (None) contact-sheet cell.""" |
| image = Image.new("RGB", (px, px), _MISSING_BG) |
| draw = ImageDraw.Draw(image) |
| font = _default_font() |
| label = "missing" |
| label_w, label_h = _text_size(draw, label, font) |
| draw.text( |
| ((px - label_w) // 2, (px - label_h) // 2), |
| label, |
| fill=_MISSING_FG, |
| font=font, |
| ) |
| return image |
|
|
|
|
| def _fit_tile(image: Image.Image, tile_px: int) -> Image.Image: |
| """Resize ``image`` to a ``tile_px`` square using NEAREST (crisp pixels).""" |
| rgb = image if image.mode == "RGB" else image.convert("RGB") |
| if rgb.size == (tile_px, tile_px): |
| return rgb |
| return rgb.resize((tile_px, tile_px), resample=Image.Resampling.NEAREST) |
|
|
|
|
| def contact_sheet( |
| cells: list[list[Image.Image | None]], |
| row_labels: list[str], |
| col_labels: list[str], |
| out_path: Path, |
| tile_px: int = 256, |
| ) -> None: |
| """Compose a labeled grid of tiles and write it as a PNG. |
| |
| ``cells`` is row-major: ``cells[r][c]`` is the image for row ``r``, column |
| ``c`` (or ``None`` to draw a gray "missing" tile). Each image is resized to |
| a ``tile_px`` square with NEAREST resampling so pixel art stays crisp. |
| Column labels run along the top margin; row labels down the left margin. |
| Labels use Pillow's default font. |
| |
| Robust to ragged ``cells`` (short rows are padded with missing tiles) and to |
| label lists that are shorter or longer than the grid. |
| """ |
| if tile_px < 1: |
| raise ValueError("tile_px must be >= 1") |
|
|
| n_rows = max(len(cells), len(row_labels)) |
| n_cols = max( |
| (len(row) for row in cells), |
| default=0, |
| ) |
| n_cols = max(n_cols, len(col_labels)) |
| n_rows = max(n_rows, 1) |
| n_cols = max(n_cols, 1) |
|
|
| |
| font = _default_font() |
| scratch = Image.new("RGB", (1, 1)) |
| sdraw = ImageDraw.Draw(scratch) |
| _, glyph_h = _text_size(sdraw, "Ag", font) |
| glyph_h = max(glyph_h, 1) |
|
|
| pad = max(4, tile_px // 32) |
|
|
| |
| longest_row_label_w = 0 |
| for r in range(n_rows): |
| label = row_labels[r] if r < len(row_labels) else "" |
| if label: |
| lw, _ = _text_size(sdraw, label, font) |
| longest_row_label_w = max(longest_row_label_w, lw) |
| left_margin = longest_row_label_w + 2 * pad if longest_row_label_w else pad |
| |
| top_margin = glyph_h + 2 * pad |
|
|
| sheet_w = left_margin + n_cols * tile_px |
| sheet_h = top_margin + n_rows * tile_px |
|
|
| sheet = Image.new("RGB", (sheet_w, sheet_h), _SHEET_BG) |
| draw = ImageDraw.Draw(sheet) |
|
|
| |
| for c in range(n_cols): |
| label = col_labels[c] if c < len(col_labels) else "" |
| if not label: |
| continue |
| fitted = _ellipsize_for(draw, label, font, tile_px - 2 * pad) |
| lw, lh = _text_size(draw, fitted, font) |
| cx = left_margin + c * tile_px + (tile_px - lw) // 2 |
| cy = max(pad, (top_margin - lh) // 2) |
| draw.text((cx, cy), fitted, fill=_SHEET_FG, font=font) |
|
|
| |
| for r in range(n_rows): |
| label = row_labels[r] if r < len(row_labels) else "" |
| if not label: |
| continue |
| fitted = _ellipsize_for(draw, label, font, max(left_margin - 2 * pad, 1)) |
| lw, lh = _text_size(draw, fitted, font) |
| rx = max(pad, (left_margin - lw) // 2) |
| ry = top_margin + r * tile_px + (tile_px - lh) // 2 |
| draw.text((rx, ry), fitted, fill=_SHEET_FG, font=font) |
|
|
| |
| for r in range(n_rows): |
| row = cells[r] if r < len(cells) else [] |
| for c in range(n_cols): |
| cell = row[c] if c < len(row) else None |
| tile = _missing_tile(tile_px) if cell is None else _fit_tile(cell, tile_px) |
| x = left_margin + c * tile_px |
| y = top_margin + r * tile_px |
| sheet.paste(tile, (x, y)) |
|
|
| |
| for c in range(n_cols + 1): |
| x = left_margin + c * tile_px |
| draw.line([(x, top_margin), (x, sheet_h - 1)], fill=_SHEET_GRID, width=1) |
| for r in range(n_rows + 1): |
| y = top_margin + r * tile_px |
| draw.line([(left_margin, y), (sheet_w - 1, y)], fill=_SHEET_GRID, width=1) |
|
|
| out_path = Path(out_path) |
| out_path.parent.mkdir(parents=True, exist_ok=True) |
| sheet.save(out_path, format="PNG") |
|
|
|
|
| def _ellipsize_for( |
| draw: ImageDraw.ImageDraw, |
| label: str, |
| font: ImageFont.ImageFont, |
| max_width: int, |
| ) -> str: |
| """Return ``label`` unchanged if it fits, else an ellipsized version.""" |
| lw, _ = _text_size(draw, label, font) |
| if lw <= max_width: |
| return label |
| return _ellipsize(draw, label, font, max_width) |
|
|