"""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: # pragma: no cover - import only for type checking from validate import Sprite # Neutral checkerboard colors shown behind transparent sprite cells. CHECKER_LIGHT = (200, 200, 200) CHECKER_DARK = (160, 160, 160) # Cosmetic constants for the auxiliary tiles / sheets. _FAILURE_BG = (90, 20, 20) # dark red _FAILURE_FG = (235, 235, 235) # near-white text _MISSING_BG = (110, 110, 110) # gray "missing" tile _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: # Transparent: one flat checker square per sprite pixel. 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: # Fits, or it is the first token on the line. if not current: # A lone token may still be too wide; break it by char. 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) # Determine line height from a representative glyph. _, 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) # Probe font metrics on a scratch image. 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) # Left margin: wide enough for the longest row label. 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: room for a column label line. 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) # Column labels across the top margin, centered over each column. 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) # Row labels down the left margin, vertically centered per row. 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) # Tiles. 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)) # Light grid lines separating tiles. 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)