Niranjan Sathish commited on
Commit ·
2724e1d
1
Parent(s): 9e5f32a
Adding all files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Cache_Builder.py +234 -0
- ColourClassification.py +511 -0
- ImagePreprocessor.py +199 -0
- Performance_metrics.py +168 -0
- app.py +480 -0
- cache_16x16_bins8_rot.pkl +3 -0
- cache_32x32_bins8_rot.pkl +3 -0
- contextual_Mosaic_Builder.py +369 -0
- extracted_images/img_2001.png +0 -0
- extracted_images/img_2002.png +0 -0
- extracted_images/img_2003.png +0 -0
- extracted_images/img_2004.png +0 -0
- extracted_images/img_2005.png +0 -0
- extracted_images/img_2006.png +0 -0
- extracted_images/img_2007.png +0 -0
- extracted_images/img_2008.png +0 -0
- extracted_images/img_2009.png +0 -0
- extracted_images/img_2010.png +0 -0
- extracted_images/img_2011.png +0 -0
- extracted_images/img_2012.png +0 -0
- extracted_images/img_2013.png +0 -0
- extracted_images/img_2014.png +0 -0
- extracted_images/img_2015.png +0 -0
- extracted_images/img_2016.png +0 -0
- extracted_images/img_2017.png +0 -0
- extracted_images/img_2018.png +0 -0
- extracted_images/img_2019.png +0 -0
- extracted_images/img_2020.png +0 -0
- extracted_images/img_2021.png +0 -0
- extracted_images/img_2022.png +0 -0
- extracted_images/img_2023.png +0 -0
- extracted_images/img_2024.png +0 -0
- extracted_images/img_2025.png +0 -0
- extracted_images/img_2026.png +0 -0
- extracted_images/img_2027.png +0 -0
- extracted_images/img_2028.png +0 -0
- extracted_images/img_2029.png +0 -0
- extracted_images/img_2030.png +0 -0
- extracted_images/img_2031.png +0 -0
- extracted_images/img_2032.png +0 -0
- extracted_images/img_2033.png +0 -0
- extracted_images/img_2034.png +0 -0
- extracted_images/img_2035.png +0 -0
- extracted_images/img_2036.png +0 -0
- extracted_images/img_2037.png +0 -0
- extracted_images/img_2038.png +0 -0
- extracted_images/img_2039.png +0 -0
- extracted_images/img_2040.png +0 -0
- extracted_images/img_2041.png +0 -0
- extracted_images/img_2042.png +0 -0
Cache_Builder.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cache_builder.py
|
| 3 |
+
Builds optimized tile caches for fast mosaic generation.
|
| 4 |
+
Run this once to create cache files, then use for instant tile loading.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import cv2
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Tuple
|
| 11 |
+
from sklearn.cluster import MiniBatchKMeans
|
| 12 |
+
from sklearn.neighbors import NearestNeighbors
|
| 13 |
+
from sklearn.metrics.pairwise import euclidean_distances
|
| 14 |
+
from tqdm import tqdm
|
| 15 |
+
import pickle
|
| 16 |
+
import time
|
| 17 |
+
import warnings
|
| 18 |
+
from collections import defaultdict
|
| 19 |
+
|
| 20 |
+
class TileCacheBuilder:
|
| 21 |
+
"""Builds optimized tile caches with rotation variants and color subgrouping."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, tile_folder: str,
|
| 24 |
+
tile_size: Tuple[int, int] = (32, 32),
|
| 25 |
+
colour_bins: int = 8,
|
| 26 |
+
enable_rotation: bool = True):
|
| 27 |
+
"""
|
| 28 |
+
Initialize cache builder.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
tile_folder: Path to folder containing tile images
|
| 32 |
+
tile_size: Target tile size (width, height)
|
| 33 |
+
colour_bins: Number of colour categories for subgrouping
|
| 34 |
+
enable_rotation: Whether to create rotated variants
|
| 35 |
+
"""
|
| 36 |
+
self.tile_folder = Path(tile_folder)
|
| 37 |
+
self.tile_size = tile_size
|
| 38 |
+
self.colour_bins = colour_bins
|
| 39 |
+
self.enable_rotation = enable_rotation
|
| 40 |
+
self.rotation_angles = [0, 90, 180, 270] if enable_rotation else [0]
|
| 41 |
+
|
| 42 |
+
# Data containers
|
| 43 |
+
self.tile_images = []
|
| 44 |
+
self.tile_colours = []
|
| 45 |
+
self.tile_names = []
|
| 46 |
+
self.colour_palette = None
|
| 47 |
+
self.colour_groups = defaultdict(list)
|
| 48 |
+
self.colour_indices = {}
|
| 49 |
+
|
| 50 |
+
print(f"Tile Cache Builder")
|
| 51 |
+
print(f"Folder: {tile_folder}")
|
| 52 |
+
print(f"Tile size: {tile_size[0]}x{tile_size[1]}")
|
| 53 |
+
print(f"Colour bins: {colour_bins}")
|
| 54 |
+
print(f"Rotation: {enable_rotation}")
|
| 55 |
+
|
| 56 |
+
def build_cache(self, output_file: str, force_rebuild: bool = False) -> bool:
|
| 57 |
+
"""Build complete optimized tile cache."""
|
| 58 |
+
if Path(output_file).exists() and not force_rebuild:
|
| 59 |
+
print(f"Cache exists: {output_file} (use force_rebuild=True to rebuild)")
|
| 60 |
+
return False
|
| 61 |
+
|
| 62 |
+
print("Building comprehensive tile cache...")
|
| 63 |
+
total_start = time.time()
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
self._load_base_tiles()
|
| 67 |
+
if self.enable_rotation:
|
| 68 |
+
self._create_rotated_variants()
|
| 69 |
+
self._analyze_tile_colors()
|
| 70 |
+
self._create_color_subgroups()
|
| 71 |
+
self._save_cache(output_file)
|
| 72 |
+
|
| 73 |
+
total_time = time.time() - total_start
|
| 74 |
+
print(f"Cache built in {total_time:.2f} seconds: {output_file}")
|
| 75 |
+
return True
|
| 76 |
+
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"Cache building failed: {e}")
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
def _load_base_tiles(self):
|
| 82 |
+
"""Load base tiles from folder."""
|
| 83 |
+
if not self.tile_folder.exists():
|
| 84 |
+
raise ValueError(f"Tile folder not found: {self.tile_folder}")
|
| 85 |
+
|
| 86 |
+
# Find all image files
|
| 87 |
+
extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.tiff']
|
| 88 |
+
image_files = []
|
| 89 |
+
for ext in extensions:
|
| 90 |
+
image_files.extend(self.tile_folder.glob(f'*{ext}'))
|
| 91 |
+
image_files.extend(self.tile_folder.glob(f'*{ext.upper()}'))
|
| 92 |
+
|
| 93 |
+
if not image_files:
|
| 94 |
+
raise ValueError(f"No images found in {self.tile_folder}")
|
| 95 |
+
|
| 96 |
+
print(f"Loading {len(image_files)} base tiles...")
|
| 97 |
+
|
| 98 |
+
for img_path in tqdm(image_files, desc="Loading tiles"):
|
| 99 |
+
try:
|
| 100 |
+
img = cv2.imread(str(img_path))
|
| 101 |
+
if img is not None:
|
| 102 |
+
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
| 103 |
+
|
| 104 |
+
# Resize to target size
|
| 105 |
+
if img_rgb.shape[:2] != (self.tile_size[1], self.tile_size[0]):
|
| 106 |
+
img_rgb = cv2.resize(img_rgb, self.tile_size, interpolation=cv2.INTER_AREA)
|
| 107 |
+
|
| 108 |
+
self.tile_images.append(img_rgb)
|
| 109 |
+
self.tile_names.append(img_path.name)
|
| 110 |
+
|
| 111 |
+
except Exception as e:
|
| 112 |
+
print(f"Error loading {img_path}: {e}")
|
| 113 |
+
|
| 114 |
+
print(f"Loaded {len(self.tile_images)} base tiles")
|
| 115 |
+
|
| 116 |
+
def _create_rotated_variants(self):
|
| 117 |
+
"""Create 90, 180, 270 degree rotated variants."""
|
| 118 |
+
print("Creating rotated variants...")
|
| 119 |
+
|
| 120 |
+
base_count = len(self.tile_images)
|
| 121 |
+
rotated_images = []
|
| 122 |
+
rotated_names = []
|
| 123 |
+
|
| 124 |
+
for i in range(base_count):
|
| 125 |
+
base_image = self.tile_images[i]
|
| 126 |
+
base_name = self.tile_names[i]
|
| 127 |
+
|
| 128 |
+
# Create 3 rotated versions
|
| 129 |
+
for angle in [90, 180, 270]:
|
| 130 |
+
if angle == 90:
|
| 131 |
+
rotated = cv2.rotate(base_image, cv2.ROTATE_90_CLOCKWISE)
|
| 132 |
+
elif angle == 180:
|
| 133 |
+
rotated = cv2.rotate(base_image, cv2.ROTATE_180)
|
| 134 |
+
elif angle == 270:
|
| 135 |
+
rotated = cv2.rotate(base_image, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
| 136 |
+
|
| 137 |
+
rotated_images.append(rotated)
|
| 138 |
+
rotated_names.append(f"{base_name}_rot{angle}")
|
| 139 |
+
|
| 140 |
+
# Add to main collections
|
| 141 |
+
self.tile_images.extend(rotated_images)
|
| 142 |
+
self.tile_names.extend(rotated_names)
|
| 143 |
+
|
| 144 |
+
print(f"Expanded to {len(self.tile_images)} tiles with rotation")
|
| 145 |
+
|
| 146 |
+
def _analyze_tile_colors(self):
|
| 147 |
+
"""Calculate average colors for all tiles."""
|
| 148 |
+
print("Analyzing tile colors...")
|
| 149 |
+
|
| 150 |
+
for tile_image in tqdm(self.tile_images, desc="Color analysis"):
|
| 151 |
+
avg_colour = np.mean(tile_image, axis=(0, 1))
|
| 152 |
+
self.tile_colours.append(avg_colour)
|
| 153 |
+
|
| 154 |
+
self.tile_colours = np.array(self.tile_colours)
|
| 155 |
+
print(f"Color analysis complete: {len(self.tile_colours)} tiles")
|
| 156 |
+
|
| 157 |
+
def _create_color_subgroups(self):
|
| 158 |
+
"""Create color palette and subgroup tiles for fast searching."""
|
| 159 |
+
print(f"Creating {self.colour_bins}-color palette...")
|
| 160 |
+
|
| 161 |
+
# Create color palette using Mini-Batch K-means
|
| 162 |
+
with warnings.catch_warnings():
|
| 163 |
+
warnings.filterwarnings("ignore", category=UserWarning)
|
| 164 |
+
|
| 165 |
+
batch_size = min(max(len(self.tile_colours) // 10, 100), 1000)
|
| 166 |
+
kmeans = MiniBatchKMeans(
|
| 167 |
+
n_clusters=self.colour_bins,
|
| 168 |
+
batch_size=batch_size,
|
| 169 |
+
random_state=42,
|
| 170 |
+
n_init=3
|
| 171 |
+
)
|
| 172 |
+
kmeans.fit(self.tile_colours)
|
| 173 |
+
self.colour_palette = kmeans.cluster_centers_
|
| 174 |
+
|
| 175 |
+
# Assign tiles to color bins
|
| 176 |
+
for i, tile_colour in enumerate(self.tile_colours):
|
| 177 |
+
distances = euclidean_distances(
|
| 178 |
+
tile_colour.reshape(1, -1),
|
| 179 |
+
self.colour_palette
|
| 180 |
+
)[0]
|
| 181 |
+
closest_bin = np.argmin(distances)
|
| 182 |
+
self.colour_groups[closest_bin].append(i)
|
| 183 |
+
|
| 184 |
+
# Create search indices for each group
|
| 185 |
+
for bin_id, tile_indices in self.colour_groups.items():
|
| 186 |
+
if len(tile_indices) > 0:
|
| 187 |
+
group_colours = self.tile_colours[tile_indices]
|
| 188 |
+
|
| 189 |
+
index = NearestNeighbors(
|
| 190 |
+
n_neighbors=min(10, len(tile_indices)),
|
| 191 |
+
metric='euclidean',
|
| 192 |
+
algorithm='kd_tree'
|
| 193 |
+
)
|
| 194 |
+
index.fit(group_colours)
|
| 195 |
+
self.colour_indices[bin_id] = (index, tile_indices)
|
| 196 |
+
|
| 197 |
+
print(f"Created {len(self.colour_groups)} color subgroups")
|
| 198 |
+
|
| 199 |
+
def _save_cache(self, output_file: str):
|
| 200 |
+
"""Save processed data to cache file."""
|
| 201 |
+
cache_data = {
|
| 202 |
+
'tile_images': np.array(self.tile_images),
|
| 203 |
+
'tile_colours': self.tile_colours,
|
| 204 |
+
'tile_names': self.tile_names,
|
| 205 |
+
'colour_palette': self.colour_palette,
|
| 206 |
+
'colour_groups': dict(self.colour_groups),
|
| 207 |
+
'colour_indices': self.colour_indices,
|
| 208 |
+
'tile_size': self.tile_size,
|
| 209 |
+
'colour_bins': self.colour_bins,
|
| 210 |
+
'enable_rotation': self.enable_rotation,
|
| 211 |
+
'build_timestamp': time.time()
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
with open(output_file, 'wb') as f:
|
| 215 |
+
pickle.dump(cache_data, f)
|
| 216 |
+
|
| 217 |
+
cache_size_mb = Path(output_file).stat().st_size / 1024 / 1024
|
| 218 |
+
print(f"Cache saved: {cache_size_mb:.1f}MB")
|
| 219 |
+
|
| 220 |
+
if __name__ == "__main__":
|
| 221 |
+
# Build cache with your settings
|
| 222 |
+
builder = TileCacheBuilder(
|
| 223 |
+
tile_folder="extracted_images",
|
| 224 |
+
tile_size=(32, 32),
|
| 225 |
+
colour_bins=8,
|
| 226 |
+
enable_rotation=True
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
success = builder.build_cache("tiles_cache.pkl", force_rebuild=True)
|
| 230 |
+
|
| 231 |
+
if success:
|
| 232 |
+
print("Cache building completed! You can now run main.py")
|
| 233 |
+
else:
|
| 234 |
+
print("Cache building failed!")
|
ColourClassification.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import cv2
|
| 3 |
+
from sklearn.cluster import KMeans
|
| 4 |
+
from sklearn.preprocessing import LabelEncoder
|
| 5 |
+
import matplotlib.pyplot as plt
|
| 6 |
+
from typing import Tuple, List, Dict, Optional
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from enum import Enum
|
| 9 |
+
import seaborn as sns
|
| 10 |
+
|
| 11 |
+
class ColorClassificationMethod(Enum):
|
| 12 |
+
"""Different methods for classifying cell colors."""
|
| 13 |
+
DOMINANT_COLOR = "dominant_color"
|
| 14 |
+
AVERAGE_COLOR = "average_color"
|
| 15 |
+
HISTOGRAM_BINS = "histogram_bins"
|
| 16 |
+
HSV_QUANTIZATION = "hsv_quantization"
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class GridCell:
|
| 20 |
+
"""Represents a single grid cell with its properties."""
|
| 21 |
+
row: int
|
| 22 |
+
col: int
|
| 23 |
+
average_color: np.ndarray
|
| 24 |
+
dominant_color: np.ndarray
|
| 25 |
+
brightness: float
|
| 26 |
+
saturation: float
|
| 27 |
+
hue: float
|
| 28 |
+
color_category: int
|
| 29 |
+
pixel_data: np.ndarray
|
| 30 |
+
|
| 31 |
+
class ImageGridAnalyzer:
|
| 32 |
+
"""
|
| 33 |
+
Analyzes images by dividing them into grids and classifying each cell's color properties.
|
| 34 |
+
Uses vectorized NumPy operations for high performance.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self, grid_size: Tuple[int, int] = (32, 32),
|
| 38 |
+
classification_method: ColorClassificationMethod = ColorClassificationMethod.DOMINANT_COLOR,
|
| 39 |
+
n_color_categories: int = 16):
|
| 40 |
+
"""
|
| 41 |
+
Initialize the grid analyzer.
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
grid_size: (rows, cols) for the grid division
|
| 45 |
+
classification_method: Method to classify cell colors
|
| 46 |
+
n_color_categories: Number of color categories for classification
|
| 47 |
+
"""
|
| 48 |
+
self.grid_size = grid_size
|
| 49 |
+
self.classification_method = classification_method
|
| 50 |
+
self.n_color_categories = n_color_categories
|
| 51 |
+
self.color_classifier = None
|
| 52 |
+
self.category_colors = None
|
| 53 |
+
|
| 54 |
+
def divide_image_into_grid(self, image: np.ndarray) -> Tuple[np.ndarray, Tuple[int, int]]:
|
| 55 |
+
"""
|
| 56 |
+
Divide image into a grid using vectorized operations.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
image: Input image (H, W, C)
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
Grid of cells (grid_rows, grid_cols, tile_height, tile_width, channels)
|
| 63 |
+
Tuple of (tile_height, tile_width)
|
| 64 |
+
"""
|
| 65 |
+
h, w, c = image.shape
|
| 66 |
+
grid_rows, grid_cols = self.grid_size
|
| 67 |
+
|
| 68 |
+
# Calculate tile dimensions
|
| 69 |
+
tile_h = h // grid_rows
|
| 70 |
+
tile_w = w // grid_cols
|
| 71 |
+
|
| 72 |
+
# Adjust image size to fit grid perfectly (crop if necessary)
|
| 73 |
+
adjusted_h = tile_h * grid_rows
|
| 74 |
+
adjusted_w = tile_w * grid_cols
|
| 75 |
+
image = image[:adjusted_h, :adjusted_w]
|
| 76 |
+
|
| 77 |
+
# Vectorized grid division using reshape and transpose
|
| 78 |
+
# This is much faster than nested loops
|
| 79 |
+
grid = image.reshape(grid_rows, tile_h, grid_cols, tile_w, c)
|
| 80 |
+
grid = grid.transpose(0, 2, 1, 3, 4) # (grid_rows, grid_cols, tile_h, tile_w, c)
|
| 81 |
+
|
| 82 |
+
return grid, (tile_h, tile_w)
|
| 83 |
+
|
| 84 |
+
def analyze_grid_colors_vectorized(self, grid: np.ndarray) -> Dict[str, np.ndarray]:
|
| 85 |
+
"""
|
| 86 |
+
Analyze color properties of all grid cells using vectorized operations.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
grid: Grid of cells (grid_rows, grid_cols, tile_h, tile_w, c)
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
Dictionary containing vectorized analysis results
|
| 93 |
+
"""
|
| 94 |
+
grid_rows, grid_cols, tile_h, tile_w, c = grid.shape
|
| 95 |
+
|
| 96 |
+
# Reshape for vectorized operations: (total_cells, pixels_per_cell, channels)
|
| 97 |
+
cells_flat = grid.reshape(grid_rows * grid_cols, tile_h * tile_w, c)
|
| 98 |
+
|
| 99 |
+
# Calculate average colors for all cells at once
|
| 100 |
+
average_colors = np.mean(cells_flat, axis=1) # (total_cells, c)
|
| 101 |
+
|
| 102 |
+
# Calculate dominant colors using vectorized approach
|
| 103 |
+
dominant_colors = self._calculate_dominant_colors_vectorized(cells_flat)
|
| 104 |
+
|
| 105 |
+
# Convert to HSV for additional analysis
|
| 106 |
+
hsv_averages = self._rgb_to_hsv_vectorized(average_colors)
|
| 107 |
+
|
| 108 |
+
# Calculate brightness (V in HSV)
|
| 109 |
+
brightness = hsv_averages[:, 2]
|
| 110 |
+
|
| 111 |
+
# Calculate saturation
|
| 112 |
+
saturation = hsv_averages[:, 1]
|
| 113 |
+
|
| 114 |
+
# Calculate hue
|
| 115 |
+
hue = hsv_averages[:, 0]
|
| 116 |
+
|
| 117 |
+
# Reshape results back to grid format
|
| 118 |
+
results = {
|
| 119 |
+
'average_colors': average_colors.reshape(grid_rows, grid_cols, c),
|
| 120 |
+
'dominant_colors': dominant_colors.reshape(grid_rows, grid_cols, c),
|
| 121 |
+
'brightness': brightness.reshape(grid_rows, grid_cols),
|
| 122 |
+
'saturation': saturation.reshape(grid_rows, grid_cols),
|
| 123 |
+
'hue': hue.reshape(grid_rows, grid_cols),
|
| 124 |
+
'cells_data': grid # Keep original cell data
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
return results
|
| 128 |
+
|
| 129 |
+
def _calculate_dominant_colors_vectorized(self, cells_flat: np.ndarray) -> np.ndarray:
|
| 130 |
+
"""
|
| 131 |
+
Calculate dominant color for each cell using vectorized operations.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
cells_flat: Flattened cells (total_cells, pixels_per_cell, channels)
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
Dominant colors for all cells (total_cells, channels)
|
| 138 |
+
"""
|
| 139 |
+
import warnings
|
| 140 |
+
|
| 141 |
+
total_cells, pixels_per_cell, c = cells_flat.shape
|
| 142 |
+
dominant_colors = np.zeros((total_cells, c))
|
| 143 |
+
|
| 144 |
+
# Process cells in batches for memory efficiency
|
| 145 |
+
batch_size = 100
|
| 146 |
+
for i in range(0, total_cells, batch_size):
|
| 147 |
+
end_idx = min(i + batch_size, total_cells)
|
| 148 |
+
batch = cells_flat[i:end_idx]
|
| 149 |
+
|
| 150 |
+
for j, cell_pixels in enumerate(batch):
|
| 151 |
+
# Check for color diversity first
|
| 152 |
+
unique_pixels = np.unique(cell_pixels, axis=0)
|
| 153 |
+
|
| 154 |
+
if len(unique_pixels) >= 3 and pixels_per_cell > 100:
|
| 155 |
+
# Use k-means for larger cells with sufficient color diversity
|
| 156 |
+
with warnings.catch_warnings():
|
| 157 |
+
warnings.filterwarnings("ignore", category=UserWarning)
|
| 158 |
+
warnings.filterwarnings("ignore", message=".*ConvergenceWarning.*")
|
| 159 |
+
|
| 160 |
+
kmeans = KMeans(n_clusters=min(3, len(unique_pixels)),
|
| 161 |
+
random_state=42, n_init=5)
|
| 162 |
+
labels = kmeans.fit_predict(cell_pixels)
|
| 163 |
+
# Get the most frequent cluster center
|
| 164 |
+
unique_labels, counts = np.unique(labels, return_counts=True)
|
| 165 |
+
dominant_idx = unique_labels[np.argmax(counts)]
|
| 166 |
+
dominant_colors[i + j] = kmeans.cluster_centers_[dominant_idx]
|
| 167 |
+
elif len(unique_pixels) >= 2:
|
| 168 |
+
# Use most frequent color for limited diversity
|
| 169 |
+
unique_colors, counts = np.unique(cell_pixels, axis=0, return_counts=True)
|
| 170 |
+
dominant_colors[i + j] = unique_colors[np.argmax(counts)]
|
| 171 |
+
else:
|
| 172 |
+
# Use simple average for uniform cells
|
| 173 |
+
dominant_colors[i + j] = np.mean(cell_pixels, axis=0)
|
| 174 |
+
|
| 175 |
+
return dominant_colors
|
| 176 |
+
|
| 177 |
+
def _rgb_to_hsv_vectorized(self, rgb_colors: np.ndarray) -> np.ndarray:
|
| 178 |
+
"""
|
| 179 |
+
Convert RGB colors to HSV using vectorized operations.
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
rgb_colors: RGB colors (N, 3)
|
| 183 |
+
|
| 184 |
+
Returns:
|
| 185 |
+
HSV colors (N, 3)
|
| 186 |
+
"""
|
| 187 |
+
# Normalize to 0-1 range
|
| 188 |
+
rgb_normalized = rgb_colors / 255.0
|
| 189 |
+
|
| 190 |
+
# Create a dummy image for cv2 conversion
|
| 191 |
+
dummy_img = rgb_normalized.reshape(-1, 1, 3).astype(np.float32)
|
| 192 |
+
hsv_img = cv2.cvtColor(dummy_img, cv2.COLOR_RGB2HSV)
|
| 193 |
+
hsv_colors = hsv_img.reshape(-1, 3)
|
| 194 |
+
|
| 195 |
+
return hsv_colors
|
| 196 |
+
|
| 197 |
+
def classify_colors(self, color_data: Dict[str, np.ndarray]) -> np.ndarray:
|
| 198 |
+
"""
|
| 199 |
+
Classify each grid cell into color categories.
|
| 200 |
+
|
| 201 |
+
Args:
|
| 202 |
+
color_data: Dictionary containing color analysis results
|
| 203 |
+
|
| 204 |
+
Returns:
|
| 205 |
+
Color categories for each grid cell (grid_rows, grid_cols)
|
| 206 |
+
"""
|
| 207 |
+
import warnings
|
| 208 |
+
|
| 209 |
+
if self.classification_method == ColorClassificationMethod.AVERAGE_COLOR:
|
| 210 |
+
features = color_data['average_colors']
|
| 211 |
+
elif self.classification_method == ColorClassificationMethod.DOMINANT_COLOR:
|
| 212 |
+
features = color_data['dominant_colors']
|
| 213 |
+
elif self.classification_method == ColorClassificationMethod.HSV_QUANTIZATION:
|
| 214 |
+
# Combine HSV features
|
| 215 |
+
h = color_data['hue']
|
| 216 |
+
s = color_data['saturation']
|
| 217 |
+
v = color_data['brightness']
|
| 218 |
+
features = np.stack([h, s, v], axis=-1)
|
| 219 |
+
else:
|
| 220 |
+
features = color_data['average_colors']
|
| 221 |
+
|
| 222 |
+
# Flatten for clustering
|
| 223 |
+
grid_rows, grid_cols = features.shape[:2]
|
| 224 |
+
features_flat = features.reshape(-1, features.shape[-1])
|
| 225 |
+
|
| 226 |
+
# Check for sufficient diversity before clustering
|
| 227 |
+
unique_features = np.unique(features_flat, axis=0)
|
| 228 |
+
effective_clusters = min(self.n_color_categories, len(unique_features))
|
| 229 |
+
|
| 230 |
+
if effective_clusters < 2:
|
| 231 |
+
# Handle case with very limited color diversity
|
| 232 |
+
print(f"Warning: Only {len(unique_features)} unique colors found. Using simple classification.")
|
| 233 |
+
categories = np.zeros(len(features_flat), dtype=int)
|
| 234 |
+
categories_grid = categories.reshape(grid_rows, grid_cols)
|
| 235 |
+
self.category_colors = unique_features[:1] if len(unique_features) > 0 else np.array([[128, 128, 128]])
|
| 236 |
+
return categories_grid
|
| 237 |
+
|
| 238 |
+
# Fit color classifier with warning suppression
|
| 239 |
+
with warnings.catch_warnings():
|
| 240 |
+
warnings.filterwarnings("ignore", category=UserWarning)
|
| 241 |
+
warnings.filterwarnings("ignore", message=".*ConvergenceWarning.*")
|
| 242 |
+
|
| 243 |
+
self.color_classifier = KMeans(n_clusters=effective_clusters,
|
| 244 |
+
random_state=42, n_init=10)
|
| 245 |
+
categories = self.color_classifier.fit_predict(features_flat)
|
| 246 |
+
|
| 247 |
+
# Store category representative colors
|
| 248 |
+
self.category_colors = self.color_classifier.cluster_centers_
|
| 249 |
+
|
| 250 |
+
# Reshape back to grid
|
| 251 |
+
categories_grid = categories.reshape(grid_rows, grid_cols)
|
| 252 |
+
|
| 253 |
+
return categories_grid
|
| 254 |
+
|
| 255 |
+
def apply_thresholding(self, color_data: Dict[str, np.ndarray],
|
| 256 |
+
brightness_threshold: float = 0.5,
|
| 257 |
+
saturation_threshold: float = 0.3) -> Dict[str, np.ndarray]:
|
| 258 |
+
"""
|
| 259 |
+
Apply thresholding to create binary masks for different criteria.
|
| 260 |
+
|
| 261 |
+
Args:
|
| 262 |
+
color_data: Color analysis results
|
| 263 |
+
brightness_threshold: Threshold for bright/dark classification
|
| 264 |
+
saturation_threshold: Threshold for saturated/desaturated classification
|
| 265 |
+
|
| 266 |
+
Returns:
|
| 267 |
+
Dictionary containing various threshold masks
|
| 268 |
+
"""
|
| 269 |
+
brightness = color_data['brightness']
|
| 270 |
+
saturation = color_data['saturation']
|
| 271 |
+
|
| 272 |
+
# Normalize brightness and saturation to 0-1 range
|
| 273 |
+
brightness_norm = brightness / 255.0 if brightness.max() > 1.0 else brightness
|
| 274 |
+
saturation_norm = saturation / 255.0 if saturation.max() > 1.0 else saturation
|
| 275 |
+
|
| 276 |
+
thresholds = {
|
| 277 |
+
'bright_mask': brightness_norm > brightness_threshold,
|
| 278 |
+
'dark_mask': brightness_norm <= brightness_threshold,
|
| 279 |
+
'saturated_mask': saturation_norm > saturation_threshold,
|
| 280 |
+
'desaturated_mask': saturation_norm <= saturation_threshold,
|
| 281 |
+
'bright_saturated': (brightness_norm > brightness_threshold) &
|
| 282 |
+
(saturation_norm > saturation_threshold),
|
| 283 |
+
'dark_saturated': (brightness_norm <= brightness_threshold) &
|
| 284 |
+
(saturation_norm > saturation_threshold)
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
return thresholds
|
| 288 |
+
|
| 289 |
+
def analyze_image_complete(self, image: np.ndarray) -> Dict:
|
| 290 |
+
"""
|
| 291 |
+
Complete analysis pipeline for an image.
|
| 292 |
+
|
| 293 |
+
Args:
|
| 294 |
+
image: Input image (H, W, C)
|
| 295 |
+
|
| 296 |
+
Returns:
|
| 297 |
+
Complete analysis results
|
| 298 |
+
"""
|
| 299 |
+
print(f"Analyzing image with {self.grid_size[0]}x{self.grid_size[1]} grid...")
|
| 300 |
+
|
| 301 |
+
# Step 1: Divide into grid
|
| 302 |
+
grid, tile_size = self.divide_image_into_grid(image)
|
| 303 |
+
print(f"Created grid with tile size: {tile_size}")
|
| 304 |
+
|
| 305 |
+
# Step 2: Analyze colors (vectorized)
|
| 306 |
+
color_data = self.analyze_grid_colors_vectorized(grid)
|
| 307 |
+
print("Completed color analysis")
|
| 308 |
+
|
| 309 |
+
# Step 3: Classify colors
|
| 310 |
+
color_categories = self.classify_colors(color_data)
|
| 311 |
+
print(f"Classified into {self.n_color_categories} color categories")
|
| 312 |
+
|
| 313 |
+
# Step 4: Apply thresholding
|
| 314 |
+
thresholds = self.apply_thresholding(color_data)
|
| 315 |
+
print("Applied thresholding")
|
| 316 |
+
|
| 317 |
+
# Combine all results
|
| 318 |
+
results = {
|
| 319 |
+
'grid': grid,
|
| 320 |
+
'tile_size': tile_size,
|
| 321 |
+
'color_data': color_data,
|
| 322 |
+
'color_categories': color_categories,
|
| 323 |
+
'thresholds': thresholds,
|
| 324 |
+
'category_colors': self.category_colors
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
return results
|
| 328 |
+
|
| 329 |
+
def visualize_analysis(self, results: Dict, original_image: np.ndarray):
|
| 330 |
+
"""
|
| 331 |
+
Create comprehensive visualizations of the analysis results.
|
| 332 |
+
|
| 333 |
+
Args:
|
| 334 |
+
results: Analysis results from analyze_image_complete
|
| 335 |
+
original_image: Original input image
|
| 336 |
+
"""
|
| 337 |
+
fig, axes = plt.subplots(2, 4, figsize=(20, 10))
|
| 338 |
+
|
| 339 |
+
# Original image
|
| 340 |
+
axes[0, 0].imshow(original_image)
|
| 341 |
+
axes[0, 0].set_title('Original Image')
|
| 342 |
+
axes[0, 0].axis('off')
|
| 343 |
+
|
| 344 |
+
# Average colors
|
| 345 |
+
avg_colors = results['color_data']['average_colors'].astype(np.uint8)
|
| 346 |
+
axes[0, 1].imshow(avg_colors)
|
| 347 |
+
axes[0, 1].set_title('Average Colors per Cell')
|
| 348 |
+
axes[0, 1].axis('off')
|
| 349 |
+
|
| 350 |
+
# Dominant colors
|
| 351 |
+
dom_colors = results['color_data']['dominant_colors'].astype(np.uint8)
|
| 352 |
+
axes[0, 2].imshow(dom_colors)
|
| 353 |
+
axes[0, 2].set_title('Dominant Colors per Cell')
|
| 354 |
+
axes[0, 2].axis('off')
|
| 355 |
+
|
| 356 |
+
# Color categories
|
| 357 |
+
categories = results['color_categories']
|
| 358 |
+
im_cat = axes[0, 3].imshow(categories, cmap='tab20')
|
| 359 |
+
axes[0, 3].set_title(f'Color Categories ({self.n_color_categories} classes)')
|
| 360 |
+
axes[0, 3].axis('off')
|
| 361 |
+
plt.colorbar(im_cat, ax=axes[0, 3])
|
| 362 |
+
|
| 363 |
+
# Brightness
|
| 364 |
+
brightness = results['color_data']['brightness']
|
| 365 |
+
im_bright = axes[1, 0].imshow(brightness, cmap='gray')
|
| 366 |
+
axes[1, 0].set_title('Brightness Values')
|
| 367 |
+
axes[1, 0].axis('off')
|
| 368 |
+
plt.colorbar(im_bright, ax=axes[1, 0])
|
| 369 |
+
|
| 370 |
+
# Saturation
|
| 371 |
+
saturation = results['color_data']['saturation']
|
| 372 |
+
im_sat = axes[1, 1].imshow(saturation, cmap='viridis')
|
| 373 |
+
axes[1, 1].set_title('Saturation Values')
|
| 374 |
+
axes[1, 1].axis('off')
|
| 375 |
+
plt.colorbar(im_sat, ax=axes[1, 1])
|
| 376 |
+
|
| 377 |
+
# Threshold: Bright areas
|
| 378 |
+
axes[1, 2].imshow(results['thresholds']['bright_mask'], cmap='gray')
|
| 379 |
+
axes[1, 2].set_title('Bright Areas (Threshold)')
|
| 380 |
+
axes[1, 2].axis('off')
|
| 381 |
+
|
| 382 |
+
# Threshold: Saturated areas
|
| 383 |
+
axes[1, 3].imshow(results['thresholds']['saturated_mask'], cmap='gray')
|
| 384 |
+
axes[1, 3].set_title('Saturated Areas (Threshold)')
|
| 385 |
+
axes[1, 3].axis('off')
|
| 386 |
+
|
| 387 |
+
plt.tight_layout()
|
| 388 |
+
plt.show()
|
| 389 |
+
|
| 390 |
+
# Show color category palette
|
| 391 |
+
self._visualize_color_palette(results['category_colors'])
|
| 392 |
+
|
| 393 |
+
def _visualize_color_palette(self, category_colors: np.ndarray):
|
| 394 |
+
"""
|
| 395 |
+
Visualize the color category palette.
|
| 396 |
+
|
| 397 |
+
Args:
|
| 398 |
+
category_colors: Color palette (n_categories, channels)
|
| 399 |
+
"""
|
| 400 |
+
if category_colors is None:
|
| 401 |
+
return
|
| 402 |
+
|
| 403 |
+
fig, ax = plt.subplots(1, 1, figsize=(12, 2))
|
| 404 |
+
|
| 405 |
+
# Normalize colors if needed
|
| 406 |
+
colors = category_colors.copy()
|
| 407 |
+
if colors.max() > 1.0:
|
| 408 |
+
colors = colors / 255.0
|
| 409 |
+
|
| 410 |
+
# Create color swatches
|
| 411 |
+
palette = colors.reshape(1, -1, 3)
|
| 412 |
+
ax.imshow(palette, aspect='auto')
|
| 413 |
+
ax.set_xlim(0, len(colors))
|
| 414 |
+
ax.set_ylim(0, 1)
|
| 415 |
+
ax.set_xticks(range(len(colors)))
|
| 416 |
+
ax.set_xticklabels([f'Cat {i}' for i in range(len(colors))])
|
| 417 |
+
ax.set_title(f'Color Category Palette ({len(colors)} categories)')
|
| 418 |
+
ax.set_ylabel('Color Categories')
|
| 419 |
+
|
| 420 |
+
plt.tight_layout()
|
| 421 |
+
plt.show()
|
| 422 |
+
|
| 423 |
+
def get_performance_stats(self, results: Dict) -> Dict:
|
| 424 |
+
"""
|
| 425 |
+
Calculate performance and analysis statistics.
|
| 426 |
+
|
| 427 |
+
Args:
|
| 428 |
+
results: Analysis results
|
| 429 |
+
|
| 430 |
+
Returns:
|
| 431 |
+
Dictionary containing statistics
|
| 432 |
+
"""
|
| 433 |
+
grid_shape = results['color_categories'].shape
|
| 434 |
+
total_cells = np.prod(grid_shape)
|
| 435 |
+
|
| 436 |
+
# Color diversity
|
| 437 |
+
unique_categories = len(np.unique(results['color_categories']))
|
| 438 |
+
|
| 439 |
+
# Brightness statistics
|
| 440 |
+
brightness = results['color_data']['brightness']
|
| 441 |
+
|
| 442 |
+
# Saturation statistics
|
| 443 |
+
saturation = results['color_data']['saturation']
|
| 444 |
+
|
| 445 |
+
stats = {
|
| 446 |
+
'grid_size': f"{grid_shape[0]}x{grid_shape[1]}",
|
| 447 |
+
'total_cells': total_cells,
|
| 448 |
+
'unique_color_categories': unique_categories,
|
| 449 |
+
'category_utilization': unique_categories / self.n_color_categories,
|
| 450 |
+
'avg_brightness': np.mean(brightness),
|
| 451 |
+
'brightness_std': np.std(brightness),
|
| 452 |
+
'avg_saturation': np.mean(saturation),
|
| 453 |
+
'saturation_std': np.std(saturation),
|
| 454 |
+
'bright_cells_percent': np.mean(results['thresholds']['bright_mask']) * 100,
|
| 455 |
+
'saturated_cells_percent': np.mean(results['thresholds']['saturated_mask']) * 100
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
return stats
|
| 459 |
+
|
| 460 |
+
# Example usage and testing
|
| 461 |
+
def main():
|
| 462 |
+
"""
|
| 463 |
+
Example usage of the ImageGridAnalyzer.
|
| 464 |
+
"""
|
| 465 |
+
# Create sample test image (or load your own)
|
| 466 |
+
def create_test_image():
|
| 467 |
+
# Create a colorful test image with gradients and patterns
|
| 468 |
+
img = np.zeros((256, 256, 3), dtype=np.uint8)
|
| 469 |
+
|
| 470 |
+
# Add gradients
|
| 471 |
+
for i in range(256):
|
| 472 |
+
for j in range(256):
|
| 473 |
+
img[i, j, 0] = i # Red gradient
|
| 474 |
+
img[i, j, 1] = j # Green gradient
|
| 475 |
+
img[i, j, 2] = (i + j) % 255 # Blue pattern
|
| 476 |
+
|
| 477 |
+
return img
|
| 478 |
+
|
| 479 |
+
# Initialize analyzer
|
| 480 |
+
analyzer = ImageGridAnalyzer(
|
| 481 |
+
grid_size=(32, 32), # 32x32 grid = 1024 cells
|
| 482 |
+
classification_method=ColorClassificationMethod.DOMINANT_COLOR,
|
| 483 |
+
n_color_categories=16
|
| 484 |
+
)
|
| 485 |
+
|
| 486 |
+
# Create or load test image
|
| 487 |
+
# test_image = create_test_image()
|
| 488 |
+
|
| 489 |
+
# Or load real image (uncomment and modify path):
|
| 490 |
+
test_image = cv2.imread('processed_quantized.jpg')
|
| 491 |
+
test_image = cv2.cvtColor(test_image, cv2.COLOR_BGR2RGB)
|
| 492 |
+
|
| 493 |
+
print("Starting analysis...")
|
| 494 |
+
|
| 495 |
+
# Analyze the image
|
| 496 |
+
results = analyzer.analyze_image_complete(test_image)
|
| 497 |
+
|
| 498 |
+
# Get performance statistics
|
| 499 |
+
stats = analyzer.get_performance_stats(results)
|
| 500 |
+
|
| 501 |
+
print("\n=== Analysis Statistics ===")
|
| 502 |
+
for key, value in stats.items():
|
| 503 |
+
print(f"{key}: {value}")
|
| 504 |
+
|
| 505 |
+
# Visualize results
|
| 506 |
+
# analyzer.visualize_analysis(results, test_image)
|
| 507 |
+
|
| 508 |
+
return results, analyzer
|
| 509 |
+
|
| 510 |
+
if __name__ == "__main__":
|
| 511 |
+
results, analyzer = main()
|
ImagePreprocessor.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ImagePreprocessor.py
|
| 3 |
+
Step 1: Image preprocessing for mosaic generation.
|
| 4 |
+
Handles image loading, resizing, cropping, and color quantization.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import cv2
|
| 8 |
+
import numpy as np
|
| 9 |
+
from PIL import Image
|
| 10 |
+
import os
|
| 11 |
+
from typing import Tuple, Optional
|
| 12 |
+
from sklearn.cluster import MiniBatchKMeans
|
| 13 |
+
import warnings
|
| 14 |
+
|
| 15 |
+
class ImagePreprocessor:
|
| 16 |
+
"""
|
| 17 |
+
Handles image preprocessing for mosaic generation.
|
| 18 |
+
Features: Smart resizing, grid-perfect cropping, fast color quantization.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def __init__(self, target_resolution: Tuple[int, int] = (800, 600),
|
| 22 |
+
grid_size: Tuple[int, int] = (20, 15)):
|
| 23 |
+
"""
|
| 24 |
+
Initialize the preprocessor.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
target_resolution: Target (width, height) for processed images
|
| 28 |
+
grid_size: Grid dimensions (cols, rows) for mosaic
|
| 29 |
+
"""
|
| 30 |
+
self.target_resolution = target_resolution
|
| 31 |
+
self.grid_size = grid_size
|
| 32 |
+
|
| 33 |
+
# Calculate tile size for perfect grid alignment
|
| 34 |
+
self.tile_width = target_resolution[0] // grid_size[0]
|
| 35 |
+
self.tile_height = target_resolution[1] // grid_size[1]
|
| 36 |
+
|
| 37 |
+
# Adjust target resolution to fit grid perfectly
|
| 38 |
+
self.adjusted_width = self.tile_width * grid_size[0]
|
| 39 |
+
self.adjusted_height = self.tile_height * grid_size[1]
|
| 40 |
+
|
| 41 |
+
print(f"Target resolution: {self.adjusted_width}x{self.adjusted_height}")
|
| 42 |
+
print(f"Grid size: {grid_size[0]}x{grid_size[1]}")
|
| 43 |
+
print(f"Tile size: {self.tile_width}x{self.tile_height}")
|
| 44 |
+
|
| 45 |
+
def load_and_preprocess_image(self, image_path: str,
|
| 46 |
+
apply_quantization: bool = False,
|
| 47 |
+
n_colors: int = 16) -> Optional[np.ndarray]:
|
| 48 |
+
"""
|
| 49 |
+
Load and preprocess image from file path.
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
image_path: Path to the image file
|
| 53 |
+
apply_quantization: Whether to apply color quantization
|
| 54 |
+
n_colors: Number of colors for quantization
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
Preprocessed image as numpy array (RGB) or None if failed
|
| 58 |
+
"""
|
| 59 |
+
try:
|
| 60 |
+
# Load and convert image
|
| 61 |
+
image = cv2.imread(image_path)
|
| 62 |
+
if image is None:
|
| 63 |
+
raise ValueError(f"Could not load image: {image_path}")
|
| 64 |
+
|
| 65 |
+
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 66 |
+
|
| 67 |
+
# Resize and crop to fit grid
|
| 68 |
+
processed_image = self._resize_and_crop(image)
|
| 69 |
+
|
| 70 |
+
# Apply color quantization if requested
|
| 71 |
+
if apply_quantization:
|
| 72 |
+
processed_image = self._apply_color_quantization(processed_image, n_colors)
|
| 73 |
+
|
| 74 |
+
return processed_image
|
| 75 |
+
|
| 76 |
+
except Exception as e:
|
| 77 |
+
print(f"Error processing {image_path}: {str(e)}")
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
def preprocess_numpy_image(self, image: np.ndarray,
|
| 81 |
+
apply_quantization: bool = False,
|
| 82 |
+
n_colors: int = 16) -> Optional[np.ndarray]:
|
| 83 |
+
"""
|
| 84 |
+
Preprocess numpy image array (for Gradio integration).
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
image: Input image as numpy array
|
| 88 |
+
apply_quantization: Whether to apply color quantization
|
| 89 |
+
n_colors: Number of colors for quantization
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
Preprocessed image as numpy array (RGB) or None if failed
|
| 93 |
+
"""
|
| 94 |
+
try:
|
| 95 |
+
if len(image.shape) != 3 or image.shape[2] != 3:
|
| 96 |
+
raise ValueError("Image must be RGB format with shape (H, W, 3)")
|
| 97 |
+
|
| 98 |
+
processed_image = image.copy()
|
| 99 |
+
processed_image = self._resize_and_crop(processed_image)
|
| 100 |
+
|
| 101 |
+
if apply_quantization:
|
| 102 |
+
processed_image = self._apply_color_quantization(processed_image, n_colors)
|
| 103 |
+
|
| 104 |
+
return processed_image
|
| 105 |
+
|
| 106 |
+
except Exception as e:
|
| 107 |
+
print(f"Error processing numpy image: {str(e)}")
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
def _resize_and_crop(self, image: np.ndarray) -> np.ndarray:
|
| 111 |
+
"""
|
| 112 |
+
Resize and crop image to fit target resolution while maintaining aspect ratio.
|
| 113 |
+
"""
|
| 114 |
+
h, w = image.shape[:2]
|
| 115 |
+
target_w, target_h = self.adjusted_width, self.adjusted_height
|
| 116 |
+
|
| 117 |
+
# Scale to fill target size
|
| 118 |
+
scale_w = target_w / w
|
| 119 |
+
scale_h = target_h / h
|
| 120 |
+
scale = max(scale_w, scale_h)
|
| 121 |
+
|
| 122 |
+
# Resize image
|
| 123 |
+
new_w = int(w * scale)
|
| 124 |
+
new_h = int(h * scale)
|
| 125 |
+
resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
| 126 |
+
|
| 127 |
+
# Center crop to exact target size
|
| 128 |
+
start_x = (new_w - target_w) // 2
|
| 129 |
+
start_y = (new_h - target_h) // 2
|
| 130 |
+
cropped = resized[start_y:start_y + target_h, start_x:start_x + target_w]
|
| 131 |
+
|
| 132 |
+
return cropped
|
| 133 |
+
|
| 134 |
+
def _apply_color_quantization(self, image: np.ndarray, n_colors: int) -> np.ndarray:
|
| 135 |
+
"""
|
| 136 |
+
Apply color quantization using Mini-Batch K-means for speed.
|
| 137 |
+
3-4x faster than regular K-means with similar quality.
|
| 138 |
+
"""
|
| 139 |
+
h, w, c = image.shape
|
| 140 |
+
pixels = image.reshape(-1, c)
|
| 141 |
+
|
| 142 |
+
# Adaptive batch size based on image size
|
| 143 |
+
total_pixels = len(pixels)
|
| 144 |
+
batch_size = min(max(total_pixels // 100, 1000), 10000)
|
| 145 |
+
|
| 146 |
+
print(f"Applying Mini-Batch K-means quantization:")
|
| 147 |
+
print(f" Total pixels: {total_pixels:,}")
|
| 148 |
+
print(f" Batch size: {batch_size:,}")
|
| 149 |
+
print(f" Target colors: {n_colors}")
|
| 150 |
+
|
| 151 |
+
with warnings.catch_warnings():
|
| 152 |
+
warnings.filterwarnings("ignore", category=UserWarning)
|
| 153 |
+
warnings.filterwarnings("ignore", message=".*ConvergenceWarning.*")
|
| 154 |
+
|
| 155 |
+
kmeans = MiniBatchKMeans(
|
| 156 |
+
n_clusters=n_colors,
|
| 157 |
+
batch_size=batch_size,
|
| 158 |
+
random_state=42,
|
| 159 |
+
n_init=3,
|
| 160 |
+
max_iter=100
|
| 161 |
+
)
|
| 162 |
+
labels = kmeans.fit_predict(pixels)
|
| 163 |
+
|
| 164 |
+
# Replace pixels with cluster centers
|
| 165 |
+
quantized_pixels = kmeans.cluster_centers_[labels]
|
| 166 |
+
quantized_image = quantized_pixels.reshape(h, w, c).astype(np.uint8)
|
| 167 |
+
|
| 168 |
+
return quantized_image
|
| 169 |
+
|
| 170 |
+
def save_preprocessed_image(self, image: np.ndarray, output_path: str):
|
| 171 |
+
"""Save preprocessed image to disk."""
|
| 172 |
+
try:
|
| 173 |
+
pil_image = Image.fromarray(image)
|
| 174 |
+
pil_image.save(output_path, quality=95)
|
| 175 |
+
print(f"Saved preprocessed image to: {output_path}")
|
| 176 |
+
except Exception as e:
|
| 177 |
+
print(f"Error saving image: {str(e)}")
|
| 178 |
+
|
| 179 |
+
if __name__ == "__main__":
|
| 180 |
+
# Test preprocessing
|
| 181 |
+
preprocessor = ImagePreprocessor(
|
| 182 |
+
target_resolution=(1280, 1280),
|
| 183 |
+
grid_size=(32, 32)
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
test_image = "EmmaPotrait.jpg"
|
| 187 |
+
|
| 188 |
+
if os.path.exists(test_image):
|
| 189 |
+
processed = preprocessor.load_and_preprocess_image(
|
| 190 |
+
test_image,
|
| 191 |
+
apply_quantization=True,
|
| 192 |
+
n_colors=8
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
if processed is not None:
|
| 196 |
+
preprocessor.save_preprocessed_image(processed, "processed_quantized.jpg")
|
| 197 |
+
print("Image preprocessing completed!")
|
| 198 |
+
else:
|
| 199 |
+
print(f"Test image not found: {test_image}")
|
Performance_metrics.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
performance_metrics.py
|
| 3 |
+
Performance evaluation for mosaic quality assessment.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import cv2
|
| 8 |
+
from typing import Dict
|
| 9 |
+
from skimage.metrics import structural_similarity as ssim
|
| 10 |
+
import matplotlib.pyplot as plt
|
| 11 |
+
|
| 12 |
+
class PerformanceEvaluator:
|
| 13 |
+
"""Comprehensive mosaic quality evaluation."""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
self.metrics = {}
|
| 17 |
+
|
| 18 |
+
def calculate_mse(self, original: np.ndarray, mosaic: np.ndarray) -> float:
|
| 19 |
+
"""Calculate Mean Squared Error."""
|
| 20 |
+
if original.shape != mosaic.shape:
|
| 21 |
+
original_resized = cv2.resize(original, (mosaic.shape[1], mosaic.shape[0]))
|
| 22 |
+
else:
|
| 23 |
+
original_resized = original
|
| 24 |
+
|
| 25 |
+
orig_float = original_resized.astype(np.float64)
|
| 26 |
+
mosaic_float = mosaic.astype(np.float64)
|
| 27 |
+
mse = np.mean((orig_float - mosaic_float) ** 2)
|
| 28 |
+
return float(mse)
|
| 29 |
+
|
| 30 |
+
def calculate_psnr(self, original: np.ndarray, mosaic: np.ndarray) -> float:
|
| 31 |
+
"""Calculate Peak Signal-to-Noise Ratio."""
|
| 32 |
+
mse = self.calculate_mse(original, mosaic)
|
| 33 |
+
if mse == 0:
|
| 34 |
+
return float('inf')
|
| 35 |
+
psnr = 20 * np.log10(255.0 / np.sqrt(mse))
|
| 36 |
+
return float(psnr)
|
| 37 |
+
|
| 38 |
+
def calculate_ssim(self, original: np.ndarray, mosaic: np.ndarray) -> float:
|
| 39 |
+
"""Calculate Structural Similarity Index."""
|
| 40 |
+
if original.shape != mosaic.shape:
|
| 41 |
+
original_resized = cv2.resize(original, (mosaic.shape[1], mosaic.shape[0]))
|
| 42 |
+
else:
|
| 43 |
+
original_resized = original
|
| 44 |
+
|
| 45 |
+
if len(original_resized.shape) == 3:
|
| 46 |
+
ssim_values = []
|
| 47 |
+
for channel in range(original_resized.shape[2]):
|
| 48 |
+
channel_ssim = ssim(original_resized[:, :, channel], mosaic[:, :, channel], data_range=255)
|
| 49 |
+
ssim_values.append(channel_ssim)
|
| 50 |
+
return float(np.mean(ssim_values))
|
| 51 |
+
else:
|
| 52 |
+
return float(ssim(original_resized, mosaic, data_range=255))
|
| 53 |
+
|
| 54 |
+
def calculate_color_histogram_similarity(self, original: np.ndarray, mosaic: np.ndarray) -> float:
|
| 55 |
+
"""Calculate color histogram similarity."""
|
| 56 |
+
if original.shape != mosaic.shape:
|
| 57 |
+
original_resized = cv2.resize(original, (mosaic.shape[1], mosaic.shape[0]))
|
| 58 |
+
else:
|
| 59 |
+
original_resized = original
|
| 60 |
+
|
| 61 |
+
correlations = []
|
| 62 |
+
for channel in range(3):
|
| 63 |
+
hist_orig = cv2.calcHist([original_resized], [channel], None, [256], [0, 256])
|
| 64 |
+
hist_mosaic = cv2.calcHist([mosaic], [channel], None, [256], [0, 256])
|
| 65 |
+
corr = cv2.compareHist(hist_orig, hist_mosaic, cv2.HISTCMP_CORREL)
|
| 66 |
+
correlations.append(corr)
|
| 67 |
+
|
| 68 |
+
return float(np.mean(correlations))
|
| 69 |
+
|
| 70 |
+
def evaluate_mosaic_quality(self, original: np.ndarray, mosaic: np.ndarray,
|
| 71 |
+
original_path: str = None) -> Dict[str, float]:
|
| 72 |
+
"""Comprehensive quality evaluation."""
|
| 73 |
+
print("Calculating quality metrics...")
|
| 74 |
+
|
| 75 |
+
metrics = {
|
| 76 |
+
'mse': self.calculate_mse(original, mosaic),
|
| 77 |
+
'psnr': self.calculate_psnr(original, mosaic),
|
| 78 |
+
'ssim': self.calculate_ssim(original, mosaic),
|
| 79 |
+
'histogram_similarity': self.calculate_color_histogram_similarity(original, mosaic)
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
# Overall score
|
| 83 |
+
ssim_norm = (metrics['ssim'] + 1) / 2
|
| 84 |
+
psnr_norm = min(metrics['psnr'] / 50.0, 1.0)
|
| 85 |
+
hist_norm = metrics['histogram_similarity']
|
| 86 |
+
overall = (0.5 * ssim_norm + 0.3 * hist_norm + 0.2 * psnr_norm) * 100
|
| 87 |
+
metrics['overall_quality'] = float(overall)
|
| 88 |
+
|
| 89 |
+
self.metrics = metrics
|
| 90 |
+
self._print_report(metrics, original_path)
|
| 91 |
+
return metrics
|
| 92 |
+
|
| 93 |
+
def _print_report(self, metrics: Dict[str, float], original_path: str = None):
|
| 94 |
+
"""Print quality assessment report."""
|
| 95 |
+
print(f"\n{'='*50}")
|
| 96 |
+
print("QUALITY ASSESSMENT")
|
| 97 |
+
print(f"{'='*50}")
|
| 98 |
+
|
| 99 |
+
if original_path:
|
| 100 |
+
print(f"Image: {original_path}")
|
| 101 |
+
|
| 102 |
+
print(f"MSE: {metrics['mse']:.2f} (lower = better)")
|
| 103 |
+
print(f"PSNR: {metrics['psnr']:.2f} dB (>30 = good)")
|
| 104 |
+
print(f"SSIM: {metrics['ssim']:.4f} (>0.8 = very good)")
|
| 105 |
+
print(f"Histogram: {metrics['histogram_similarity']:.4f}")
|
| 106 |
+
print(f"Overall Score: {metrics['overall_quality']:.1f}/100")
|
| 107 |
+
|
| 108 |
+
score = metrics['overall_quality']
|
| 109 |
+
if score >= 85:
|
| 110 |
+
quality = "Excellent"
|
| 111 |
+
elif score >= 75:
|
| 112 |
+
quality = "Very Good"
|
| 113 |
+
elif score >= 65:
|
| 114 |
+
quality = "Good"
|
| 115 |
+
else:
|
| 116 |
+
quality = "Fair"
|
| 117 |
+
|
| 118 |
+
print(f"Assessment: {quality}")
|
| 119 |
+
print(f"{'='*50}")
|
| 120 |
+
|
| 121 |
+
def visualize_quality_comparison(self, original: np.ndarray, mosaic: np.ndarray,
|
| 122 |
+
metrics: Dict[str, float] = None):
|
| 123 |
+
"""Create visual comparison with metrics."""
|
| 124 |
+
if metrics is None:
|
| 125 |
+
metrics = self.metrics
|
| 126 |
+
|
| 127 |
+
print("Showing quality comparison...")
|
| 128 |
+
|
| 129 |
+
fig, axes = plt.subplots(2, 2, figsize=(16, 12))
|
| 130 |
+
|
| 131 |
+
# Original
|
| 132 |
+
axes[0, 0].imshow(original)
|
| 133 |
+
axes[0, 0].set_title(f'Original\n{original.shape[1]}×{original.shape[0]}')
|
| 134 |
+
axes[0, 0].axis('off')
|
| 135 |
+
|
| 136 |
+
# Mosaic
|
| 137 |
+
axes[0, 1].imshow(mosaic)
|
| 138 |
+
axes[0, 1].set_title(f'Mosaic\n{mosaic.shape[1]}×{mosaic.shape[0]}')
|
| 139 |
+
axes[0, 1].axis('off')
|
| 140 |
+
|
| 141 |
+
# Metrics
|
| 142 |
+
if metrics:
|
| 143 |
+
metrics_text = (f"Quality Metrics:\n\n"
|
| 144 |
+
f"MSE: {metrics['mse']:.1f}\n"
|
| 145 |
+
f"PSNR: {metrics['psnr']:.1f} dB\n"
|
| 146 |
+
f"SSIM: {metrics['ssim']:.4f}\n"
|
| 147 |
+
f"Histogram: {metrics['histogram_similarity']:.4f}\n\n"
|
| 148 |
+
f"Score: {metrics['overall_quality']:.1f}/100")
|
| 149 |
+
|
| 150 |
+
axes[1, 0].text(0.1, 0.5, metrics_text, fontsize=12, fontfamily='monospace', va='center')
|
| 151 |
+
axes[1, 0].set_title('Quality Assessment')
|
| 152 |
+
axes[1, 0].axis('off')
|
| 153 |
+
|
| 154 |
+
# Difference map
|
| 155 |
+
if original.shape == mosaic.shape:
|
| 156 |
+
diff_img = np.abs(original.astype(np.int16) - mosaic.astype(np.int16))
|
| 157 |
+
else:
|
| 158 |
+
original_resized = cv2.resize(original, (mosaic.shape[1], mosaic.shape[0]))
|
| 159 |
+
diff_img = np.abs(original_resized.astype(np.int16) - mosaic.astype(np.int16))
|
| 160 |
+
|
| 161 |
+
diff_enhanced = np.clip(diff_img * 3, 0, 255).astype(np.uint8)
|
| 162 |
+
axes[1, 1].imshow(diff_enhanced)
|
| 163 |
+
axes[1, 1].set_title('Difference Map')
|
| 164 |
+
axes[1, 1].axis('off')
|
| 165 |
+
|
| 166 |
+
plt.suptitle('Mosaic Quality Evaluation', fontsize=16)
|
| 167 |
+
plt.tight_layout()
|
| 168 |
+
plt.show()
|
app.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py
|
| 3 |
+
Deployment-only Gradio interface that NEVER builds caches during startup.
|
| 4 |
+
Uses only pre-built cache files for fast cloud deployment.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import gradio as gr
|
| 8 |
+
import numpy as np
|
| 9 |
+
import cv2
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import time
|
| 12 |
+
import pickle
|
| 13 |
+
import warnings
|
| 14 |
+
from typing import Tuple, Dict, Optional, List
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from sklearn.cluster import MiniBatchKMeans
|
| 17 |
+
from sklearn.metrics.pairwise import euclidean_distances
|
| 18 |
+
from skimage.metrics import structural_similarity as ssim
|
| 19 |
+
|
| 20 |
+
# Deployment configuration
|
| 21 |
+
TILE_FOLDER = "extracted_images"
|
| 22 |
+
|
| 23 |
+
# Pre-built cache files that should be uploaded with the app
|
| 24 |
+
AVAILABLE_CACHES = {
|
| 25 |
+
16: "cache_16x16_bins8_rot.pkl",
|
| 26 |
+
32: "cache_32x32_bins8_rot.pkl",
|
| 27 |
+
64: "cache_64x64_bins8_rot.pkl"
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class ImageContext:
|
| 32 |
+
"""Contextual analysis results."""
|
| 33 |
+
has_faces: bool
|
| 34 |
+
face_regions: List[Tuple[int, int, int, int]]
|
| 35 |
+
is_portrait: bool
|
| 36 |
+
is_landscape: bool
|
| 37 |
+
content_complexity: float
|
| 38 |
+
|
| 39 |
+
class DeploymentMosaicGenerator:
|
| 40 |
+
"""Deployment-optimized mosaic generator that NEVER builds caches."""
|
| 41 |
+
|
| 42 |
+
def __init__(self, cache_file: str):
|
| 43 |
+
"""Initialize with existing cache file only."""
|
| 44 |
+
self.cache_file = cache_file
|
| 45 |
+
|
| 46 |
+
# Load cache data
|
| 47 |
+
try:
|
| 48 |
+
with open(cache_file, 'rb') as f:
|
| 49 |
+
data = pickle.load(f)
|
| 50 |
+
|
| 51 |
+
self.tile_images = data['tile_images']
|
| 52 |
+
self.tile_colours = data['tile_colours']
|
| 53 |
+
self.tile_names = data['tile_names']
|
| 54 |
+
self.colour_palette = data['colour_palette']
|
| 55 |
+
self.colour_groups = data['colour_groups']
|
| 56 |
+
self.colour_indices = data['colour_indices']
|
| 57 |
+
self.tile_size = data['tile_size']
|
| 58 |
+
|
| 59 |
+
print(f"Loaded cache: {len(self.tile_images)} tiles, size {self.tile_size}")
|
| 60 |
+
|
| 61 |
+
except Exception as e:
|
| 62 |
+
raise RuntimeError(f"Failed to load cache {cache_file}: {e}")
|
| 63 |
+
|
| 64 |
+
# Face detection
|
| 65 |
+
try:
|
| 66 |
+
self.face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
|
| 67 |
+
except:
|
| 68 |
+
self.face_cascade = None
|
| 69 |
+
|
| 70 |
+
def analyze_context(self, image: np.ndarray) -> ImageContext:
|
| 71 |
+
"""Basic context analysis for deployment."""
|
| 72 |
+
faces = []
|
| 73 |
+
has_faces = False
|
| 74 |
+
|
| 75 |
+
if self.face_cascade is not None:
|
| 76 |
+
try:
|
| 77 |
+
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
| 78 |
+
detected_faces = self.face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(20, 20))
|
| 79 |
+
faces = [(x, y, w, h) for (x, y, w, h) in detected_faces]
|
| 80 |
+
has_faces = len(faces) > 0
|
| 81 |
+
except:
|
| 82 |
+
pass
|
| 83 |
+
|
| 84 |
+
# Basic scene classification
|
| 85 |
+
aspect_ratio = image.shape[1] / image.shape[0]
|
| 86 |
+
is_portrait = aspect_ratio < 1.2 and has_faces
|
| 87 |
+
is_landscape = aspect_ratio > 1.5
|
| 88 |
+
|
| 89 |
+
# Simple complexity measure
|
| 90 |
+
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
| 91 |
+
edges = cv2.Canny(gray, 50, 150)
|
| 92 |
+
content_complexity = np.mean(edges) / 255.0
|
| 93 |
+
|
| 94 |
+
return ImageContext(
|
| 95 |
+
has_faces=has_faces,
|
| 96 |
+
face_regions=faces,
|
| 97 |
+
is_portrait=is_portrait,
|
| 98 |
+
is_landscape=is_landscape,
|
| 99 |
+
content_complexity=content_complexity
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
def find_best_tile(self, target_colour: np.ndarray) -> int:
|
| 103 |
+
"""Find best matching tile using color subgroups."""
|
| 104 |
+
distances = euclidean_distances(target_colour.reshape(1, -1), self.colour_palette)[0]
|
| 105 |
+
target_bin = np.argmin(distances)
|
| 106 |
+
|
| 107 |
+
if target_bin in self.colour_indices:
|
| 108 |
+
index, tile_indices = self.colour_indices[target_bin]
|
| 109 |
+
_, indices = index.kneighbors(target_colour.reshape(1, -1), n_neighbors=1)
|
| 110 |
+
return tile_indices[indices[0][0]]
|
| 111 |
+
|
| 112 |
+
return 0
|
| 113 |
+
|
| 114 |
+
def create_mosaic(self, image: np.ndarray, grid_size: int, diversity_factor: float = 0.1) -> Tuple[np.ndarray, ImageContext]:
|
| 115 |
+
"""Create mosaic with deployment-optimized processing."""
|
| 116 |
+
print(f"Creating {grid_size}x{grid_size} mosaic...")
|
| 117 |
+
|
| 118 |
+
# Analyze context
|
| 119 |
+
context = self.analyze_context(image)
|
| 120 |
+
|
| 121 |
+
# Preprocess image to fit grid
|
| 122 |
+
target_size = grid_size * self.tile_size[0]
|
| 123 |
+
h, w = image.shape[:2]
|
| 124 |
+
scale = max(target_size / w, target_size / h)
|
| 125 |
+
new_w, new_h = int(w * scale), int(h * scale)
|
| 126 |
+
resized = cv2.resize(image, (new_w, new_h))
|
| 127 |
+
|
| 128 |
+
# Center crop
|
| 129 |
+
start_x = (new_w - target_size) // 2
|
| 130 |
+
start_y = (new_h - target_size) // 2
|
| 131 |
+
processed_image = resized[start_y:start_y + target_size, start_x:start_x + target_size]
|
| 132 |
+
|
| 133 |
+
# Create mosaic
|
| 134 |
+
cell_size = target_size // grid_size
|
| 135 |
+
mosaic = np.zeros((grid_size * self.tile_size[1], grid_size * self.tile_size[0], 3), dtype=np.uint8)
|
| 136 |
+
|
| 137 |
+
usage_count = {} if diversity_factor > 0 else None
|
| 138 |
+
|
| 139 |
+
for i in range(grid_size):
|
| 140 |
+
for j in range(grid_size):
|
| 141 |
+
# Get cell color
|
| 142 |
+
start_y = i * cell_size
|
| 143 |
+
end_y = start_y + cell_size
|
| 144 |
+
start_x = j * cell_size
|
| 145 |
+
end_x = start_x + cell_size
|
| 146 |
+
|
| 147 |
+
cell = processed_image[start_y:end_y, start_x:end_x]
|
| 148 |
+
target_colour = np.mean(cell, axis=(0, 1))
|
| 149 |
+
|
| 150 |
+
# Find best tile
|
| 151 |
+
best_tile_idx = self.find_best_tile(target_colour)
|
| 152 |
+
|
| 153 |
+
# Apply diversity if enabled
|
| 154 |
+
if usage_count is not None:
|
| 155 |
+
tile_name = self.tile_names[best_tile_idx]
|
| 156 |
+
usage_penalty = usage_count.get(tile_name, 0) * diversity_factor
|
| 157 |
+
|
| 158 |
+
# Try alternative tiles if this one is overused
|
| 159 |
+
if usage_penalty > 2.0: # Simple diversity check
|
| 160 |
+
distances = euclidean_distances(target_colour.reshape(1, -1), self.tile_colours)[0]
|
| 161 |
+
sorted_indices = np.argsort(distances)
|
| 162 |
+
for idx in sorted_indices[1:6]: # Try next 5 best matches
|
| 163 |
+
alt_tile_name = self.tile_names[idx]
|
| 164 |
+
if usage_count.get(alt_tile_name, 0) < usage_count[tile_name]:
|
| 165 |
+
best_tile_idx = idx
|
| 166 |
+
break
|
| 167 |
+
|
| 168 |
+
usage_count[self.tile_names[best_tile_idx]] = usage_count.get(self.tile_names[best_tile_idx], 0) + 1
|
| 169 |
+
|
| 170 |
+
# Place tile
|
| 171 |
+
tile_start_y = i * self.tile_size[1]
|
| 172 |
+
tile_end_y = tile_start_y + self.tile_size[1]
|
| 173 |
+
tile_start_x = j * self.tile_size[0]
|
| 174 |
+
tile_end_x = tile_start_x + self.tile_size[0]
|
| 175 |
+
|
| 176 |
+
mosaic[tile_start_y:tile_end_y, tile_start_x:tile_end_x] = self.tile_images[best_tile_idx]
|
| 177 |
+
|
| 178 |
+
return mosaic, context
|
| 179 |
+
|
| 180 |
+
def calculate_metrics(original: np.ndarray, mosaic: np.ndarray) -> Dict[str, float]:
|
| 181 |
+
"""Calculate the 4 core quality metrics."""
|
| 182 |
+
# Resize for comparison
|
| 183 |
+
if original.shape != mosaic.shape:
|
| 184 |
+
original_resized = cv2.resize(original, (mosaic.shape[1], mosaic.shape[0]))
|
| 185 |
+
else:
|
| 186 |
+
original_resized = original
|
| 187 |
+
|
| 188 |
+
# MSE
|
| 189 |
+
orig_float = original_resized.astype(np.float64)
|
| 190 |
+
mosaic_float = mosaic.astype(np.float64)
|
| 191 |
+
mse = float(np.mean((orig_float - mosaic_float) ** 2))
|
| 192 |
+
|
| 193 |
+
# PSNR
|
| 194 |
+
psnr = float(20 * np.log10(255.0 / np.sqrt(mse))) if mse > 0 else float('inf')
|
| 195 |
+
|
| 196 |
+
# SSIM
|
| 197 |
+
ssim_values = []
|
| 198 |
+
for channel in range(3):
|
| 199 |
+
channel_ssim = ssim(original_resized[:, :, channel], mosaic[:, :, channel], data_range=255)
|
| 200 |
+
ssim_values.append(channel_ssim)
|
| 201 |
+
ssim_score = float(np.mean(ssim_values))
|
| 202 |
+
|
| 203 |
+
# Histogram similarity
|
| 204 |
+
correlations = []
|
| 205 |
+
for channel in range(3):
|
| 206 |
+
hist_orig = cv2.calcHist([original_resized], [channel], None, [256], [0, 256])
|
| 207 |
+
hist_mosaic = cv2.calcHist([mosaic], [channel], None, [256], [0, 256])
|
| 208 |
+
corr = cv2.compareHist(hist_orig, hist_mosaic, cv2.HISTCMP_CORREL)
|
| 209 |
+
correlations.append(corr)
|
| 210 |
+
histogram_similarity = float(np.mean(correlations))
|
| 211 |
+
|
| 212 |
+
# Overall score
|
| 213 |
+
ssim_norm = (ssim_score + 1) / 2
|
| 214 |
+
psnr_norm = min(psnr / 50.0, 1.0)
|
| 215 |
+
overall = (0.5 * ssim_norm + 0.3 * histogram_similarity + 0.2 * psnr_norm) * 100
|
| 216 |
+
|
| 217 |
+
return {
|
| 218 |
+
'mse': mse,
|
| 219 |
+
'psnr': psnr,
|
| 220 |
+
'ssim': ssim_score,
|
| 221 |
+
'histogram_similarity': histogram_similarity,
|
| 222 |
+
'overall_quality': float(overall)
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
def get_best_available_cache(requested_tile_size: int) -> Optional[str]:
|
| 226 |
+
"""Get the best available cache for requested tile size."""
|
| 227 |
+
# Check exact match first
|
| 228 |
+
if requested_tile_size in AVAILABLE_CACHES:
|
| 229 |
+
cache_file = AVAILABLE_CACHES[requested_tile_size]
|
| 230 |
+
if Path(cache_file).exists():
|
| 231 |
+
return cache_file
|
| 232 |
+
|
| 233 |
+
# Find closest available cache
|
| 234 |
+
available_sizes = []
|
| 235 |
+
for size, cache_file in AVAILABLE_CACHES.items():
|
| 236 |
+
if Path(cache_file).exists():
|
| 237 |
+
available_sizes.append(size)
|
| 238 |
+
|
| 239 |
+
if not available_sizes:
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
# Return closest match
|
| 243 |
+
closest_size = min(available_sizes, key=lambda x: abs(x - requested_tile_size))
|
| 244 |
+
return AVAILABLE_CACHES[closest_size]
|
| 245 |
+
|
| 246 |
+
def create_mosaic_interface(image, grid_size, tile_size, diversity_factor, enable_rotation, apply_quantization, n_colors):
|
| 247 |
+
"""Main interface function - deployment optimized."""
|
| 248 |
+
if image is None:
|
| 249 |
+
return None, None, "Please upload an image first.", "Please upload an image first."
|
| 250 |
+
|
| 251 |
+
try:
|
| 252 |
+
start_time = time.time()
|
| 253 |
+
|
| 254 |
+
# Get appropriate cache file
|
| 255 |
+
cache_file = get_best_available_cache(tile_size)
|
| 256 |
+
if cache_file is None:
|
| 257 |
+
error_msg = f"No cache available for tile size {tile_size}x{tile_size}"
|
| 258 |
+
return None, None, error_msg, error_msg
|
| 259 |
+
|
| 260 |
+
# Initialize generator with existing cache
|
| 261 |
+
generator = DeploymentMosaicGenerator(cache_file)
|
| 262 |
+
|
| 263 |
+
# Basic color quantization if requested
|
| 264 |
+
if apply_quantization:
|
| 265 |
+
pixels = image.reshape(-1, 3)
|
| 266 |
+
sample_size = min(5000, len(pixels))
|
| 267 |
+
sampled_pixels = pixels[np.random.choice(len(pixels), sample_size, replace=False)]
|
| 268 |
+
|
| 269 |
+
with warnings.catch_warnings():
|
| 270 |
+
warnings.filterwarnings("ignore")
|
| 271 |
+
kmeans = MiniBatchKMeans(n_clusters=n_colors, batch_size=500, random_state=42)
|
| 272 |
+
kmeans.fit(sampled_pixels)
|
| 273 |
+
labels = kmeans.predict(pixels)
|
| 274 |
+
|
| 275 |
+
quantized_pixels = kmeans.cluster_centers_[labels]
|
| 276 |
+
image = quantized_pixels.reshape(image.shape).astype(np.uint8)
|
| 277 |
+
|
| 278 |
+
# Create mosaic
|
| 279 |
+
mosaic, context = generator.create_mosaic(image, grid_size, diversity_factor)
|
| 280 |
+
|
| 281 |
+
# Calculate metrics
|
| 282 |
+
metrics = calculate_metrics(image, mosaic)
|
| 283 |
+
|
| 284 |
+
total_time = time.time() - start_time
|
| 285 |
+
|
| 286 |
+
# Create comparison
|
| 287 |
+
comparison = np.hstack([
|
| 288 |
+
cv2.resize(image, (mosaic.shape[1]//2, mosaic.shape[0])),
|
| 289 |
+
cv2.resize(mosaic, (mosaic.shape[1]//2, mosaic.shape[0]))
|
| 290 |
+
])
|
| 291 |
+
|
| 292 |
+
# Metrics display - only 4 core metrics
|
| 293 |
+
metrics_text = f"""PERFORMANCE METRICS
|
| 294 |
+
|
| 295 |
+
Mean Squared Error (MSE): {metrics['mse']:.2f}
|
| 296 |
+
|
| 297 |
+
Peak Signal-to-Noise Ratio (PSNR): {metrics['psnr']:.2f} dB
|
| 298 |
+
|
| 299 |
+
Structural Similarity Index (SSIM): {metrics['ssim']:.4f}
|
| 300 |
+
|
| 301 |
+
Color Histogram Similarity: {metrics['histogram_similarity']:.4f}
|
| 302 |
+
|
| 303 |
+
Overall Quality Score: {metrics['overall_quality']:.1f}/100"""
|
| 304 |
+
|
| 305 |
+
# Status
|
| 306 |
+
status = f"""Generation Successful!
|
| 307 |
+
|
| 308 |
+
Grid: {grid_size}x{grid_size} = {grid_size**2} tiles
|
| 309 |
+
Tile Size: {tile_size}x{tile_size} pixels
|
| 310 |
+
Processing Time: {total_time:.2f} seconds
|
| 311 |
+
Cache Used: {cache_file}
|
| 312 |
+
|
| 313 |
+
Contextual Analysis:
|
| 314 |
+
• Faces Detected: {len(context.face_regions)}
|
| 315 |
+
• Scene Type: {'Portrait' if context.is_portrait else 'Landscape' if context.is_landscape else 'General'}
|
| 316 |
+
• Content Complexity: {context.content_complexity:.3f}"""
|
| 317 |
+
|
| 318 |
+
return mosaic, comparison, metrics_text, status
|
| 319 |
+
|
| 320 |
+
except Exception as e:
|
| 321 |
+
error_msg = f"Error: {str(e)}"
|
| 322 |
+
return None, None, error_msg, error_msg
|
| 323 |
+
|
| 324 |
+
def verify_deployment_setup():
|
| 325 |
+
"""Check deployment setup without building anything."""
|
| 326 |
+
available_caches = {}
|
| 327 |
+
total_size_mb = 0
|
| 328 |
+
|
| 329 |
+
for size, cache_file in AVAILABLE_CACHES.items():
|
| 330 |
+
if Path(cache_file).exists():
|
| 331 |
+
size_mb = Path(cache_file).stat().st_size / 1024 / 1024
|
| 332 |
+
available_caches[size] = size_mb
|
| 333 |
+
total_size_mb += size_mb
|
| 334 |
+
|
| 335 |
+
setup_msg = f"Found {len(available_caches)} cache files ({total_size_mb:.1f}MB total)"
|
| 336 |
+
|
| 337 |
+
return len(available_caches) > 0, setup_msg, available_caches
|
| 338 |
+
|
| 339 |
+
def get_system_status():
|
| 340 |
+
"""System status for deployment."""
|
| 341 |
+
setup_ok, setup_msg, available_caches = verify_deployment_setup()
|
| 342 |
+
|
| 343 |
+
cache_list = ""
|
| 344 |
+
for size, size_mb in available_caches.items():
|
| 345 |
+
cache_list += f" {size}x{size}: {size_mb:.1f}MB\n"
|
| 346 |
+
|
| 347 |
+
status = f"""DEPLOYMENT STATUS
|
| 348 |
+
{'='*30}
|
| 349 |
+
|
| 350 |
+
Cache System: {'✅' if setup_ok else '❌'}
|
| 351 |
+
{setup_msg}
|
| 352 |
+
|
| 353 |
+
Available Caches:
|
| 354 |
+
{cache_list if cache_list else " None found"}
|
| 355 |
+
|
| 356 |
+
Smart Selection: System automatically uses the best
|
| 357 |
+
available cache for your chosen tile size.
|
| 358 |
+
|
| 359 |
+
INNOVATIONS INCLUDED
|
| 360 |
+
{'='*30}
|
| 361 |
+
|
| 362 |
+
🧠 Contextual Awareness: Face detection, scene classification
|
| 363 |
+
🔄 Multi-Orientation: Rotation variants in cache files
|
| 364 |
+
⚡ Performance: Color subgrouping, Mini-Batch K-means
|
| 365 |
+
📊 Quality Metrics: MSE, PSNR, SSIM, Histogram similarity
|
| 366 |
+
|
| 367 |
+
DEPLOYMENT OPTIMIZED
|
| 368 |
+
{'='*30}
|
| 369 |
+
|
| 370 |
+
• No cache building during startup
|
| 371 |
+
• Fast initialization with pre-built caches
|
| 372 |
+
• Lightweight processing for cloud deployment
|
| 373 |
+
• Maintains all core innovations"""
|
| 374 |
+
|
| 375 |
+
return status
|
| 376 |
+
|
| 377 |
+
def create_interface():
|
| 378 |
+
"""Create deployment-optimized Gradio interface."""
|
| 379 |
+
|
| 380 |
+
css = """
|
| 381 |
+
.gradio-container { max-width: 100% !important; padding: 0 20px; }
|
| 382 |
+
.left-panel {
|
| 383 |
+
flex: 0 0 350px; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
| 384 |
+
padding: 25px; border-radius: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
| 385 |
+
}
|
| 386 |
+
.right-panel {
|
| 387 |
+
flex: 1; background: white; padding: 25px; border-radius: 15px;
|
| 388 |
+
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
| 389 |
+
}
|
| 390 |
+
.metrics-display {
|
| 391 |
+
background: linear-gradient(145deg, #667eea 0%, #764ba2 100%);
|
| 392 |
+
color: white; padding: 20px; border-radius: 10px;
|
| 393 |
+
font-family: 'Courier New', monospace; font-size: 14px; line-height: 1.6;
|
| 394 |
+
}
|
| 395 |
+
"""
|
| 396 |
+
|
| 397 |
+
with gr.Blocks(css=css, title="Advanced Mosaic Generator") as demo:
|
| 398 |
+
|
| 399 |
+
gr.Markdown("# Advanced Contextual Mosaic Generator")
|
| 400 |
+
gr.Markdown("AI-powered mosaic creation with contextual awareness and performance metrics.")
|
| 401 |
+
|
| 402 |
+
with gr.Accordion("System Status", open=False):
|
| 403 |
+
status_display = gr.Textbox(value=get_system_status(), lines=20, show_label=False)
|
| 404 |
+
gr.Button("Refresh Status").click(fn=get_system_status, outputs=status_display)
|
| 405 |
+
|
| 406 |
+
with gr.Row():
|
| 407 |
+
# Left Panel: Controls
|
| 408 |
+
with gr.Column(scale=0, min_width=350, elem_classes=["left-panel"]):
|
| 409 |
+
gr.Markdown("## Configuration")
|
| 410 |
+
|
| 411 |
+
# Generate button at top
|
| 412 |
+
generate_btn = gr.Button("Generate Advanced Mosaic", variant="primary", size="lg")
|
| 413 |
+
|
| 414 |
+
gr.Markdown("---")
|
| 415 |
+
|
| 416 |
+
# Image upload
|
| 417 |
+
input_image = gr.Image(type="numpy", label="Upload Image", height=200)
|
| 418 |
+
|
| 419 |
+
# Controls
|
| 420 |
+
grid_size = gr.Slider(16, 96, 32, step=8, label="Grid Size", info="Number of tiles per side")
|
| 421 |
+
tile_size = gr.Slider(16, 64, 32, step=16, label="Tile Size", info="Must match available cache")
|
| 422 |
+
diversity_factor = gr.Slider(0.0, 0.5, 0.15, step=0.05, label="Diversity", info="Tile variety")
|
| 423 |
+
enable_rotation = gr.Checkbox(label="Enable Rotation", value=False, info="Uses rotation variants if available")
|
| 424 |
+
apply_quantization = gr.Checkbox(label="Color Quantization", value=True)
|
| 425 |
+
n_colors = gr.Slider(4, 24, 12, step=2, label="Colors")
|
| 426 |
+
|
| 427 |
+
# Presets
|
| 428 |
+
gr.Markdown("### Quick Presets")
|
| 429 |
+
with gr.Row():
|
| 430 |
+
preset_fast = gr.Button("Fast", size="sm")
|
| 431 |
+
preset_quality = gr.Button("Quality", size="sm")
|
| 432 |
+
|
| 433 |
+
# Right Panel: Results
|
| 434 |
+
with gr.Column(scale=2, elem_classes=["right-panel"]):
|
| 435 |
+
gr.Markdown("## Results & Analysis")
|
| 436 |
+
|
| 437 |
+
with gr.Row():
|
| 438 |
+
with gr.Column():
|
| 439 |
+
gr.Markdown("### Generated Mosaic")
|
| 440 |
+
mosaic_output = gr.Image(height=300, show_label=False)
|
| 441 |
+
|
| 442 |
+
with gr.Column():
|
| 443 |
+
gr.Markdown("### Comparison")
|
| 444 |
+
comparison_output = gr.Image(height=300, show_label=False)
|
| 445 |
+
|
| 446 |
+
gr.Markdown("### Performance Metrics")
|
| 447 |
+
metrics_output = gr.Textbox(lines=8, elem_classes=["metrics-display"], show_label=False)
|
| 448 |
+
|
| 449 |
+
status_output = gr.Textbox(label="Status", lines=6)
|
| 450 |
+
|
| 451 |
+
# Connect functions
|
| 452 |
+
def fast_preset():
|
| 453 |
+
return 24, 32, 0.1, False, True, 8
|
| 454 |
+
|
| 455 |
+
def quality_preset():
|
| 456 |
+
return 48, 32, 0.15, False, True, 12
|
| 457 |
+
|
| 458 |
+
generate_btn.click(
|
| 459 |
+
fn=create_mosaic_interface,
|
| 460 |
+
inputs=[input_image, grid_size, tile_size, diversity_factor, enable_rotation, apply_quantization, n_colors],
|
| 461 |
+
outputs=[mosaic_output, comparison_output, metrics_output, status_output]
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
preset_fast.click(fn=fast_preset, outputs=[grid_size, tile_size, diversity_factor, enable_rotation, apply_quantization, n_colors])
|
| 465 |
+
preset_quality.click(fn=quality_preset, outputs=[grid_size, tile_size, diversity_factor, enable_rotation, apply_quantization, n_colors])
|
| 466 |
+
|
| 467 |
+
return demo
|
| 468 |
+
|
| 469 |
+
if __name__ == "__main__":
|
| 470 |
+
print("Advanced Mosaic Generator - Deployment Version")
|
| 471 |
+
print("Checking deployment setup...")
|
| 472 |
+
|
| 473 |
+
setup_ok, setup_msg, caches = verify_deployment_setup()
|
| 474 |
+
if setup_ok:
|
| 475 |
+
print(f"Deployment ready: {setup_msg}")
|
| 476 |
+
demo = create_interface()
|
| 477 |
+
demo.launch(server_name="0.0.0.0", server_port=7860, share=True)
|
| 478 |
+
else:
|
| 479 |
+
print(f"Deployment not ready: {setup_msg}")
|
| 480 |
+
print("Please upload the required cache files")
|
cache_16x16_bins8_rot.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f471f90618f8a5c431fa4e066f7b9c02ea698e74d21a2c1082c98a76a75d63fa
|
| 3 |
+
size 57528829
|
cache_32x32_bins8_rot.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c8a246f56373475cec1642fea61a1cb2ee0b1c5d9106869d380c9d464fc2ac94
|
| 3 |
+
size 213371389
|
contextual_Mosaic_Builder.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
contextual_mosaic.py
|
| 3 |
+
Advanced contextual mosaic generator with face detection and rotation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import cv2
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Dict, Tuple, List, Optional
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from sklearn.metrics.pairwise import euclidean_distances
|
| 12 |
+
from sklearn.cluster import MiniBatchKMeans
|
| 13 |
+
from tqdm import tqdm
|
| 14 |
+
import pickle
|
| 15 |
+
import warnings
|
| 16 |
+
import matplotlib.pyplot as plt
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class ImageContext:
|
| 20 |
+
"""Stores contextual analysis results."""
|
| 21 |
+
has_faces: bool
|
| 22 |
+
face_regions: List[Tuple[int, int, int, int]]
|
| 23 |
+
is_portrait: bool
|
| 24 |
+
is_landscape: bool
|
| 25 |
+
dominant_colors: np.ndarray
|
| 26 |
+
brightness_map: np.ndarray
|
| 27 |
+
edge_density_map: np.ndarray
|
| 28 |
+
content_complexity: float
|
| 29 |
+
|
| 30 |
+
class ContextualMosaicGenerator:
|
| 31 |
+
"""Advanced mosaic generator with contextual awareness."""
|
| 32 |
+
|
| 33 |
+
def __init__(self, cache_file: str, tile_folder: str = "extracted_images",
|
| 34 |
+
tile_size: Tuple[int, int] = (32, 32),
|
| 35 |
+
colour_bins: int = 8,
|
| 36 |
+
enable_rotation: bool = True):
|
| 37 |
+
self.cache_file = cache_file
|
| 38 |
+
self.tile_folder = tile_folder
|
| 39 |
+
self.target_tile_size = tile_size
|
| 40 |
+
self.target_colour_bins = colour_bins
|
| 41 |
+
self.target_enable_rotation = enable_rotation
|
| 42 |
+
|
| 43 |
+
# Data containers
|
| 44 |
+
self.tile_images = None
|
| 45 |
+
self.tile_colours = None
|
| 46 |
+
self.tile_names = None
|
| 47 |
+
self.colour_palette = None
|
| 48 |
+
self.colour_groups = None
|
| 49 |
+
self.colour_indices = None
|
| 50 |
+
self.tile_size = None
|
| 51 |
+
self.enable_rotation = False
|
| 52 |
+
|
| 53 |
+
# Setup face detection
|
| 54 |
+
try:
|
| 55 |
+
self.face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
|
| 56 |
+
except:
|
| 57 |
+
print("Warning: Face detection not available")
|
| 58 |
+
self.face_cascade = None
|
| 59 |
+
|
| 60 |
+
self._load_or_build_cache()
|
| 61 |
+
|
| 62 |
+
def _load_or_build_cache(self):
|
| 63 |
+
"""Load existing cache or build new one."""
|
| 64 |
+
if Path(self.cache_file).exists():
|
| 65 |
+
try:
|
| 66 |
+
if self._load_and_validate_cache():
|
| 67 |
+
print("Cache loaded successfully")
|
| 68 |
+
return
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print(f"Cache error: {e}")
|
| 71 |
+
|
| 72 |
+
print("Building new cache...")
|
| 73 |
+
self._build_cache_automatically()
|
| 74 |
+
|
| 75 |
+
def _load_and_validate_cache(self) -> bool:
|
| 76 |
+
"""Load and validate cache parameters."""
|
| 77 |
+
with open(self.cache_file, 'rb') as f:
|
| 78 |
+
data = pickle.load(f)
|
| 79 |
+
|
| 80 |
+
required_keys = ['tile_images', 'tile_colours', 'tile_names', 'colour_palette', 'colour_groups', 'colour_indices']
|
| 81 |
+
if not all(key in data for key in required_keys):
|
| 82 |
+
return False
|
| 83 |
+
|
| 84 |
+
# Validate parameters
|
| 85 |
+
if (data.get('tile_size') != self.target_tile_size or
|
| 86 |
+
data.get('colour_bins') != self.target_colour_bins or
|
| 87 |
+
data.get('enable_rotation') != self.target_enable_rotation):
|
| 88 |
+
print("Cache parameters don't match")
|
| 89 |
+
return False
|
| 90 |
+
|
| 91 |
+
# Load data
|
| 92 |
+
self.tile_images = data['tile_images']
|
| 93 |
+
self.tile_colours = data['tile_colours']
|
| 94 |
+
self.tile_names = data['tile_names']
|
| 95 |
+
self.colour_palette = data['colour_palette']
|
| 96 |
+
self.colour_groups = data['colour_groups']
|
| 97 |
+
self.colour_indices = data['colour_indices']
|
| 98 |
+
self.tile_size = data['tile_size']
|
| 99 |
+
self.enable_rotation = data.get('enable_rotation', False)
|
| 100 |
+
|
| 101 |
+
print(f"Loaded: {len(self.tile_images)} tiles")
|
| 102 |
+
return True
|
| 103 |
+
|
| 104 |
+
def _build_cache_automatically(self):
|
| 105 |
+
"""Build cache using cache builder."""
|
| 106 |
+
try:
|
| 107 |
+
from Cache_Builder import TileCacheBuilder
|
| 108 |
+
|
| 109 |
+
print("Auto-building cache...")
|
| 110 |
+
builder = TileCacheBuilder(
|
| 111 |
+
tile_folder=self.tile_folder,
|
| 112 |
+
tile_size=self.target_tile_size,
|
| 113 |
+
colour_bins=self.target_colour_bins,
|
| 114 |
+
enable_rotation=self.target_enable_rotation
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
success = builder.build_cache(self.cache_file, force_rebuild=True)
|
| 118 |
+
if not success:
|
| 119 |
+
raise RuntimeError("Cache builder failed")
|
| 120 |
+
|
| 121 |
+
if not self._load_and_validate_cache():
|
| 122 |
+
raise RuntimeError("Failed to load new cache")
|
| 123 |
+
|
| 124 |
+
print("Cache built successfully!")
|
| 125 |
+
|
| 126 |
+
except Exception as e:
|
| 127 |
+
raise RuntimeError(f"Auto-cache failed: {e}")
|
| 128 |
+
|
| 129 |
+
def analyze_image_context(self, image: np.ndarray) -> ImageContext:
|
| 130 |
+
"""Analyze image content for contextual tile selection."""
|
| 131 |
+
print("Analyzing context...")
|
| 132 |
+
|
| 133 |
+
# Face detection
|
| 134 |
+
faces = []
|
| 135 |
+
has_faces = False
|
| 136 |
+
if self.face_cascade is not None:
|
| 137 |
+
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
| 138 |
+
detected_faces = self.face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
|
| 139 |
+
faces = [(x, y, w, h) for (x, y, w, h) in detected_faces]
|
| 140 |
+
has_faces = len(faces) > 0
|
| 141 |
+
|
| 142 |
+
# Scene classification
|
| 143 |
+
aspect_ratio = image.shape[1] / image.shape[0]
|
| 144 |
+
is_portrait = aspect_ratio < 1.2 and has_faces
|
| 145 |
+
is_landscape = aspect_ratio > 1.5
|
| 146 |
+
|
| 147 |
+
# Dominant colors
|
| 148 |
+
pixels = image.reshape(-1, 3)
|
| 149 |
+
sample_size = min(10000, len(pixels))
|
| 150 |
+
sampled_pixels = pixels[np.random.choice(len(pixels), sample_size, replace=False)]
|
| 151 |
+
|
| 152 |
+
with warnings.catch_warnings():
|
| 153 |
+
warnings.filterwarnings("ignore")
|
| 154 |
+
kmeans = MiniBatchKMeans(n_clusters=5, random_state=42, batch_size=1000)
|
| 155 |
+
kmeans.fit(sampled_pixels)
|
| 156 |
+
dominant_colors = kmeans.cluster_centers_
|
| 157 |
+
|
| 158 |
+
# Analysis maps
|
| 159 |
+
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
|
| 160 |
+
brightness_map = cv2.resize(gray, (32, 32)) / 255.0
|
| 161 |
+
edges = cv2.Canny(gray, 50, 150)
|
| 162 |
+
edge_density_map = cv2.resize(edges, (32, 32)) / 255.0
|
| 163 |
+
content_complexity = np.mean(edge_density_map)
|
| 164 |
+
|
| 165 |
+
context = ImageContext(
|
| 166 |
+
has_faces=has_faces,
|
| 167 |
+
face_regions=faces,
|
| 168 |
+
is_portrait=is_portrait,
|
| 169 |
+
is_landscape=is_landscape,
|
| 170 |
+
dominant_colors=dominant_colors,
|
| 171 |
+
brightness_map=brightness_map,
|
| 172 |
+
edge_density_map=edge_density_map,
|
| 173 |
+
content_complexity=content_complexity
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
print(f"Context: {len(faces)} faces, {'Portrait' if is_portrait else 'Landscape' if is_landscape else 'General'}")
|
| 177 |
+
return context
|
| 178 |
+
|
| 179 |
+
def get_adaptive_tile_strategy(self, context: ImageContext, grid_pos: Tuple[int, int], grid_size: Tuple[int, int]) -> Dict:
|
| 180 |
+
"""Get adaptive strategy based on content and position."""
|
| 181 |
+
row, col = grid_pos
|
| 182 |
+
grid_rows, grid_cols = grid_size
|
| 183 |
+
|
| 184 |
+
strategy = {
|
| 185 |
+
'precision_factor': 1.0,
|
| 186 |
+
'diversity_factor': 0.1,
|
| 187 |
+
'allow_rotation': self.enable_rotation,
|
| 188 |
+
'search_candidates': 5
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
# Face area adjustments
|
| 192 |
+
if context.has_faces:
|
| 193 |
+
for face_x, face_y, face_w, face_h in context.face_regions:
|
| 194 |
+
face_grid_x = int((face_x / 1024) * grid_cols)
|
| 195 |
+
face_grid_y = int((face_y / 1024) * grid_rows)
|
| 196 |
+
face_grid_w = max(1, int((face_w / 1024) * grid_cols))
|
| 197 |
+
face_grid_h = max(1, int((face_h / 1024) * grid_rows))
|
| 198 |
+
|
| 199 |
+
if (face_grid_x <= col <= face_grid_x + face_grid_w and
|
| 200 |
+
face_grid_y <= row <= face_grid_y + face_grid_h):
|
| 201 |
+
strategy['precision_factor'] = 2.0
|
| 202 |
+
strategy['diversity_factor'] = 0.05
|
| 203 |
+
strategy['allow_rotation'] = False
|
| 204 |
+
strategy['search_candidates'] = 10
|
| 205 |
+
break
|
| 206 |
+
|
| 207 |
+
# Edge adjustments
|
| 208 |
+
edge_row = min(int((row / grid_rows) * 32), 31)
|
| 209 |
+
edge_col = min(int((col / grid_cols) * 32), 31)
|
| 210 |
+
edge_density = context.edge_density_map[edge_row, edge_col]
|
| 211 |
+
|
| 212 |
+
if edge_density > 0.3:
|
| 213 |
+
strategy['precision_factor'] *= 1.5
|
| 214 |
+
strategy['search_candidates'] = min(strategy['search_candidates'] * 2, 15)
|
| 215 |
+
|
| 216 |
+
return strategy
|
| 217 |
+
|
| 218 |
+
def find_contextual_best_tile(self, target_colour: np.ndarray, context: ImageContext,
|
| 219 |
+
grid_pos: Tuple[int, int], grid_size: Tuple[int, int],
|
| 220 |
+
usage_count: Dict = None) -> int:
|
| 221 |
+
"""Find best tile using contextual analysis."""
|
| 222 |
+
strategy = self.get_adaptive_tile_strategy(context, grid_pos, grid_size)
|
| 223 |
+
|
| 224 |
+
# Find color bin
|
| 225 |
+
distances = euclidean_distances(target_colour.reshape(1, -1), self.colour_palette)[0]
|
| 226 |
+
target_bin = np.argmin(distances)
|
| 227 |
+
|
| 228 |
+
if target_bin in self.colour_indices:
|
| 229 |
+
index, tile_indices = self.colour_indices[target_bin]
|
| 230 |
+
|
| 231 |
+
n_candidates = min(strategy['search_candidates'], len(tile_indices))
|
| 232 |
+
_, indices = index.kneighbors(target_colour.reshape(1, -1), n_neighbors=n_candidates)
|
| 233 |
+
|
| 234 |
+
best_score = float('inf')
|
| 235 |
+
best_tile_idx = tile_indices[indices[0][0]]
|
| 236 |
+
|
| 237 |
+
for local_idx in indices[0]:
|
| 238 |
+
global_tile_idx = tile_indices[local_idx]
|
| 239 |
+
tile_name = self.tile_names[global_tile_idx]
|
| 240 |
+
|
| 241 |
+
# Skip rotation in face areas
|
| 242 |
+
if not strategy['allow_rotation'] and '_rot' in tile_name:
|
| 243 |
+
continue
|
| 244 |
+
|
| 245 |
+
# Calculate score
|
| 246 |
+
tile_colour = self.tile_colours[global_tile_idx]
|
| 247 |
+
color_distance = np.linalg.norm(target_colour - tile_colour) * strategy['precision_factor']
|
| 248 |
+
|
| 249 |
+
usage_penalty = 0
|
| 250 |
+
if usage_count is not None:
|
| 251 |
+
usage_penalty = usage_count.get(tile_name, 0) * strategy['diversity_factor']
|
| 252 |
+
|
| 253 |
+
total_score = color_distance + usage_penalty
|
| 254 |
+
|
| 255 |
+
if total_score < best_score:
|
| 256 |
+
best_score = total_score
|
| 257 |
+
best_tile_idx = global_tile_idx
|
| 258 |
+
|
| 259 |
+
return best_tile_idx
|
| 260 |
+
|
| 261 |
+
return 0
|
| 262 |
+
|
| 263 |
+
def create_contextual_mosaic(self, image: np.ndarray, grid_size: Tuple[int, int],
|
| 264 |
+
diversity_factor: float = 0.1) -> Tuple[np.ndarray, ImageContext]:
|
| 265 |
+
"""Create mosaic with contextual awareness."""
|
| 266 |
+
print("Creating contextual mosaic...")
|
| 267 |
+
|
| 268 |
+
# Analyze context
|
| 269 |
+
context = self.analyze_image_context(image)
|
| 270 |
+
|
| 271 |
+
# Setup grid
|
| 272 |
+
h, w = image.shape[:2]
|
| 273 |
+
grid_rows, grid_cols = grid_size
|
| 274 |
+
cell_h = h // grid_rows
|
| 275 |
+
cell_w = w // grid_cols
|
| 276 |
+
|
| 277 |
+
# Initialize mosaic
|
| 278 |
+
tile_h, tile_w = self.tile_size[1], self.tile_size[0]
|
| 279 |
+
mosaic = np.zeros((grid_rows * tile_h, grid_cols * tile_w, 3), dtype=np.uint8)
|
| 280 |
+
|
| 281 |
+
usage_count = {} if diversity_factor > 0 else None
|
| 282 |
+
|
| 283 |
+
print(f"Building {grid_rows}x{grid_cols} mosaic...")
|
| 284 |
+
|
| 285 |
+
for i in tqdm(range(grid_rows), desc="Creating mosaic"):
|
| 286 |
+
for j in range(grid_cols):
|
| 287 |
+
# Get cell color
|
| 288 |
+
start_y = i * cell_h
|
| 289 |
+
end_y = min(start_y + cell_h, h)
|
| 290 |
+
start_x = j * cell_w
|
| 291 |
+
end_x = min(start_x + cell_w, w)
|
| 292 |
+
|
| 293 |
+
cell = image[start_y:end_y, start_x:end_x]
|
| 294 |
+
target_colour = np.mean(cell, axis=(0, 1))
|
| 295 |
+
|
| 296 |
+
# Find best tile
|
| 297 |
+
best_tile_idx = self.find_contextual_best_tile(
|
| 298 |
+
target_colour, context, (i, j), (grid_rows, grid_cols), usage_count
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
# Place tile
|
| 302 |
+
tile_start_row = i * tile_h
|
| 303 |
+
tile_end_row = tile_start_row + tile_h
|
| 304 |
+
tile_start_col = j * tile_w
|
| 305 |
+
tile_end_col = tile_start_col + tile_w
|
| 306 |
+
|
| 307 |
+
mosaic[tile_start_row:tile_end_row, tile_start_col:tile_end_col] = self.tile_images[best_tile_idx]
|
| 308 |
+
|
| 309 |
+
# Update usage
|
| 310 |
+
if usage_count is not None:
|
| 311 |
+
tile_name = self.tile_names[best_tile_idx]
|
| 312 |
+
usage_count[tile_name] = usage_count.get(tile_name, 0) + 1
|
| 313 |
+
|
| 314 |
+
print("Contextual mosaic completed")
|
| 315 |
+
return mosaic, context
|
| 316 |
+
|
| 317 |
+
def visualize_context_analysis(self, image: np.ndarray, context: ImageContext):
|
| 318 |
+
"""Display contextual analysis visualization."""
|
| 319 |
+
print("Showing context analysis...")
|
| 320 |
+
|
| 321 |
+
fig, axes = plt.subplots(2, 3, figsize=(18, 12))
|
| 322 |
+
|
| 323 |
+
# Original with face overlay
|
| 324 |
+
img_with_faces = image.copy()
|
| 325 |
+
for (x, y, w, h) in context.face_regions:
|
| 326 |
+
cv2.rectangle(img_with_faces, (x, y), (x+w, y+h), (255, 0, 0), 3)
|
| 327 |
+
|
| 328 |
+
axes[0, 0].imshow(img_with_faces)
|
| 329 |
+
axes[0, 0].set_title(f'Face Detection\n{len(context.face_regions)} faces')
|
| 330 |
+
axes[0, 0].axis('off')
|
| 331 |
+
|
| 332 |
+
# Brightness map
|
| 333 |
+
axes[0, 1].imshow(context.brightness_map, cmap='gray')
|
| 334 |
+
axes[0, 1].set_title('Brightness Map')
|
| 335 |
+
axes[0, 1].axis('off')
|
| 336 |
+
|
| 337 |
+
# Edge density
|
| 338 |
+
axes[0, 2].imshow(context.edge_density_map, cmap='hot')
|
| 339 |
+
axes[0, 2].set_title(f'Edge Density\nComplexity: {context.content_complexity:.3f}')
|
| 340 |
+
axes[0, 2].axis('off')
|
| 341 |
+
|
| 342 |
+
# Dominant colors
|
| 343 |
+
colors_display = context.dominant_colors.reshape(1, -1, 3).astype(np.uint8)
|
| 344 |
+
axes[1, 0].imshow(colors_display)
|
| 345 |
+
axes[1, 0].set_title('Dominant Colors')
|
| 346 |
+
axes[1, 0].axis('off')
|
| 347 |
+
|
| 348 |
+
# Scene info
|
| 349 |
+
scene_text = (f"Scene Analysis:\n\n"
|
| 350 |
+
f"Portrait: {context.is_portrait}\n"
|
| 351 |
+
f"Landscape: {context.is_landscape}\n"
|
| 352 |
+
f"Has Faces: {context.has_faces}\n"
|
| 353 |
+
f"Complexity: {context.content_complexity:.3f}")
|
| 354 |
+
|
| 355 |
+
axes[1, 1].text(0.5, 0.5, scene_text, ha='center', va='center',
|
| 356 |
+
fontsize=14, transform=axes[1, 1].transAxes)
|
| 357 |
+
axes[1, 1].set_title('Scene Classification')
|
| 358 |
+
axes[1, 1].axis('off')
|
| 359 |
+
|
| 360 |
+
# Color palette
|
| 361 |
+
if self.colour_palette is not None:
|
| 362 |
+
palette_display = self.colour_palette.reshape(1, -1, 3).astype(np.uint8)
|
| 363 |
+
axes[1, 2].imshow(palette_display)
|
| 364 |
+
axes[1, 2].set_title('Color Palette')
|
| 365 |
+
axes[1, 2].axis('off')
|
| 366 |
+
|
| 367 |
+
plt.suptitle('Contextual Analysis for Intelligent Tile Selection', fontsize=16)
|
| 368 |
+
plt.tight_layout()
|
| 369 |
+
plt.show()
|
extracted_images/img_2001.png
ADDED
|
extracted_images/img_2002.png
ADDED
|
extracted_images/img_2003.png
ADDED
|
extracted_images/img_2004.png
ADDED
|
extracted_images/img_2005.png
ADDED
|
extracted_images/img_2006.png
ADDED
|
extracted_images/img_2007.png
ADDED
|
extracted_images/img_2008.png
ADDED
|
extracted_images/img_2009.png
ADDED
|
extracted_images/img_2010.png
ADDED
|
extracted_images/img_2011.png
ADDED
|
extracted_images/img_2012.png
ADDED
|
extracted_images/img_2013.png
ADDED
|
extracted_images/img_2014.png
ADDED
|
extracted_images/img_2015.png
ADDED
|
extracted_images/img_2016.png
ADDED
|
extracted_images/img_2017.png
ADDED
|
extracted_images/img_2018.png
ADDED
|
extracted_images/img_2019.png
ADDED
|
extracted_images/img_2020.png
ADDED
|
extracted_images/img_2021.png
ADDED
|
extracted_images/img_2022.png
ADDED
|
extracted_images/img_2023.png
ADDED
|
extracted_images/img_2024.png
ADDED
|
extracted_images/img_2025.png
ADDED
|
extracted_images/img_2026.png
ADDED
|
extracted_images/img_2027.png
ADDED
|
extracted_images/img_2028.png
ADDED
|
extracted_images/img_2029.png
ADDED
|
extracted_images/img_2030.png
ADDED
|
extracted_images/img_2031.png
ADDED
|
extracted_images/img_2032.png
ADDED
|
extracted_images/img_2033.png
ADDED
|
extracted_images/img_2034.png
ADDED
|
extracted_images/img_2035.png
ADDED
|
extracted_images/img_2036.png
ADDED
|
extracted_images/img_2037.png
ADDED
|
extracted_images/img_2038.png
ADDED
|
extracted_images/img_2039.png
ADDED
|
extracted_images/img_2040.png
ADDED
|
extracted_images/img_2041.png
ADDED
|
extracted_images/img_2042.png
ADDED
|