Spaces:
Sleeping
Sleeping
| """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) | |