"""Utility helpers for image conversion, resolution validation, and formatting.""" from __future__ import annotations import json from typing import Any import numpy as np from PIL import Image def validate_resolution(width: int, height: int) -> tuple[int, int]: """Clamp and align the resolution to multiples of 64 within the SD range. Stable Diffusion models expect spatial dimensions that are multiples of 64. The allowed range is clamped to [256, 768] to avoid excessive memory use. Args: width: Requested width in pixels. height: Requested height in pixels. Returns: A (width, height) tuple aligned to the valid grid. """ width = (max(256, min(width, 768)) // 64) * 64 height = (max(256, min(height, 768)) // 64) * 64 return width, height def to_pil(image: Any) -> Image.Image: """Convert a numpy array to a PIL image, or return the existing PIL image. Supports: - uint8 arrays in shape (H, W) or (H, W, C) - float arrays assumed to be normalized in [0, 1] - PIL.Image is returned unchanged Args: image: Input image data, either PIL.Image or numpy.ndarray. Returns: A PIL.Image instance. Raises: TypeError: If the input type is unsupported. """ if isinstance(image, Image.Image): return image if isinstance(image, np.ndarray): arr = image # Normalize floats to uint8 safely if np.issubdtype(arr.dtype, np.floating): # Clip first to avoid wraparound arr = np.clip(arr, 0.0, 1.0) arr = (arr * 255.0).astype("uint8") elif arr.dtype != np.uint8: arr = arr.astype("uint8") # Grayscale → RGB if arr.ndim == 2: arr = np.stack([arr] * 3, axis=-1) # Drop alpha channel if present if arr.ndim == 3 and arr.shape[2] == 4: arr = arr[..., :3] return Image.fromarray(arr) raise TypeError( f"Expected PIL.Image or numpy.ndarray for 'image', got {type(image).__name__!r}" ) def pretty_json(data: Any) -> str: """Return a pretty-printed JSON string representation of data. Args: data: Any JSON-serializable object. Returns: A formatted JSON string. If serialization fails, a best-effort string representation is returned. """ try: return json.dumps(data, ensure_ascii=False, indent=2) except Exception: return str(data) def short_prompt(text: str | None, max_len: int = 50) -> str: """Return a compact single-line prompt suitable for labels. Removes newlines and truncates with an ellipsis if longer than max_len. Args: text: The full text prompt. max_len: Maximum number of characters including ellipsis. Returns: A short display string. """ if not text: return "" text = text.replace("\n", " ") if len(text) <= max_len: return text # Reserve 1 char for ellipsis return text[: max_len - 1] + "…"