pixellock / render.py
solarkyle's picture
Deploy PixelLock GPU Space (llama.cpp + GGUF + GBNF + custom UI)
eb90246 verified
Raw
History Blame Contribute Delete
12.2 kB
"""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)