File size: 5,876 Bytes
8edf546
 
 
6d232bb
e36ad66
 
388efcc
 
e36ad66
 
6d232bb
388efcc
 
 
 
 
 
 
 
 
1933832
6d232bb
388efcc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8edf546
388efcc
 
 
e36ad66
 
 
388efcc
 
 
 
 
 
 
 
 
 
 
 
 
e36ad66
 
 
 
388efcc
 
 
 
 
 
 
 
 
 
e36ad66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d232bb
e36ad66
 
 
 
 
388efcc
 
 
 
 
 
 
 
 
 
 
 
 
 
e36ad66
 
 
 
 
 
 
 
 
 
 
 
 
 
8edf546
388efcc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8edf546
e36ad66
 
6d232bb
e36ad66
 
 
 
 
 
 
6d232bb
e36ad66
 
 
 
 
 
8edf546
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
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