Spaces:
Runtime error
Runtime error
| from __future__ import annotations | |
| from dataclasses import asdict | |
| from math import ceil, sqrt | |
| from pathlib import Path | |
| from typing import Sequence | |
| import hashlib | |
| import textwrap | |
| from .prompt_rewriter import RewriteResult, STYLE_LIBRARY | |
| from . import flux_backend | |
| def _require_pillow(): | |
| try: | |
| from PIL import Image, ImageDraw, ImageFont | |
| except Exception as exc: # pragma: no cover - only triggered in non-runtime environments | |
| raise RuntimeError( | |
| "Pillow is required for quilt rendering. Run scripts/bootstrap_venv.sh first or install requirements.txt." | |
| ) from exc | |
| return Image, ImageDraw, ImageFont | |
| def _digest(result: RewriteResult) -> bytes: | |
| payload = "|".join( | |
| [ | |
| result.memory_text, | |
| result.location_tag, | |
| result.style, | |
| result.caption, | |
| result.flux_prompt, | |
| result.selected_model_id, | |
| ] | |
| ) | |
| return hashlib.sha256(payload.encode("utf-8")).digest() | |
| def _rgb_from_digest(digest: bytes, offset: int, mix: float = 1.0) -> tuple[int, int, int]: | |
| r = int(digest[offset % len(digest)] * mix) % 256 | |
| g = int(digest[(offset + 1) % len(digest)] * mix) % 256 | |
| b = int(digest[(offset + 2) % len(digest)] * mix) % 256 | |
| return (r, g, b) | |
| def _palette(result: RewriteResult) -> dict[str, tuple[int, int, int]]: | |
| digest = _digest(result) | |
| base = _rgb_from_digest(digest, 0) | |
| accent = _rgb_from_digest(digest, 3, 1.3) | |
| soft = _rgb_from_digest(digest, 6, 0.8) | |
| border = _rgb_from_digest(digest, 9, 0.65) | |
| text = (246, 241, 229) | |
| return {"base": base, "accent": accent, "soft": soft, "border": border, "text": text} | |
| def _load_font(ImageFont, size: int): | |
| try: | |
| return ImageFont.truetype("DejaVuSans.ttf", size) | |
| except Exception: | |
| return ImageFont.load_default() | |
| def _draw_stitch_border(draw, size: int, color: tuple[int, int, int]) -> None: | |
| step = 16 | |
| for x in range(8, size - 8, step): | |
| draw.rectangle((x, 6, x + 8, 10), fill=color) | |
| draw.rectangle((x, size - 10, x + 8, size - 6), fill=color) | |
| for y in range(8, size - 8, step): | |
| draw.rectangle((6, y, 10, y + 8), fill=color) | |
| draw.rectangle((size - 10, y, size - 6, y + 8), fill=color) | |
| def _draw_motif(draw, result: RewriteResult, palette: dict[str, tuple[int, int, int]], size: int) -> None: | |
| Image, ImageDraw, ImageFont = _require_pillow() | |
| digest = _digest(result) | |
| motif = digest[0] % 4 | |
| cx = size // 2 | |
| cy = int(size * 0.46) | |
| if motif == 0: | |
| # House silhouette. | |
| roof = [(cx - 120, cy + 20), (cx, cy - 92), (cx + 120, cy + 20)] | |
| draw.polygon(roof, fill=palette["accent"]) | |
| draw.rectangle((cx - 92, cy + 20, cx + 92, cy + 132), fill=palette["soft"]) | |
| draw.rectangle((cx - 24, cy + 74, cx + 24, cy + 132), fill=palette["text"]) | |
| draw.rectangle((cx - 70, cy + 46, cx - 34, cy + 82), fill=palette["text"]) | |
| draw.rectangle((cx + 34, cy + 46, cx + 70, cy + 82), fill=palette["text"]) | |
| elif motif == 1: | |
| # Tree and bench. | |
| draw.line((cx, cy + 40, cx, cy + 128), fill=palette["accent"], width=14) | |
| for shift in (-70, -28, 26, 68): | |
| draw.ellipse((cx + shift - 42, cy - 60, cx + shift + 42, cy + 24), fill=palette["soft"]) | |
| draw.rectangle((cx - 92, cy + 92, cx + 92, cy + 114), fill=palette["border"]) | |
| draw.rectangle((cx - 70, cy + 70, cx - 52, cy + 114), fill=palette["border"]) | |
| draw.rectangle((cx + 52, cy + 70, cx + 70, cy + 114), fill=palette["border"]) | |
| elif motif == 2: | |
| # Window grid. | |
| draw.rounded_rectangle((cx - 120, cy - 86, cx + 120, cy + 90), radius=24, fill=palette["accent"]) | |
| draw.rectangle((cx - 102, cy - 68, cx + 102, cy + 72), outline=palette["text"], width=6) | |
| draw.line((cx, cy - 68, cx, cy + 72), fill=palette["text"], width=6) | |
| draw.line((cx - 102, cy + 2, cx + 102, cy + 2), fill=palette["text"], width=6) | |
| else: | |
| # Porch light and street. | |
| draw.ellipse((cx - 76, cy - 86, cx + 76, cy + 62), fill=palette["soft"]) | |
| draw.line((cx, cy - 98, cx, cy - 140), fill=palette["border"], width=8) | |
| draw.line((cx - 120, cy + 74, cx + 120, cy + 74), fill=palette["text"], width=10) | |
| draw.arc((cx - 100, cy + 24, cx + 100, cy + 148), start=180, end=360, fill=palette["accent"], width=10) | |
| def _draw_caption_band(image: "object", result: RewriteResult, palette: dict[str, tuple[int, int, int]]) -> None: | |
| Image, ImageDraw, ImageFont = _require_pillow() | |
| size = image.size[0] | |
| draw = ImageDraw.Draw(image) | |
| band_height = 112 | |
| band_top = size - band_height | |
| draw.rectangle((0, band_top, size, size), fill=(22, 21, 19, 210)) | |
| title_font = _load_font(ImageFont, 24) | |
| body_font = _load_font(ImageFont, 16) | |
| small_font = _load_font(ImageFont, 13) | |
| caption = textwrap.fill(result.caption, width=20) | |
| prompt_excerpt = textwrap.fill(result.flux_prompt, width=38) | |
| draw.text((18, band_top + 10), caption, fill=palette["text"], font=title_font) | |
| draw.text((18, band_top + 48), prompt_excerpt, fill=(233, 228, 218), font=body_font) | |
| draw.text((18, band_top + 89), f"{result.selected_model_id} · {result.prompt_source}", fill=(202, 196, 185), font=small_font) | |
| def _render_tile_flux(result: RewriteResult, size: int = 512) -> "object": | |
| Image, ImageDraw, ImageFont = _require_pillow() | |
| palette = _palette(result) | |
| base, meta = flux_backend.try_generate(result.flux_prompt, size=size) | |
| image = base.convert("RGBA").resize((size, size)) | |
| _draw_caption_band(image, result, palette) | |
| image.info["backend"] = str(meta.get("backend", "flux-diffusers")) | |
| if meta.get("adapter_name"): | |
| image.info["adapter_name"] = str(meta["adapter_name"]) | |
| if meta.get("model_id"): | |
| image.info["model_id"] = str(meta["model_id"]) | |
| if meta.get("generation_stats") is not None: | |
| image.info["generation_stats"] = meta["generation_stats"] | |
| return image | |
| def render_tile(result: RewriteResult, size: int = 512) -> "object": | |
| if not flux_backend.available(): | |
| raise RuntimeError( | |
| "P5_FLUX_MODEL_DIR is not set or does not point to a local FLUX checkpoint; " | |
| "deterministic tile fallback has been removed" | |
| ) | |
| return _render_tile_flux(result, size=size) | |
| def assemble_quilt(results: Sequence[RewriteResult], columns: int | None = None, tile_size: int = 256) -> "object": | |
| Image, ImageDraw, ImageFont = _require_pillow() | |
| cards = list(results) | |
| if not cards: | |
| raise ValueError("assemble_quilt requires at least one card") | |
| columns = columns or (3 if len(cards) >= 6 else 2 if len(cards) > 1 else 1) | |
| columns = max(1, min(columns, len(cards))) | |
| rows = ceil(len(cards) / columns) | |
| padding = 24 | |
| banner_height = 104 | |
| tile_span = tile_size + 112 | |
| width = padding * 2 + columns * tile_size + (columns - 1) * padding | |
| height = banner_height + padding + rows * tile_span + max(0, rows - 1) * padding | |
| image = Image.new("RGBA", (width, height), (244, 238, 228, 255)) | |
| draw = ImageDraw.Draw(image) | |
| header_font = _load_font(ImageFont, 30) | |
| body_font = _load_font(ImageFont, 16) | |
| draw.rounded_rectangle((16, 16, width - 16, banner_height - 12), radius=28, fill=(78, 62, 46, 255)) | |
| draw.text((36, 32), "Memory Quilt", fill=(250, 245, 236), font=header_font) | |
| subtitle = f"{len(cards)} stitched contribution(s) · local model renderer" | |
| draw.text((36, 68), subtitle, fill=(239, 226, 205), font=body_font) | |
| for index, result in enumerate(cards): | |
| row = index // columns | |
| col = index % columns | |
| x = padding + col * (tile_size + padding) | |
| y = banner_height + padding + row * (tile_span + padding) | |
| tile = render_tile(result, size=tile_size).convert("RGBA") | |
| image.paste(tile, (x, y), tile) | |
| return image | |
| def save_image(image: "object", path: str | Path) -> Path: | |
| path = Path(path) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| image.save(path, format="PNG") | |
| return path | |
| def card_summaries(results: Sequence[RewriteResult]) -> list[dict[str, object]]: | |
| summaries: list[dict[str, object]] = [] | |
| for index, result in enumerate(results, start=1): | |
| summary = asdict(result) | |
| summary["index"] = index | |
| summaries.append(summary) | |
| return summaries | |