import numpy as np import os from PIL import Image import cv2 from typing import Dict, Tuple from logic.validation import ValidationError, ensure_file_exists _TILE_SOURCE_CACHE: Dict[str, list] = {} _TILE_RESIZE_CACHE: Dict[Tuple[str, int], Tuple[np.ndarray, np.ndarray]] = {} def calculate_average_color(image: np.ndarray) -> np.ndarray: """Calculate the average BGR color of an image. Args: image (np.ndarray): Tile image in BGR format. Returns: np.ndarray: Average color ``[b, g, r]``. """ return np.mean(image.reshape(-1, 3), axis=0) def color_distance(color1: np.ndarray, color2: np.ndarray) -> float: """Return the Euclidean distance between two colors. Args: color1 (np.ndarray): First color vector. color2 (np.ndarray): Second color vector. Returns: float: Euclidean distance. """ return float(np.linalg.norm(color1 - color2)) def _ensure_tile_sources(tile_dir: str): """Load base BGR tiles from disk once per directory. Args: tile_dir (str): Directory containing tile imagery. Returns: list[np.ndarray]: List of base-resolution BGR tiles. """ if tile_dir in _TILE_SOURCE_CACHE: return _TILE_SOURCE_CACHE[tile_dir] base_tiles = [] ensure_file_exists(tile_dir, "Tile directory") for f in sorted(os.listdir(tile_dir)): if not f.lower().endswith(('.png', '.jpg', '.jpeg', '.png', '.webp', '.bmp')): continue path = os.path.join(tile_dir, f) try: img = Image.open(path).convert('RGB') tile_array = np.array(img) tile_bgr = cv2.cvtColor(tile_array, cv2.COLOR_RGB2BGR) base_tiles.append(tile_bgr) except Exception as exc: print(f"Error loading {path}: {exc}") continue _TILE_SOURCE_CACHE[tile_dir] = base_tiles return base_tiles def _ensure_resized_tiles(tile_dir: str, cell_size: int): """Resize cached tiles to a particular cell size. Args: tile_dir (str): Source directory. cell_size (int): Desired tile size. Returns: tuple[np.ndarray, np.ndarray]: Resized tiles and their colors. """ key = (tile_dir, cell_size) if key in _TILE_RESIZE_CACHE: return _TILE_RESIZE_CACHE[key] base_tiles = _ensure_tile_sources(tile_dir) resized_tiles = [] tile_colors = [] for tile in base_tiles: interpolation = cv2.INTER_AREA if tile.shape[0] >= cell_size else cv2.INTER_CUBIC resized = cv2.resize(tile, (cell_size, cell_size), interpolation=interpolation) resized_tiles.append(resized) tile_colors.append(calculate_average_color(resized)) if not resized_tiles: # fallback synthetic tile if directory empty color = np.array([128, 128, 128], dtype=np.uint8) dummy = np.full((cell_size, cell_size, 3), color, dtype=np.uint8) resized_tiles.append(dummy) tile_colors.append(color.astype(float)) cache_value = (np.stack(resized_tiles, axis=0), np.vstack(tile_colors)) _TILE_RESIZE_CACHE[key] = cache_value return cache_value def load_tile_images(tile_dir: str, n_colors: int, cell_size: int): """Load tiles, raising informative errors when assets are missing. Args: tile_dir (str): Directory containing tile images. n_colors (int): Number of representative colors required. cell_size (int): Target cell dimension in pixels. Returns: tuple[np.ndarray, np.ndarray]: Arrays of tiles and their average colors. Raises: ValidationError: If no tiles could be produced. """ tiles_arr, tile_colors = _ensure_resized_tiles(tile_dir, cell_size) if len(tiles_arr) < n_colors: deficit = n_colors - len(tiles_arr) if deficit > 0: random_tiles = [] random_colors = [] for _ in range(deficit): color = np.random.randint(0, 255, 3, dtype=np.uint8) tile = np.full((cell_size, cell_size, 3), color, dtype=np.uint8) random_tiles.append(tile) random_colors.append(color.astype(float)) tiles_arr = np.concatenate([tiles_arr, np.stack(random_tiles)], axis=0) tile_colors = np.concatenate([tile_colors, np.vstack(random_colors)], axis=0) return tiles_arr, tile_colors def map_tiles_to_grid( grid_labels: np.ndarray, tiles: np.ndarray, cell_size: int, color_centers: np.ndarray | None = None, tile_colors: np.ndarray | None = None, ) -> np.ndarray: """Assemble the mosaic image by replacing each cell with a tile. Args: grid_labels (np.ndarray): ``grid_size x grid_size`` indices. tiles (np.ndarray): Stack of tile images. cell_size (int): Tile dimension in pixels. color_centers (np.ndarray | None): Optional color palette for matching. tile_colors (np.ndarray | None): Average colors per tile. Returns: np.ndarray: Mosaic image in BGR format. """ grid_size = grid_labels.shape[0] tiles_arr = np.asarray(tiles) if color_centers is not None and tile_colors is not None: color_centers = np.asarray(color_centers) tile_colors = np.asarray(tile_colors) distances = np.linalg.norm( color_centers[:, np.newaxis, :] - tile_colors[np.newaxis, :, :], axis=2 ) color_to_tile = np.argmin(distances, axis=1) tile_indices = color_to_tile[grid_labels] else: max_index = len(tiles_arr) - 1 tile_indices = np.clip(grid_labels, 0, max_index) flat_tiles = tiles_arr[tile_indices.reshape(-1)] tile_grid = flat_tiles.reshape(grid_size, grid_size, cell_size, cell_size, 3) mosaic = tile_grid.transpose(0, 2, 1, 3, 4).reshape(grid_size * cell_size, grid_size * cell_size, 3) return mosaic