feat: Implement mosaic generation pipeline with performance analysis
Browse files- Added MosaicGenerator class for generating photomosaics with preprocessing, grid analysis, and tile mapping.
- Introduced MosaicPipeline class to manage the complete mosaic generation process, including performance benchmarking and metrics calculation.
- Implemented color quantization methods: uniform quantization and K-means clustering.
- Developed TileManager class for managing image tiles, including loading from datasets and caching mechanisms.
- Enhanced utility functions for image processing, including conversion between PIL and NumPy formats, resizing, and cropping.
- Added comprehensive performance analysis and reporting features for benchmarking different implementations and grid sizes.
- Included test image for demonstration purposes.
- .gitattributes +1 -0
- README.md +6 -7
- app.py +24 -0
- benchmark.py +298 -0
- example.py +167 -0
- helpers/download_tiles.py +21 -0
- preload_tiles.py +38 -0
- profiling_analysis.ipynb +0 -0
- requirements.txt +30 -0
- src/__init__.py +37 -0
- src/config.py +68 -0
- src/gradio_interface.py +386 -0
- src/metrics.py +234 -0
- src/mosaic.py +206 -0
- src/pipeline.py +263 -0
- src/quantization.py +120 -0
- src/tiles.py +370 -0
- src/utils.py +58 -0
- test_images/test.jpeg +0 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
CS5130_Lab_1_Report.pdf filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,12 +1,11 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
|
| 8 |
-
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Mosaic Generator
|
| 3 |
+
emoji: 🧩
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
+
app_file: app.py
|
|
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
|
app.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gradio interface for the Mosaic Generator.
|
| 3 |
+
Allows users to upload images, adjust parameters, and generate mosaic-style images.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import numpy as np
|
| 8 |
+
from PIL import Image
|
| 9 |
+
import time
|
| 10 |
+
import os
|
| 11 |
+
from typing import Tuple, Dict, List
|
| 12 |
+
|
| 13 |
+
from src.gradio_interface import create_interface
|
| 14 |
+
|
| 15 |
+
# Create the interface (this will be available for Gradio auto-reload)
|
| 16 |
+
demo = create_interface()
|
| 17 |
+
|
| 18 |
+
if __name__ == "__main__":
|
| 19 |
+
# Launch the interface
|
| 20 |
+
demo.launch(
|
| 21 |
+
server_name="0.0.0.0",
|
| 22 |
+
server_port=7860,
|
| 23 |
+
share=True
|
| 24 |
+
)
|
benchmark.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Benchmark script for mosaic generation performance analysis.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import time
|
| 7 |
+
import numpy as np
|
| 8 |
+
from PIL import Image
|
| 9 |
+
import matplotlib.pyplot as plt
|
| 10 |
+
from typing import Dict, List
|
| 11 |
+
import argparse
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
from src.config import Config, Implementation
|
| 15 |
+
from src.pipeline import MosaicPipeline
|
| 16 |
+
from src.utils import pil_to_np, np_to_pil
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def create_test_image(width: int = 512, height: int = 512) -> Image.Image:
|
| 20 |
+
"""Create a test image with various features for benchmarking."""
|
| 21 |
+
# Create a colorful test image with gradients and patterns
|
| 22 |
+
img_array = np.zeros((height, width, 3), dtype=np.float32)
|
| 23 |
+
|
| 24 |
+
# Create gradient patterns
|
| 25 |
+
for y in range(height):
|
| 26 |
+
for x in range(width):
|
| 27 |
+
# Red gradient
|
| 28 |
+
img_array[y, x, 0] = x / width
|
| 29 |
+
|
| 30 |
+
# Green gradient
|
| 31 |
+
img_array[y, x, 1] = y / height
|
| 32 |
+
|
| 33 |
+
# Blue pattern
|
| 34 |
+
img_array[y, x, 2] = (x + y) / (width + height)
|
| 35 |
+
|
| 36 |
+
# Add some geometric shapes
|
| 37 |
+
center_x, center_y = width // 2, height // 2
|
| 38 |
+
radius = min(width, height) // 4
|
| 39 |
+
|
| 40 |
+
for y in range(height):
|
| 41 |
+
for x in range(width):
|
| 42 |
+
# Circle
|
| 43 |
+
dist = np.sqrt((x - center_x)**2 + (y - center_y)**2)
|
| 44 |
+
if dist < radius:
|
| 45 |
+
img_array[y, x] = [1.0, 0.5, 0.2] # Orange circle
|
| 46 |
+
|
| 47 |
+
return np_to_pil(img_array)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def benchmark_grid_sizes(pipeline: MosaicPipeline, test_image: Image.Image,
|
| 51 |
+
grid_sizes: List[int]) -> Dict:
|
| 52 |
+
"""Benchmark performance across different grid sizes."""
|
| 53 |
+
print("Benchmarking grid sizes...")
|
| 54 |
+
results = {}
|
| 55 |
+
|
| 56 |
+
for grid_size in grid_sizes:
|
| 57 |
+
print(f"Testing grid size {grid_size}x{grid_size}...")
|
| 58 |
+
|
| 59 |
+
# Update config
|
| 60 |
+
pipeline.config.grid = grid_size
|
| 61 |
+
pipeline.config.out_w = (test_image.width // grid_size) * grid_size
|
| 62 |
+
pipeline.config.out_h = (test_image.height // grid_size) * grid_size
|
| 63 |
+
|
| 64 |
+
# Time the generation
|
| 65 |
+
start_time = time.time()
|
| 66 |
+
pipeline_results = pipeline.run_full_pipeline(test_image)
|
| 67 |
+
total_time = time.time() - start_time
|
| 68 |
+
|
| 69 |
+
results[grid_size] = {
|
| 70 |
+
'processing_time': total_time,
|
| 71 |
+
'total_tiles': grid_size * grid_size,
|
| 72 |
+
'tiles_per_second': (grid_size * grid_size) / total_time,
|
| 73 |
+
'mse': pipeline_results['metrics']['mse'],
|
| 74 |
+
'ssim': pipeline_results['metrics']['ssim'],
|
| 75 |
+
'output_resolution': f"{pipeline_results['outputs']['mosaic'].width}x{pipeline_results['outputs']['mosaic'].height}"
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
print(f" Processing time: {total_time:.3f}s")
|
| 79 |
+
print(f" Tiles per second: {results[grid_size]['tiles_per_second']:.1f}")
|
| 80 |
+
|
| 81 |
+
return results
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def benchmark_implementations(pipeline: MosaicPipeline, test_image: Image.Image) -> Dict:
|
| 85 |
+
"""Compare vectorized vs loop-based implementations."""
|
| 86 |
+
print("Benchmarking implementations...")
|
| 87 |
+
|
| 88 |
+
results = {}
|
| 89 |
+
|
| 90 |
+
# Test vectorized implementation
|
| 91 |
+
print("Testing vectorized implementation...")
|
| 92 |
+
pipeline.config.impl = Implementation.VECT
|
| 93 |
+
start_time = time.time()
|
| 94 |
+
vec_results = pipeline.run_full_pipeline(test_image)
|
| 95 |
+
vec_time = time.time() - start_time
|
| 96 |
+
|
| 97 |
+
results['vectorized'] = {
|
| 98 |
+
'processing_time': vec_time,
|
| 99 |
+
'mse': vec_results['metrics']['mse'],
|
| 100 |
+
'ssim': vec_results['metrics']['ssim']
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
# Test loop-based implementation
|
| 104 |
+
print("Testing loop-based implementation...")
|
| 105 |
+
pipeline.config.impl = Implementation.LOOPS
|
| 106 |
+
start_time = time.time()
|
| 107 |
+
loop_results = pipeline.run_full_pipeline(test_image)
|
| 108 |
+
loop_time = time.time() - start_time
|
| 109 |
+
|
| 110 |
+
results['loop_based'] = {
|
| 111 |
+
'processing_time': loop_time,
|
| 112 |
+
'mse': loop_results['metrics']['mse'],
|
| 113 |
+
'ssim': loop_results['metrics']['ssim']
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
# Calculate comparison
|
| 117 |
+
speedup = loop_time / vec_time if vec_time > 0 else 0
|
| 118 |
+
results['comparison'] = {
|
| 119 |
+
'speedup_factor': speedup,
|
| 120 |
+
'vectorized_faster': vec_time < loop_time
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
print(f"Vectorized: {vec_time:.3f}s")
|
| 124 |
+
print(f"Loop-based: {loop_time:.3f}s")
|
| 125 |
+
print(f"Speedup factor: {speedup:.2f}x")
|
| 126 |
+
|
| 127 |
+
return results
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def plot_benchmark_results(grid_results: Dict, impl_results: Dict, output_dir: str = "images"):
|
| 131 |
+
"""Create plots of benchmark results."""
|
| 132 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 133 |
+
|
| 134 |
+
# Plot 1: Processing time vs grid size
|
| 135 |
+
plt.figure(figsize=(10, 6))
|
| 136 |
+
grid_sizes = sorted(grid_results.keys())
|
| 137 |
+
processing_times = [grid_results[gs]['processing_time'] for gs in grid_sizes]
|
| 138 |
+
total_tiles = [grid_results[gs]['total_tiles'] for gs in grid_sizes]
|
| 139 |
+
|
| 140 |
+
plt.subplot(1, 2, 1)
|
| 141 |
+
plt.plot(grid_sizes, processing_times, 'bo-', linewidth=2, markersize=8)
|
| 142 |
+
plt.xlabel('Grid Size')
|
| 143 |
+
plt.ylabel('Processing Time (seconds)')
|
| 144 |
+
plt.title('Processing Time vs Grid Size')
|
| 145 |
+
plt.grid(True, alpha=0.3)
|
| 146 |
+
|
| 147 |
+
plt.subplot(1, 2, 2)
|
| 148 |
+
plt.plot(total_tiles, processing_times, 'ro-', linewidth=2, markersize=8)
|
| 149 |
+
plt.xlabel('Total Number of Tiles')
|
| 150 |
+
plt.ylabel('Processing Time (seconds)')
|
| 151 |
+
plt.title('Processing Time vs Number of Tiles')
|
| 152 |
+
plt.grid(True, alpha=0.3)
|
| 153 |
+
|
| 154 |
+
plt.tight_layout()
|
| 155 |
+
plt.savefig(f"{output_dir}/processing_time_analysis.png", dpi=300, bbox_inches='tight')
|
| 156 |
+
plt.close()
|
| 157 |
+
|
| 158 |
+
# Plot 2: Quality metrics vs grid size
|
| 159 |
+
plt.figure(figsize=(12, 5))
|
| 160 |
+
|
| 161 |
+
plt.subplot(1, 2, 1)
|
| 162 |
+
mse_values = [grid_results[gs]['mse'] for gs in grid_sizes]
|
| 163 |
+
plt.plot(grid_sizes, mse_values, 'go-', linewidth=2, markersize=8)
|
| 164 |
+
plt.xlabel('Grid Size')
|
| 165 |
+
plt.ylabel('MSE')
|
| 166 |
+
plt.title('Mean Squared Error vs Grid Size')
|
| 167 |
+
plt.grid(True, alpha=0.3)
|
| 168 |
+
plt.yscale('log')
|
| 169 |
+
|
| 170 |
+
plt.subplot(1, 2, 2)
|
| 171 |
+
ssim_values = [grid_results[gs]['ssim'] for gs in grid_sizes]
|
| 172 |
+
plt.plot(grid_sizes, ssim_values, 'mo-', linewidth=2, markersize=8)
|
| 173 |
+
plt.xlabel('Grid Size')
|
| 174 |
+
plt.ylabel('SSIM')
|
| 175 |
+
plt.title('Structural Similarity vs Grid Size')
|
| 176 |
+
plt.grid(True, alpha=0.3)
|
| 177 |
+
|
| 178 |
+
plt.tight_layout()
|
| 179 |
+
plt.savefig(f"{output_dir}/quality_metrics_analysis.png", dpi=300, bbox_inches='tight')
|
| 180 |
+
plt.close()
|
| 181 |
+
|
| 182 |
+
# Plot 3: Implementation comparison
|
| 183 |
+
plt.figure(figsize=(8, 6))
|
| 184 |
+
impl_names = ['Vectorized', 'Loop-based']
|
| 185 |
+
impl_times = [
|
| 186 |
+
impl_results['vectorized']['processing_time'],
|
| 187 |
+
impl_results['loop_based']['processing_time']
|
| 188 |
+
]
|
| 189 |
+
|
| 190 |
+
bars = plt.bar(impl_names, impl_times, color=['skyblue', 'lightcoral'])
|
| 191 |
+
plt.ylabel('Processing Time (seconds)')
|
| 192 |
+
plt.title('Implementation Performance Comparison')
|
| 193 |
+
plt.grid(True, alpha=0.3, axis='y')
|
| 194 |
+
|
| 195 |
+
# Add value labels on bars
|
| 196 |
+
for bar, time_val in zip(bars, impl_times):
|
| 197 |
+
plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.01,
|
| 198 |
+
f'{time_val:.3f}s', ha='center', va='bottom')
|
| 199 |
+
|
| 200 |
+
plt.tight_layout()
|
| 201 |
+
plt.savefig(f"{output_dir}/implementation_comparison.png", dpi=300, bbox_inches='tight')
|
| 202 |
+
plt.close()
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def generate_benchmark_report(grid_results: Dict, impl_results: Dict, output_file: str = "benchmark_report.txt"):
|
| 206 |
+
"""Generate a comprehensive benchmark report."""
|
| 207 |
+
with open(output_file, 'w') as f:
|
| 208 |
+
f.write("MOSAIC GENERATION BENCHMARK REPORT\n")
|
| 209 |
+
f.write("=" * 50 + "\n\n")
|
| 210 |
+
|
| 211 |
+
# Grid size analysis
|
| 212 |
+
f.write("GRID SIZE PERFORMANCE ANALYSIS\n")
|
| 213 |
+
f.write("-" * 30 + "\n")
|
| 214 |
+
for grid_size in sorted(grid_results.keys()):
|
| 215 |
+
result = grid_results[grid_size]
|
| 216 |
+
f.write(f"Grid {grid_size}x{grid_size}:\n")
|
| 217 |
+
f.write(f" Processing Time: {result['processing_time']:.3f}s\n")
|
| 218 |
+
f.write(f" Total Tiles: {result['total_tiles']}\n")
|
| 219 |
+
f.write(f" Tiles per Second: {result['tiles_per_second']:.1f}\n")
|
| 220 |
+
f.write(f" MSE: {result['mse']:.6f}\n")
|
| 221 |
+
f.write(f" SSIM: {result['ssim']:.4f}\n")
|
| 222 |
+
f.write(f" Output Resolution: {result['output_resolution']}\n\n")
|
| 223 |
+
|
| 224 |
+
# Scaling analysis
|
| 225 |
+
grid_sizes = sorted(grid_results.keys())
|
| 226 |
+
if len(grid_sizes) >= 2:
|
| 227 |
+
first_result = grid_results[grid_sizes[0]]
|
| 228 |
+
last_result = grid_results[grid_sizes[-1]]
|
| 229 |
+
|
| 230 |
+
tile_ratio = last_result['total_tiles'] / first_result['total_tiles']
|
| 231 |
+
time_ratio = last_result['processing_time'] / first_result['processing_time']
|
| 232 |
+
|
| 233 |
+
f.write("SCALING ANALYSIS\n")
|
| 234 |
+
f.write("-" * 20 + "\n")
|
| 235 |
+
f.write(f"Tile increase ratio: {tile_ratio:.2f}x\n")
|
| 236 |
+
f.write(f"Time increase ratio: {time_ratio:.2f}x\n")
|
| 237 |
+
f.write(f"Scaling efficiency: {tile_ratio/time_ratio:.2f}\n")
|
| 238 |
+
f.write(f"Linear scaling: {'Yes' if abs(time_ratio - tile_ratio) / tile_ratio < 0.1 else 'No'}\n\n")
|
| 239 |
+
|
| 240 |
+
# Implementation comparison
|
| 241 |
+
f.write("IMPLEMENTATION COMPARISON\n")
|
| 242 |
+
f.write("-" * 25 + "\n")
|
| 243 |
+
f.write(f"Vectorized processing time: {impl_results['vectorized']['processing_time']:.3f}s\n")
|
| 244 |
+
f.write(f"Loop-based processing time: {impl_results['loop_based']['processing_time']:.3f}s\n")
|
| 245 |
+
f.write(f"Speedup factor: {impl_results['comparison']['speedup_factor']:.2f}x\n")
|
| 246 |
+
f.write(f"Vectorized is faster: {'Yes' if impl_results['comparison']['vectorized_faster'] else 'No'}\n\n")
|
| 247 |
+
|
| 248 |
+
# Quality comparison
|
| 249 |
+
f.write("QUALITY COMPARISON\n")
|
| 250 |
+
f.write("-" * 18 + "\n")
|
| 251 |
+
f.write(f"Vectorized MSE: {impl_results['vectorized']['mse']:.6f}\n")
|
| 252 |
+
f.write(f"Loop-based MSE: {impl_results['loop_based']['mse']:.6f}\n")
|
| 253 |
+
f.write(f"Vectorized SSIM: {impl_results['vectorized']['ssim']:.4f}\n")
|
| 254 |
+
f.write(f"Loop-based SSIM: {impl_results['loop_based']['ssim']:.4f}\n")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def main():
|
| 258 |
+
"""Main benchmark function."""
|
| 259 |
+
parser = argparse.ArgumentParser(description='Benchmark mosaic generation performance')
|
| 260 |
+
parser.add_argument('--grid-sizes', nargs='+', type=int, default=[16, 32, 48, 64],
|
| 261 |
+
help='Grid sizes to test (default: 16 32 48 64)')
|
| 262 |
+
parser.add_argument('--output-dir', default='images', help='Output directory for plots')
|
| 263 |
+
parser.add_argument('--test-image', help='Path to test image (optional)')
|
| 264 |
+
args = parser.parse_args()
|
| 265 |
+
|
| 266 |
+
print("Starting mosaic generation benchmark...")
|
| 267 |
+
|
| 268 |
+
# Create test image
|
| 269 |
+
if args.test_image and os.path.exists(args.test_image):
|
| 270 |
+
test_image = Image.open(args.test_image)
|
| 271 |
+
print(f"Using test image: {args.test_image}")
|
| 272 |
+
else:
|
| 273 |
+
test_image = create_test_image()
|
| 274 |
+
print("Using generated test image")
|
| 275 |
+
|
| 276 |
+
# Create pipeline
|
| 277 |
+
config = Config(grid=32) # Default grid size
|
| 278 |
+
pipeline = MosaicPipeline(config)
|
| 279 |
+
|
| 280 |
+
# Run benchmarks
|
| 281 |
+
print("\n" + "="*50)
|
| 282 |
+
grid_results = benchmark_grid_sizes(pipeline, test_image, args.grid_sizes)
|
| 283 |
+
|
| 284 |
+
print("\n" + "="*50)
|
| 285 |
+
impl_results = benchmark_implementations(pipeline, test_image)
|
| 286 |
+
|
| 287 |
+
# Generate plots and report
|
| 288 |
+
print("\nGenerating plots and report...")
|
| 289 |
+
plot_benchmark_results(grid_results, impl_results, args.output_dir)
|
| 290 |
+
generate_benchmark_report(grid_results, impl_results)
|
| 291 |
+
|
| 292 |
+
print(f"\nBenchmark complete!")
|
| 293 |
+
print(f"Plots saved to: {args.output_dir}/")
|
| 294 |
+
print(f"Report saved to: benchmark_report.txt")
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
if __name__ == "__main__":
|
| 298 |
+
main()
|
example.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Example script demonstrating mosaic generation functionality.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
import matplotlib.pyplot as plt
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
from src.config import Config
|
| 12 |
+
from src.pipeline import MosaicPipeline
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def create_sample_image(size=(512, 512)):
|
| 16 |
+
"""Create a sample image with gradients and patterns."""
|
| 17 |
+
img_array = np.zeros((size[1], size[0], 3), dtype=np.float32)
|
| 18 |
+
|
| 19 |
+
# Create gradient patterns
|
| 20 |
+
for y in range(size[1]):
|
| 21 |
+
for x in range(size[0]):
|
| 22 |
+
# Red gradient
|
| 23 |
+
img_array[y, x, 0] = x / size[0]
|
| 24 |
+
|
| 25 |
+
# Green gradient
|
| 26 |
+
img_array[y, x, 1] = y / size[1]
|
| 27 |
+
|
| 28 |
+
# Blue pattern
|
| 29 |
+
img_array[y, x, 2] = (x + y) / (size[0] + size[1])
|
| 30 |
+
|
| 31 |
+
# Add geometric shapes
|
| 32 |
+
center_x, center_y = size[0] // 2, size[1] // 2
|
| 33 |
+
radius = min(size) // 4
|
| 34 |
+
|
| 35 |
+
for y in range(size[1]):
|
| 36 |
+
for x in range(size[0]):
|
| 37 |
+
# Circle
|
| 38 |
+
dist = np.sqrt((x - center_x)**2 + (y - center_y)**2)
|
| 39 |
+
if dist < radius:
|
| 40 |
+
img_array[y, x] = [1.0, 0.5, 0.2] # Orange circle
|
| 41 |
+
|
| 42 |
+
return Image.fromarray((img_array * 255).astype(np.uint8))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def demonstrate_mosaic_generation():
|
| 46 |
+
"""Demonstrate mosaic generation with different configurations."""
|
| 47 |
+
|
| 48 |
+
print("🎨 Mosaic Generator Example")
|
| 49 |
+
print("=" * 40)
|
| 50 |
+
|
| 51 |
+
# Create sample image
|
| 52 |
+
print("Creating sample image...")
|
| 53 |
+
sample_img = create_sample_image()
|
| 54 |
+
os.makedirs("images", exist_ok=True)
|
| 55 |
+
sample_img.save("images/sample_input.png")
|
| 56 |
+
print("✅ Sample image saved to images/sample_input.png")
|
| 57 |
+
|
| 58 |
+
# Test different grid sizes
|
| 59 |
+
grid_sizes = [16, 32, 48]
|
| 60 |
+
|
| 61 |
+
for grid_size in grid_sizes:
|
| 62 |
+
print(f"\nGenerating mosaic with {grid_size}x{grid_size} grid...")
|
| 63 |
+
|
| 64 |
+
# Create configuration
|
| 65 |
+
config = Config(
|
| 66 |
+
grid=grid_size,
|
| 67 |
+
tile_size=32,
|
| 68 |
+
out_w=512,
|
| 69 |
+
out_h=512
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# Create pipeline
|
| 73 |
+
pipeline = MosaicPipeline(config)
|
| 74 |
+
|
| 75 |
+
# Generate mosaic
|
| 76 |
+
results = pipeline.run_full_pipeline(sample_img)
|
| 77 |
+
|
| 78 |
+
# Save results
|
| 79 |
+
mosaic_img = results['outputs']['mosaic']
|
| 80 |
+
processed_img = results['outputs']['processed_image']
|
| 81 |
+
|
| 82 |
+
mosaic_img.save(f"images/mosaic_{grid_size}x{grid_size}.png")
|
| 83 |
+
processed_img.save(f"images/processed_{grid_size}x{grid_size}.png")
|
| 84 |
+
|
| 85 |
+
# Print metrics
|
| 86 |
+
metrics = results['metrics']
|
| 87 |
+
timing = results['timing']
|
| 88 |
+
|
| 89 |
+
print(f"✅ Mosaic saved to images/mosaic_{grid_size}x{grid_size}.png")
|
| 90 |
+
print(f" Processing time: {timing['total']:.3f}s")
|
| 91 |
+
print(f" MSE: {metrics['mse']:.6f}")
|
| 92 |
+
print(f" SSIM: {metrics['ssim']:.4f}")
|
| 93 |
+
|
| 94 |
+
# Test implementation comparison
|
| 95 |
+
print(f"\nComparing implementations...")
|
| 96 |
+
|
| 97 |
+
config_vect = Config(grid=32, tile_size=32, out_w=512, out_h=512, impl="Vectorised")
|
| 98 |
+
config_loop = Config(grid=32, tile_size=32, out_w=512, out_h=512, impl="Loops")
|
| 99 |
+
|
| 100 |
+
pipeline_vect = MosaicPipeline(config_vect)
|
| 101 |
+
pipeline_loop = MosaicPipeline(config_loop)
|
| 102 |
+
|
| 103 |
+
import time
|
| 104 |
+
|
| 105 |
+
# Time vectorized
|
| 106 |
+
start = time.time()
|
| 107 |
+
results_vect = pipeline_vect.run_full_pipeline(sample_img)
|
| 108 |
+
time_vect = time.time() - start
|
| 109 |
+
|
| 110 |
+
# Time loop-based
|
| 111 |
+
start = time.time()
|
| 112 |
+
results_loop = pipeline_loop.run_full_pipeline(sample_img)
|
| 113 |
+
time_loop = time.time() - start
|
| 114 |
+
|
| 115 |
+
speedup = time_loop / time_vect if time_vect > 0 else 0
|
| 116 |
+
|
| 117 |
+
print(f"✅ Vectorized: {time_vect:.3f}s")
|
| 118 |
+
print(f"✅ Loop-based: {time_loop:.3f}s")
|
| 119 |
+
print(f"✅ Speedup: {speedup:.2f}x")
|
| 120 |
+
|
| 121 |
+
# Create comparison visualization
|
| 122 |
+
fig, axes = plt.subplots(2, 3, figsize=(15, 10))
|
| 123 |
+
|
| 124 |
+
# Original image
|
| 125 |
+
axes[0, 0].imshow(sample_img)
|
| 126 |
+
axes[0, 0].set_title("Original Image")
|
| 127 |
+
axes[0, 0].axis('off')
|
| 128 |
+
|
| 129 |
+
# 16x16 mosaic
|
| 130 |
+
mosaic_16 = Image.open("images/mosaic_16x16.png")
|
| 131 |
+
axes[0, 1].imshow(mosaic_16)
|
| 132 |
+
axes[0, 1].set_title("16×16 Grid Mosaic")
|
| 133 |
+
axes[0, 1].axis('off')
|
| 134 |
+
|
| 135 |
+
# 32x32 mosaic
|
| 136 |
+
mosaic_32 = Image.open("images/mosaic_32x32.png")
|
| 137 |
+
axes[0, 2].imshow(mosaic_32)
|
| 138 |
+
axes[0, 2].set_title("32×32 Grid Mosaic")
|
| 139 |
+
axes[0, 2].axis('off')
|
| 140 |
+
|
| 141 |
+
# 48x48 mosaic
|
| 142 |
+
mosaic_48 = Image.open("images/mosaic_48x48.png")
|
| 143 |
+
axes[1, 0].imshow(mosaic_48)
|
| 144 |
+
axes[1, 0].set_title("48×48 Grid Mosaic")
|
| 145 |
+
axes[1, 0].axis('off')
|
| 146 |
+
|
| 147 |
+
# Vectorized result
|
| 148 |
+
axes[1, 1].imshow(results_vect['outputs']['mosaic'])
|
| 149 |
+
axes[1, 1].set_title(f"Vectorized ({time_vect:.3f}s)")
|
| 150 |
+
axes[1, 1].axis('off')
|
| 151 |
+
|
| 152 |
+
# Loop-based result
|
| 153 |
+
axes[1, 2].imshow(results_loop['outputs']['mosaic'])
|
| 154 |
+
axes[1, 2].set_title(f"Loop-based ({time_loop:.3f}s)")
|
| 155 |
+
axes[1, 2].axis('off')
|
| 156 |
+
|
| 157 |
+
plt.tight_layout()
|
| 158 |
+
plt.savefig("images/mosaic_comparison.png", dpi=300, bbox_inches='tight')
|
| 159 |
+
plt.close()
|
| 160 |
+
|
| 161 |
+
print(f"\n✅ Comparison visualization saved to images/mosaic_comparison.png")
|
| 162 |
+
|
| 163 |
+
print(f"\n🎉 Example complete! Check the 'images' folder for results.")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
if __name__ == "__main__":
|
| 167 |
+
demonstrate_mosaic_generation()
|
helpers/download_tiles.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from datasets import load_dataset
|
| 3 |
+
from PIL import Image
|
| 4 |
+
|
| 5 |
+
def save_hf_tiles(dataset="Kratos-AI/KAI_car-images", split="train", out_dir="tiles", tile_size=32, limit=300):
|
| 6 |
+
ds = load_dataset(dataset, split=split)
|
| 7 |
+
out = Path(out_dir); out.mkdir(parents=True, exist_ok=True)
|
| 8 |
+
n = 0
|
| 9 |
+
for i, s in enumerate(ds):
|
| 10 |
+
try:
|
| 11 |
+
im = s["image"].convert("RGB").resize((tile_size, tile_size), Image.LANCZOS)
|
| 12 |
+
im.save(out / f"hf_{i:05d}.jpg", quality=90)
|
| 13 |
+
n += 1
|
| 14 |
+
if limit and n >= limit:
|
| 15 |
+
break
|
| 16 |
+
except Exception:
|
| 17 |
+
pass
|
| 18 |
+
print(f"Saved {n} tiles → {out}/")
|
| 19 |
+
|
| 20 |
+
if __name__ == "__main__":
|
| 21 |
+
save_hf_tiles()
|
preload_tiles.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Script to pre-load tiles for faster first-time mosaic generation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import time
|
| 7 |
+
from src.config import Config
|
| 8 |
+
from src.tiles import TileManager
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def preload_tiles():
|
| 12 |
+
"""Pre-load tiles to cache them for faster subsequent use."""
|
| 13 |
+
print("🔄 Pre-loading tiles for faster mosaic generation...")
|
| 14 |
+
print("This will download a small set of tiles from Hugging Face.")
|
| 15 |
+
|
| 16 |
+
# Create configuration with default settings
|
| 17 |
+
config = Config(
|
| 18 |
+
grid=32,
|
| 19 |
+
tile_size=32,
|
| 20 |
+
hf_limit=50 # Load 50 tiles for good variety
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# Create tile manager - this will trigger the loading
|
| 24 |
+
start_time = time.time()
|
| 25 |
+
tile_manager = TileManager(config)
|
| 26 |
+
|
| 27 |
+
# Force tile loading by calling get_tile_count
|
| 28 |
+
tile_count = tile_manager.get_tile_count()
|
| 29 |
+
loading_time = time.time() - start_time
|
| 30 |
+
|
| 31 |
+
print(f"✅ Successfully loaded {tile_count} tiles in {loading_time:.2f} seconds")
|
| 32 |
+
print("🎉 Tiles are now cached! Mosaic generation will be much faster.")
|
| 33 |
+
print("\nYou can now run the app with:")
|
| 34 |
+
print(" python app.py")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
if __name__ == "__main__":
|
| 38 |
+
preload_tiles()
|
profiling_analysis.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core dependencies
|
| 2 |
+
numpy>=1.21.0
|
| 3 |
+
Pillow>=9.0.0
|
| 4 |
+
|
| 5 |
+
# Image processing and computer vision
|
| 6 |
+
scikit-image>=0.19.0
|
| 7 |
+
scipy>=1.9.0
|
| 8 |
+
|
| 9 |
+
# Machine learning
|
| 10 |
+
scikit-learn>=1.1.0
|
| 11 |
+
|
| 12 |
+
# Web interface
|
| 13 |
+
gradio>=4.0.0
|
| 14 |
+
|
| 15 |
+
# Hugging Face datasets
|
| 16 |
+
datasets>=2.0.0
|
| 17 |
+
huggingface-hub>=0.16.0
|
| 18 |
+
|
| 19 |
+
# Visualization and plotting
|
| 20 |
+
matplotlib>=3.5.0
|
| 21 |
+
|
| 22 |
+
# Standard library modules (no installation needed)
|
| 23 |
+
# - typing (built-in)
|
| 24 |
+
# - dataclasses (built-in)
|
| 25 |
+
# - enum (built-in)
|
| 26 |
+
# - time (built-in)
|
| 27 |
+
# - os (built-in)
|
| 28 |
+
# - pickle (built-in)
|
| 29 |
+
# - pathlib (built-in)
|
| 30 |
+
# - argparse (built-in)
|
src/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Mosaic Generator Package
|
| 3 |
+
|
| 4 |
+
A comprehensive system for generating mosaic-style images from input photographs
|
| 5 |
+
using advanced image processing techniques and vectorized operations.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
__version__ = "1.0.0"
|
| 9 |
+
__author__ = "CS5130 Assignment"
|
| 10 |
+
|
| 11 |
+
from .config import Config, Implementation, MatchSpace
|
| 12 |
+
from .mosaic import MosaicGenerator
|
| 13 |
+
from .tiles import TileManager
|
| 14 |
+
from .quantization import apply_color_quantization, apply_uniform_quantization, apply_kmeans_quantization
|
| 15 |
+
from .metrics import calculate_comprehensive_metrics, calculate_mse, calculate_ssim, calculate_psnr
|
| 16 |
+
from .pipeline import MosaicPipeline
|
| 17 |
+
from .utils import pil_to_np, np_to_pil, resize_and_crop_to_grid, cell_means
|
| 18 |
+
|
| 19 |
+
__all__ = [
|
| 20 |
+
'Config',
|
| 21 |
+
'Implementation',
|
| 22 |
+
'MatchSpace',
|
| 23 |
+
'MosaicGenerator',
|
| 24 |
+
'TileManager',
|
| 25 |
+
'apply_color_quantization',
|
| 26 |
+
'apply_uniform_quantization',
|
| 27 |
+
'apply_kmeans_quantization',
|
| 28 |
+
'calculate_comprehensive_metrics',
|
| 29 |
+
'calculate_mse',
|
| 30 |
+
'calculate_ssim',
|
| 31 |
+
'calculate_psnr',
|
| 32 |
+
'MosaicPipeline',
|
| 33 |
+
'pil_to_np',
|
| 34 |
+
'np_to_pil',
|
| 35 |
+
'resize_and_crop_to_grid',
|
| 36 |
+
'cell_means'
|
| 37 |
+
]
|
src/config.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from enum import Enum
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
|
| 6 |
+
class Implementation(Enum):
|
| 7 |
+
VECT = "Vectorised"
|
| 8 |
+
|
| 9 |
+
class MatchSpace(Enum):
|
| 10 |
+
LAB = "Lab (perceptual)"
|
| 11 |
+
RGB = "RGB (euclidean)"
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class Config:
|
| 15 |
+
"""Runtime configuration for the Lab 5 mosaic generator pipeline."""
|
| 16 |
+
|
| 17 |
+
# Core
|
| 18 |
+
grid: int = 32
|
| 19 |
+
out_w: int = 768
|
| 20 |
+
out_h: int = 768
|
| 21 |
+
tile_size: int = 32
|
| 22 |
+
|
| 23 |
+
# Hugging Face tile source (always used)
|
| 24 |
+
hf_dataset: str = "Kratos-AI/KAI_car-images"
|
| 25 |
+
hf_split: str = "train"
|
| 26 |
+
hf_limit: int = 200 # Increased for better tile diversity
|
| 27 |
+
hf_cache_dir: Optional[str] = None
|
| 28 |
+
|
| 29 |
+
# Pipeline
|
| 30 |
+
impl: Implementation = Implementation.VECT
|
| 31 |
+
match_space: MatchSpace = MatchSpace.LAB
|
| 32 |
+
|
| 33 |
+
# Quantization
|
| 34 |
+
use_uniform_q: bool = False
|
| 35 |
+
q_levels: int = 8
|
| 36 |
+
use_kmeans_q: bool = False
|
| 37 |
+
k_colors: int = 8
|
| 38 |
+
|
| 39 |
+
# Creative
|
| 40 |
+
tile_norm_brightness: bool = False
|
| 41 |
+
allow_rotations: bool = False
|
| 42 |
+
|
| 43 |
+
# Caching
|
| 44 |
+
tiles_cache_dir: Optional[str] = None
|
| 45 |
+
|
| 46 |
+
# Benchmark
|
| 47 |
+
do_bench: bool = False
|
| 48 |
+
bench_grids: Optional[List[int]] = None
|
| 49 |
+
|
| 50 |
+
def __post_init__(self) -> None:
|
| 51 |
+
self._validate()
|
| 52 |
+
|
| 53 |
+
def validate(self) -> None:
|
| 54 |
+
"""Public wrapper that re-validates the current configuration."""
|
| 55 |
+
self._validate()
|
| 56 |
+
|
| 57 |
+
def _validate(self) -> None:
|
| 58 |
+
"""Validate numeric parameters so incorrect grids fail fast."""
|
| 59 |
+
if self.grid <= 0:
|
| 60 |
+
raise ValueError("grid must be a positive integer")
|
| 61 |
+
if self.tile_size <= 0:
|
| 62 |
+
raise ValueError("tile_size must be a positive integer")
|
| 63 |
+
if self.out_w <= 0 or self.out_h <= 0:
|
| 64 |
+
raise ValueError("out_w and out_h must be positive integers")
|
| 65 |
+
if self.out_w % self.grid != 0 or self.out_h % self.grid != 0:
|
| 66 |
+
raise ValueError("out_w and out_h must be divisible by grid to maintain whole tiles")
|
| 67 |
+
if self.hf_limit <= 0:
|
| 68 |
+
raise ValueError("hf_limit must be positive")
|
src/gradio_interface.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gradio interface functions for the Mosaic Generator.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
import time
|
| 9 |
+
from typing import Tuple, Dict, List
|
| 10 |
+
|
| 11 |
+
from .config import Config, Implementation, MatchSpace
|
| 12 |
+
from .pipeline import MosaicPipeline
|
| 13 |
+
from .metrics import calculate_comprehensive_metrics, interpret_metrics
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def create_default_config(
|
| 17 |
+
grid_size: int = 32,
|
| 18 |
+
tile_size: int = 32,
|
| 19 |
+
output_width: int = 768,
|
| 20 |
+
output_height: int = 768,
|
| 21 |
+
color_matching: str = "Lab (perceptual)",
|
| 22 |
+
use_uniform_quantization: bool = False,
|
| 23 |
+
quantization_levels: int = 8,
|
| 24 |
+
use_kmeans_quantization: bool = False,
|
| 25 |
+
kmeans_colors: int = 8,
|
| 26 |
+
normalize_tile_brightness: bool = False
|
| 27 |
+
) -> Config:
|
| 28 |
+
"""Create configuration from Gradio interface parameters."""
|
| 29 |
+
|
| 30 |
+
# Convert string parameters to enums
|
| 31 |
+
match_space = MatchSpace.LAB if color_matching == "Lab (perceptual)" else MatchSpace.RGB
|
| 32 |
+
|
| 33 |
+
return Config(
|
| 34 |
+
grid=grid_size,
|
| 35 |
+
tile_size=tile_size,
|
| 36 |
+
out_w=output_width,
|
| 37 |
+
out_h=output_height,
|
| 38 |
+
impl=Implementation.VECT, # Always use vectorized
|
| 39 |
+
match_space=match_space,
|
| 40 |
+
use_uniform_q=use_uniform_quantization,
|
| 41 |
+
q_levels=quantization_levels,
|
| 42 |
+
use_kmeans_q=use_kmeans_quantization,
|
| 43 |
+
k_colors=kmeans_colors,
|
| 44 |
+
tile_norm_brightness=normalize_tile_brightness
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def generate_mosaic(
|
| 49 |
+
image: Image.Image,
|
| 50 |
+
grid_size: int,
|
| 51 |
+
tile_size: int,
|
| 52 |
+
output_width: int,
|
| 53 |
+
output_height: int,
|
| 54 |
+
color_matching: str,
|
| 55 |
+
use_uniform_quantization: bool,
|
| 56 |
+
quantization_levels: int,
|
| 57 |
+
use_kmeans_quantization: bool,
|
| 58 |
+
kmeans_colors: int,
|
| 59 |
+
normalize_tile_brightness: bool,
|
| 60 |
+
progress=gr.Progress()
|
| 61 |
+
) -> Tuple[Image.Image, Image.Image, str, str]:
|
| 62 |
+
"""
|
| 63 |
+
Generate mosaic from input image with given parameters.
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
Tuple of (mosaic_image, processed_image, metrics_text, timing_text)
|
| 67 |
+
"""
|
| 68 |
+
if image is None:
|
| 69 |
+
return None, None, "Please upload an image.", ""
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
# Create configuration
|
| 73 |
+
config = create_default_config(
|
| 74 |
+
grid_size, tile_size, output_width, output_height,
|
| 75 |
+
color_matching, use_uniform_quantization,
|
| 76 |
+
quantization_levels, use_kmeans_quantization, kmeans_colors,
|
| 77 |
+
normalize_tile_brightness
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Create pipeline
|
| 81 |
+
pipeline = MosaicPipeline(config)
|
| 82 |
+
|
| 83 |
+
# Update progress
|
| 84 |
+
progress(0.1, desc="Initializing pipeline...")
|
| 85 |
+
|
| 86 |
+
# Run pipeline
|
| 87 |
+
progress(0.2, desc="Loading tiles (first time only)...")
|
| 88 |
+
progress(0.4, desc="Generating mosaic...")
|
| 89 |
+
results = pipeline.run_full_pipeline(image)
|
| 90 |
+
|
| 91 |
+
progress(0.7, desc="Calculating metrics...")
|
| 92 |
+
|
| 93 |
+
# Extract results
|
| 94 |
+
mosaic_img = results['outputs']['mosaic']
|
| 95 |
+
processed_img = results['outputs']['processed_image']
|
| 96 |
+
|
| 97 |
+
# Format metrics
|
| 98 |
+
metrics = results['metrics']
|
| 99 |
+
interpretations = results['metrics_interpretation']
|
| 100 |
+
|
| 101 |
+
metrics_text = f"""
|
| 102 |
+
**Quality Metrics:**
|
| 103 |
+
- **MSE (Mean Squared Error):** {metrics['mse']:.6f} - {interpretations['mse']}
|
| 104 |
+
- **PSNR (Peak Signal-to-Noise Ratio):** {metrics['psnr']:.2f} dB - {interpretations['psnr']}
|
| 105 |
+
- **SSIM (Structural Similarity):** {metrics['ssim']:.4f} - {interpretations['ssim']}
|
| 106 |
+
- **RMSE (Root Mean Squared Error):** {metrics['rmse']:.6f}
|
| 107 |
+
- **MAE (Mean Absolute Error):** {metrics['mae']:.6f}
|
| 108 |
+
|
| 109 |
+
**Color Analysis:**
|
| 110 |
+
- **Color MSE:** {metrics['color_mse']:.6f}
|
| 111 |
+
- **Histogram Correlation:** {metrics['histogram_correlation']:.4f}
|
| 112 |
+
"""
|
| 113 |
+
|
| 114 |
+
# Format timing information
|
| 115 |
+
timing = results['timing']
|
| 116 |
+
timing_text = f"""
|
| 117 |
+
**Processing Times:**
|
| 118 |
+
- **Preprocessing:** {timing['preprocessing']:.3f} seconds
|
| 119 |
+
- **Grid Analysis:** {timing['grid_analysis']:.3f} seconds
|
| 120 |
+
- **Tile Mapping:** {timing['tile_mapping']:.3f} seconds
|
| 121 |
+
- **Total Time:** {timing['total']:.3f} seconds
|
| 122 |
+
|
| 123 |
+
**Configuration:**
|
| 124 |
+
- **Grid Size:** {config.grid}x{config.grid} ({config.grid**2} tiles total)
|
| 125 |
+
- **Tile Size:** {config.tile_size}x{config.tile_size} pixels
|
| 126 |
+
- **Output Resolution:** {mosaic_img.width}x{mosaic_img.height}
|
| 127 |
+
- **Implementation:** {config.impl.value}
|
| 128 |
+
- **Color Matching:** {config.match_space.value}
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
progress(1.0, desc="Complete!")
|
| 132 |
+
|
| 133 |
+
return mosaic_img, processed_img, metrics_text, timing_text
|
| 134 |
+
|
| 135 |
+
except Exception as e:
|
| 136 |
+
error_msg = f"Error generating mosaic: {str(e)}"
|
| 137 |
+
print(error_msg)
|
| 138 |
+
return None, None, error_msg, ""
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def benchmark_grid_sizes(
|
| 144 |
+
image: Image.Image,
|
| 145 |
+
grid_sizes: str,
|
| 146 |
+
progress=gr.Progress()
|
| 147 |
+
) -> str:
|
| 148 |
+
"""Benchmark different grid sizes."""
|
| 149 |
+
if image is None:
|
| 150 |
+
return "Please upload an image for benchmarking."
|
| 151 |
+
|
| 152 |
+
try:
|
| 153 |
+
# Parse grid sizes
|
| 154 |
+
sizes = [int(x.strip()) for x in grid_sizes.split(',')]
|
| 155 |
+
|
| 156 |
+
results = []
|
| 157 |
+
total_tests = len(sizes)
|
| 158 |
+
|
| 159 |
+
for i, grid_size in enumerate(sizes):
|
| 160 |
+
progress((i + 1) / total_tests, desc=f"Testing grid size {grid_size}x{grid_size}...")
|
| 161 |
+
|
| 162 |
+
config = create_default_config(grid_size, 32, 768, 768)
|
| 163 |
+
pipeline = MosaicPipeline(config)
|
| 164 |
+
|
| 165 |
+
start_time = time.time()
|
| 166 |
+
pipeline_results = pipeline.run_full_pipeline(image)
|
| 167 |
+
processing_time = time.time() - start_time
|
| 168 |
+
|
| 169 |
+
results.append({
|
| 170 |
+
'grid_size': grid_size,
|
| 171 |
+
'processing_time': processing_time,
|
| 172 |
+
'total_tiles': grid_size * grid_size,
|
| 173 |
+
'tiles_per_second': (grid_size * grid_size) / processing_time,
|
| 174 |
+
'mse': pipeline_results['metrics']['mse'],
|
| 175 |
+
'ssim': pipeline_results['metrics']['ssim']
|
| 176 |
+
})
|
| 177 |
+
|
| 178 |
+
# Generate report
|
| 179 |
+
report = "**Grid Size Performance Analysis:**\n\n"
|
| 180 |
+
|
| 181 |
+
for result in results:
|
| 182 |
+
report += f"**Grid {result['grid_size']}x{result['grid_size']}:**\n"
|
| 183 |
+
report += f"- Processing Time: {result['processing_time']:.3f}s\n"
|
| 184 |
+
report += f"- Total Tiles: {result['total_tiles']}\n"
|
| 185 |
+
report += f"- Tiles per Second: {result['tiles_per_second']:.1f}\n"
|
| 186 |
+
report += f"- MSE: {result['mse']:.6f}\n"
|
| 187 |
+
report += f"- SSIM: {result['ssim']:.4f}\n\n"
|
| 188 |
+
|
| 189 |
+
# Scaling analysis
|
| 190 |
+
if len(results) >= 2:
|
| 191 |
+
first = results[0]
|
| 192 |
+
last = results[-1]
|
| 193 |
+
tile_ratio = last['total_tiles'] / first['total_tiles']
|
| 194 |
+
time_ratio = last['processing_time'] / first['processing_time']
|
| 195 |
+
|
| 196 |
+
report += "**Scaling Analysis:**\n"
|
| 197 |
+
report += f"- Tile increase ratio: {tile_ratio:.2f}x\n"
|
| 198 |
+
report += f"- Time increase ratio: {time_ratio:.2f}x\n"
|
| 199 |
+
report += f"- Scaling efficiency: {tile_ratio/time_ratio:.2f}\n"
|
| 200 |
+
report += f"- Linear scaling: {'Yes' if abs(time_ratio - tile_ratio) / tile_ratio < 0.1 else 'No'}\n"
|
| 201 |
+
|
| 202 |
+
return report
|
| 203 |
+
|
| 204 |
+
except Exception as e:
|
| 205 |
+
return f"Error during grid size benchmarking: {str(e)}"
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def create_interface():
|
| 209 |
+
"""Create the Gradio interface."""
|
| 210 |
+
|
| 211 |
+
with gr.Blocks(title="Mosaic Generator", theme=gr.themes.Soft()) as demo:
|
| 212 |
+
gr.Markdown("# 🎨 Mosaic Generator")
|
| 213 |
+
gr.Markdown("Generate beautiful mosaic-style images from your photos using advanced image processing techniques.")
|
| 214 |
+
|
| 215 |
+
with gr.Tab("Generate Mosaic"):
|
| 216 |
+
with gr.Row():
|
| 217 |
+
with gr.Column(scale=1):
|
| 218 |
+
# Input controls
|
| 219 |
+
gr.Markdown("## Upload & Configure")
|
| 220 |
+
|
| 221 |
+
input_image = gr.Image(
|
| 222 |
+
type="pil",
|
| 223 |
+
label="Upload Image",
|
| 224 |
+
height=300
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
with gr.Accordion("Basic Settings", open=True):
|
| 228 |
+
grid_size = gr.Slider(
|
| 229 |
+
minimum=8, maximum=128, step=8, value=32,
|
| 230 |
+
label="Grid Size (N×N tiles)"
|
| 231 |
+
)
|
| 232 |
+
tile_size = gr.Slider(
|
| 233 |
+
minimum=4, maximum=64, step=4, value=32,
|
| 234 |
+
label="Tile Size (pixels)"
|
| 235 |
+
)
|
| 236 |
+
output_width = gr.Slider(
|
| 237 |
+
minimum=256, maximum=1024, step=64, value=768,
|
| 238 |
+
label="Output Width"
|
| 239 |
+
)
|
| 240 |
+
output_height = gr.Slider(
|
| 241 |
+
minimum=256, maximum=1024, step=64, value=768,
|
| 242 |
+
label="Output Height"
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
with gr.Accordion("Advanced Settings", open=False):
|
| 246 |
+
color_matching = gr.Radio(
|
| 247 |
+
choices=["Lab (perceptual)", "RGB (euclidean)"],
|
| 248 |
+
value="Lab (perceptual)",
|
| 249 |
+
label="Color Matching Space"
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
gr.Markdown("**Color Quantization:**")
|
| 253 |
+
use_uniform_quantization = gr.Checkbox(
|
| 254 |
+
label="Use Uniform Quantization",
|
| 255 |
+
value=False
|
| 256 |
+
)
|
| 257 |
+
quantization_levels = gr.Slider(
|
| 258 |
+
minimum=4, maximum=16, step=2, value=8,
|
| 259 |
+
label="Quantization Levels",
|
| 260 |
+
visible=True
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
use_kmeans_quantization = gr.Checkbox(
|
| 264 |
+
label="Use K-means Quantization",
|
| 265 |
+
value=False
|
| 266 |
+
)
|
| 267 |
+
kmeans_colors = gr.Slider(
|
| 268 |
+
minimum=4, maximum=32, step=2, value=8,
|
| 269 |
+
label="K-means Colors"
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
normalize_tile_brightness = gr.Checkbox(
|
| 273 |
+
label="Normalize Tile Brightness",
|
| 274 |
+
value=False
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
generate_btn = gr.Button("Generate Mosaic", variant="primary", size="lg")
|
| 278 |
+
|
| 279 |
+
with gr.Column(scale=2):
|
| 280 |
+
# Output display
|
| 281 |
+
gr.Markdown("## Results")
|
| 282 |
+
|
| 283 |
+
with gr.Row():
|
| 284 |
+
mosaic_output = gr.Image(
|
| 285 |
+
label="Generated Mosaic",
|
| 286 |
+
height=400
|
| 287 |
+
)
|
| 288 |
+
processed_output = gr.Image(
|
| 289 |
+
label="Processed Input",
|
| 290 |
+
height=400
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
with gr.Row():
|
| 294 |
+
metrics_output = gr.Markdown(label="Quality Metrics")
|
| 295 |
+
timing_output = gr.Markdown(label="Processing Information")
|
| 296 |
+
|
| 297 |
+
with gr.Tab("Performance Analysis"):
|
| 298 |
+
gr.Markdown("## Performance Benchmarking")
|
| 299 |
+
|
| 300 |
+
with gr.Row():
|
| 301 |
+
with gr.Column():
|
| 302 |
+
benchmark_image = gr.Image(
|
| 303 |
+
type="pil",
|
| 304 |
+
label="Image for Benchmarking",
|
| 305 |
+
height=200
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
gr.Markdown("### Grid Size Benchmarking")
|
| 309 |
+
grid_sizes_input = gr.Textbox(
|
| 310 |
+
value="16,32,48,64",
|
| 311 |
+
label="Grid Sizes (comma-separated)",
|
| 312 |
+
placeholder="16,32,48,64"
|
| 313 |
+
)
|
| 314 |
+
benchmark_grid_btn = gr.Button("Benchmark Grid Sizes", variant="secondary")
|
| 315 |
+
|
| 316 |
+
with gr.Column():
|
| 317 |
+
benchmark_output = gr.Markdown(label="Benchmark Results")
|
| 318 |
+
|
| 319 |
+
with gr.Tab("About"):
|
| 320 |
+
gr.Markdown("""
|
| 321 |
+
## About the Mosaic Generator
|
| 322 |
+
|
| 323 |
+
This application implements a complete mosaic generation pipeline with the following features:
|
| 324 |
+
|
| 325 |
+
**Note**: The first time you generate a mosaic, it will load tiles from the Hugging Face dataset. This may take a few moments, but subsequent generations will be much faster as tiles are cached.
|
| 326 |
+
|
| 327 |
+
### Core Functionality
|
| 328 |
+
- **Image Preprocessing**: Resize and crop images to fit grid requirements
|
| 329 |
+
- **Color Quantization**: Optional uniform and K-means quantization
|
| 330 |
+
- **Grid Analysis**: Vectorized operations for efficient processing
|
| 331 |
+
- **Tile Mapping**: Replace grid cells with matching image tiles
|
| 332 |
+
- **Quality Metrics**: MSE, PSNR, SSIM, and color similarity analysis
|
| 333 |
+
|
| 334 |
+
### Performance Features
|
| 335 |
+
- **Vectorized Operations**: NumPy-based efficient processing
|
| 336 |
+
- **Grid Size Benchmarking**: Performance analysis across different resolutions
|
| 337 |
+
- **Real-time Metrics**: Processing time and quality measurements
|
| 338 |
+
|
| 339 |
+
### Technical Details
|
| 340 |
+
- Uses Hugging Face datasets for tile sources
|
| 341 |
+
- Supports LAB and RGB color space matching
|
| 342 |
+
- Configurable grid sizes from 8×8 to 128×128
|
| 343 |
+
- Adjustable tile sizes and output resolutions
|
| 344 |
+
|
| 345 |
+
### Assignment Requirements Met
|
| 346 |
+
✅ Image selection and preprocessing
|
| 347 |
+
✅ Grid division and thresholding
|
| 348 |
+
✅ Vectorized NumPy operations
|
| 349 |
+
✅ Tile mapping and replacement
|
| 350 |
+
✅ Gradio interface with parameter controls
|
| 351 |
+
✅ Similarity metrics (MSE, SSIM)
|
| 352 |
+
✅ Performance analysis and benchmarking
|
| 353 |
+
""")
|
| 354 |
+
|
| 355 |
+
# Event handlers
|
| 356 |
+
generate_btn.click(
|
| 357 |
+
fn=generate_mosaic,
|
| 358 |
+
inputs=[
|
| 359 |
+
input_image, grid_size, tile_size, output_width, output_height,
|
| 360 |
+
color_matching, use_uniform_quantization,
|
| 361 |
+
quantization_levels, use_kmeans_quantization, kmeans_colors,
|
| 362 |
+
normalize_tile_brightness
|
| 363 |
+
],
|
| 364 |
+
outputs=[mosaic_output, processed_output, metrics_output, timing_output]
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
benchmark_grid_btn.click(
|
| 368 |
+
fn=benchmark_grid_sizes,
|
| 369 |
+
inputs=[benchmark_image, grid_sizes_input],
|
| 370 |
+
outputs=[benchmark_output]
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
# Update visibility of quantization controls
|
| 374 |
+
use_uniform_quantization.change(
|
| 375 |
+
fn=lambda x: gr.Slider(visible=x),
|
| 376 |
+
inputs=[use_uniform_quantization],
|
| 377 |
+
outputs=[quantization_levels]
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
use_kmeans_quantization.change(
|
| 381 |
+
fn=lambda x: gr.Slider(visible=x),
|
| 382 |
+
inputs=[use_kmeans_quantization],
|
| 383 |
+
outputs=[kmeans_colors]
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
return demo
|
src/metrics.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
from typing import Dict, Tuple
|
| 5 |
+
from .utils import pil_to_np
|
| 6 |
+
from skimage.metrics import structural_similarity as ssim
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def calculate_mse(original: Image.Image, reconstructed: Image.Image) -> float:
|
| 10 |
+
"""
|
| 11 |
+
Calculate Mean Squared Error between original and reconstructed images.
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
original: Original PIL Image
|
| 15 |
+
reconstructed: Reconstructed PIL Image
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
MSE value
|
| 19 |
+
"""
|
| 20 |
+
orig_array = pil_to_np(original)
|
| 21 |
+
recon_array = pil_to_np(reconstructed)
|
| 22 |
+
|
| 23 |
+
# Ensure same size
|
| 24 |
+
if orig_array.shape != recon_array.shape:
|
| 25 |
+
# Resize reconstructed to match original
|
| 26 |
+
recon_pil = reconstructed.resize(original.size, Image.LANCZOS)
|
| 27 |
+
recon_array = pil_to_np(recon_pil)
|
| 28 |
+
|
| 29 |
+
# Calculate MSE
|
| 30 |
+
mse = np.mean((orig_array - recon_array) ** 2)
|
| 31 |
+
return float(mse)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def calculate_psnr(original: Image.Image, reconstructed: Image.Image) -> float:
|
| 35 |
+
"""
|
| 36 |
+
Calculate Peak Signal-to-Noise Ratio.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
original: Original PIL Image
|
| 40 |
+
reconstructed: Reconstructed PIL Image
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
PSNR value in dB
|
| 44 |
+
"""
|
| 45 |
+
mse = calculate_mse(original, reconstructed)
|
| 46 |
+
if mse == 0:
|
| 47 |
+
return float('inf')
|
| 48 |
+
|
| 49 |
+
psnr = 20 * np.log10(1.0 / np.sqrt(mse))
|
| 50 |
+
return float(psnr)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def calculate_ssim(original: Image.Image, reconstructed: Image.Image) -> float:
|
| 54 |
+
"""
|
| 55 |
+
Calculate Structural Similarity Index.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
original: Original PIL Image
|
| 59 |
+
reconstructed: Reconstructed PIL Image
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
SSIM value between 0 and 1
|
| 63 |
+
"""
|
| 64 |
+
orig_array = pil_to_np(original)
|
| 65 |
+
recon_array = pil_to_np(reconstructed)
|
| 66 |
+
|
| 67 |
+
# Ensure same size
|
| 68 |
+
if orig_array.shape != recon_array.shape:
|
| 69 |
+
# Resize reconstructed to match original
|
| 70 |
+
recon_pil = reconstructed.resize(original.size, Image.LANCZOS)
|
| 71 |
+
recon_array = pil_to_np(recon_pil)
|
| 72 |
+
|
| 73 |
+
# Convert to grayscale for SSIM calculation
|
| 74 |
+
if len(orig_array.shape) == 3:
|
| 75 |
+
orig_gray = np.mean(orig_array, axis=2)
|
| 76 |
+
recon_gray = np.mean(recon_array, axis=2)
|
| 77 |
+
else:
|
| 78 |
+
orig_gray = orig_array
|
| 79 |
+
recon_gray = recon_array
|
| 80 |
+
|
| 81 |
+
# Calculate SSIM
|
| 82 |
+
ssim_value = ssim(orig_gray, recon_gray, data_range=1.0)
|
| 83 |
+
return float(ssim_value)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def calculate_color_similarity(original: Image.Image, reconstructed: Image.Image) -> Dict[str, float]:
|
| 87 |
+
"""
|
| 88 |
+
Calculate color-based similarity metrics.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
original: Original PIL Image
|
| 92 |
+
reconstructed: Reconstructed PIL Image
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
Dictionary with color similarity metrics
|
| 96 |
+
"""
|
| 97 |
+
orig_array = pil_to_np(original)
|
| 98 |
+
recon_array = pil_to_np(reconstructed)
|
| 99 |
+
|
| 100 |
+
# Ensure same size
|
| 101 |
+
if orig_array.shape != recon_array.shape:
|
| 102 |
+
recon_pil = reconstructed.resize(original.size, Image.LANCZOS)
|
| 103 |
+
recon_array = pil_to_np(recon_pil)
|
| 104 |
+
|
| 105 |
+
# Calculate per-channel differences
|
| 106 |
+
channel_diffs = []
|
| 107 |
+
for channel in range(3):
|
| 108 |
+
orig_channel = orig_array[:, :, channel]
|
| 109 |
+
recon_channel = recon_array[:, :, channel]
|
| 110 |
+
channel_mse = np.mean((orig_channel - recon_channel) ** 2)
|
| 111 |
+
channel_diffs.append(channel_mse)
|
| 112 |
+
|
| 113 |
+
# Calculate overall color difference
|
| 114 |
+
color_mse = np.mean(channel_diffs)
|
| 115 |
+
|
| 116 |
+
# Calculate color histogram similarity
|
| 117 |
+
orig_hist = np.histogram(orig_array.flatten(), bins=256, range=(0, 1))[0]
|
| 118 |
+
recon_hist = np.histogram(recon_array.flatten(), bins=256, range=(0, 1))[0]
|
| 119 |
+
|
| 120 |
+
# Normalize histograms
|
| 121 |
+
orig_hist = orig_hist / np.sum(orig_hist)
|
| 122 |
+
recon_hist = recon_hist / np.sum(recon_hist)
|
| 123 |
+
|
| 124 |
+
# Calculate histogram correlation
|
| 125 |
+
hist_correlation = np.corrcoef(orig_hist, recon_hist)[0, 1]
|
| 126 |
+
|
| 127 |
+
return {
|
| 128 |
+
'color_mse': float(color_mse),
|
| 129 |
+
'red_channel_mse': float(channel_diffs[0]),
|
| 130 |
+
'green_channel_mse': float(channel_diffs[1]),
|
| 131 |
+
'blue_channel_mse': float(channel_diffs[2]),
|
| 132 |
+
'histogram_correlation': float(hist_correlation) if not np.isnan(hist_correlation) else 0.0
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def calculate_comprehensive_metrics(original: Image.Image, reconstructed: Image.Image) -> Dict[str, float]:
|
| 137 |
+
"""
|
| 138 |
+
Calculate comprehensive similarity metrics.
|
| 139 |
+
|
| 140 |
+
Args:
|
| 141 |
+
original: Original PIL Image
|
| 142 |
+
reconstructed: Reconstructed PIL Image
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
Dictionary with all similarity metrics
|
| 146 |
+
"""
|
| 147 |
+
metrics = {}
|
| 148 |
+
|
| 149 |
+
# Basic metrics
|
| 150 |
+
metrics['mse'] = calculate_mse(original, reconstructed)
|
| 151 |
+
metrics['psnr'] = calculate_psnr(original, reconstructed)
|
| 152 |
+
metrics['ssim'] = calculate_ssim(original, reconstructed)
|
| 153 |
+
|
| 154 |
+
# Color metrics
|
| 155 |
+
color_metrics = calculate_color_similarity(original, reconstructed)
|
| 156 |
+
metrics.update(color_metrics)
|
| 157 |
+
|
| 158 |
+
# Additional derived metrics
|
| 159 |
+
metrics['rmse'] = np.sqrt(metrics['mse'])
|
| 160 |
+
metrics['mae'] = calculate_mae(original, reconstructed)
|
| 161 |
+
|
| 162 |
+
return metrics
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def calculate_mae(original: Image.Image, reconstructed: Image.Image) -> float:
|
| 166 |
+
"""
|
| 167 |
+
Calculate Mean Absolute Error.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
original: Original PIL Image
|
| 171 |
+
reconstructed: Reconstructed PIL Image
|
| 172 |
+
|
| 173 |
+
Returns:
|
| 174 |
+
MAE value
|
| 175 |
+
"""
|
| 176 |
+
orig_array = pil_to_np(original)
|
| 177 |
+
recon_array = pil_to_np(reconstructed)
|
| 178 |
+
|
| 179 |
+
# Ensure same size
|
| 180 |
+
if orig_array.shape != recon_array.shape:
|
| 181 |
+
recon_pil = reconstructed.resize(original.size, Image.LANCZOS)
|
| 182 |
+
recon_array = pil_to_np(recon_pil)
|
| 183 |
+
|
| 184 |
+
# Calculate MAE
|
| 185 |
+
mae = np.mean(np.abs(orig_array - recon_array))
|
| 186 |
+
return float(mae)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def interpret_metrics(metrics: Dict[str, float]) -> Dict[str, str]:
|
| 190 |
+
"""
|
| 191 |
+
Provide human-readable interpretations of metrics.
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
metrics: Dictionary of metric values
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
Dictionary with interpretations
|
| 198 |
+
"""
|
| 199 |
+
interpretations = {}
|
| 200 |
+
|
| 201 |
+
# MSE interpretation
|
| 202 |
+
mse = metrics.get('mse', 0)
|
| 203 |
+
if mse < 0.01:
|
| 204 |
+
interpretations['mse'] = "Excellent similarity"
|
| 205 |
+
elif mse < 0.05:
|
| 206 |
+
interpretations['mse'] = "Good similarity"
|
| 207 |
+
elif mse < 0.1:
|
| 208 |
+
interpretations['mse'] = "Moderate similarity"
|
| 209 |
+
else:
|
| 210 |
+
interpretations['mse'] = "Poor similarity"
|
| 211 |
+
|
| 212 |
+
# PSNR interpretation
|
| 213 |
+
psnr = metrics.get('psnr', 0)
|
| 214 |
+
if psnr > 40:
|
| 215 |
+
interpretations['psnr'] = "Excellent quality"
|
| 216 |
+
elif psnr > 30:
|
| 217 |
+
interpretations['psnr'] = "Good quality"
|
| 218 |
+
elif psnr > 20:
|
| 219 |
+
interpretations['psnr'] = "Acceptable quality"
|
| 220 |
+
else:
|
| 221 |
+
interpretations['psnr'] = "Poor quality"
|
| 222 |
+
|
| 223 |
+
# SSIM interpretation
|
| 224 |
+
ssim_val = metrics.get('ssim', 0)
|
| 225 |
+
if ssim_val > 0.9:
|
| 226 |
+
interpretations['ssim'] = "Very similar structure"
|
| 227 |
+
elif ssim_val > 0.7:
|
| 228 |
+
interpretations['ssim'] = "Similar structure"
|
| 229 |
+
elif ssim_val > 0.5:
|
| 230 |
+
interpretations['ssim'] = "Moderately similar structure"
|
| 231 |
+
else:
|
| 232 |
+
interpretations['ssim'] = "Different structure"
|
| 233 |
+
|
| 234 |
+
return interpretations
|
src/mosaic.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
from typing import List, Tuple
|
| 5 |
+
import time
|
| 6 |
+
from scipy.spatial.distance import cdist
|
| 7 |
+
from .utils import pil_to_np, np_to_pil, resize_and_crop_to_grid, cell_means
|
| 8 |
+
from .config import Config, Implementation, MatchSpace
|
| 9 |
+
from .tiles import TileManager
|
| 10 |
+
from .quantization import apply_color_quantization
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MosaicGenerator:
|
| 14 |
+
"""Generate photomosaic images using configuration-driven stages."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, config: Config):
|
| 17 |
+
self.config = config
|
| 18 |
+
self.tile_manager = TileManager(config)
|
| 19 |
+
self.processing_time = {}
|
| 20 |
+
|
| 21 |
+
def preprocess_image(self, image: Image.Image) -> Image.Image:
|
| 22 |
+
"""
|
| 23 |
+
Step 1: Resize/crop the source image to align with the configured grid.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
image (Image.Image): Input photo supplied by the user.
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
Image.Image: Processed RGB image whose dimensions are divisible by `grid`.
|
| 30 |
+
"""
|
| 31 |
+
# Resize and crop to ensure grid compatibility
|
| 32 |
+
processed_img = resize_and_crop_to_grid(
|
| 33 |
+
image,
|
| 34 |
+
self.config.out_w,
|
| 35 |
+
self.config.out_h,
|
| 36 |
+
self.config.grid
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# Apply color quantization if enabled
|
| 40 |
+
if self.config.use_uniform_q or self.config.use_kmeans_q:
|
| 41 |
+
processed_img = apply_color_quantization(processed_img, self.config)
|
| 42 |
+
|
| 43 |
+
return processed_img
|
| 44 |
+
|
| 45 |
+
def analyze_grid_cells(self, image: Image.Image) -> np.ndarray:
|
| 46 |
+
"""
|
| 47 |
+
Step 2: Compute representative colors for every grid cell.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
image (Image.Image): Preprocessed image from `preprocess_image`.
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
np.ndarray: Array of shape (grid, grid, 3) containing mean RGB values.
|
| 54 |
+
"""
|
| 55 |
+
img_array = pil_to_np(image)
|
| 56 |
+
|
| 57 |
+
# Always use vectorized operations for better performance
|
| 58 |
+
cell_colors = cell_means(img_array, self.config.grid)
|
| 59 |
+
|
| 60 |
+
return cell_colors
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def map_tiles_to_grid(self, cell_colors: np.ndarray) -> np.ndarray:
|
| 64 |
+
"""
|
| 65 |
+
Step 3: Assemble the mosaic by mapping each cell color to a tile.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
cell_colors (np.ndarray): Array produced by `analyze_grid_cells`.
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
np.ndarray: Final mosaic pixels as a float32 array in [0, 1].
|
| 72 |
+
"""
|
| 73 |
+
grid = self.config.grid
|
| 74 |
+
tile_size = self.config.tile_size
|
| 75 |
+
output_h, output_w = grid * tile_size, grid * tile_size
|
| 76 |
+
|
| 77 |
+
# Vectorized approach - find all matches at once
|
| 78 |
+
tile_indices = self._find_all_tile_matches_vectorized(cell_colors)
|
| 79 |
+
|
| 80 |
+
# Stack tile bank once and gather the selected tiles in bulk
|
| 81 |
+
tile_bank = np.stack(self.tile_manager.tiles, axis=0).astype(np.float32, copy=False)
|
| 82 |
+
selected_tiles = tile_bank[tile_indices] # (grid, grid, tile_size, tile_size, 3)
|
| 83 |
+
mosaic_array = (
|
| 84 |
+
selected_tiles.transpose(0, 2, 1, 3, 4)
|
| 85 |
+
.reshape(output_h, output_w, 3)
|
| 86 |
+
.copy()
|
| 87 |
+
)
|
| 88 |
+
return mosaic_array
|
| 89 |
+
|
| 90 |
+
def generate_mosaic(self, image: Image.Image) -> Tuple[Image.Image, dict]:
|
| 91 |
+
"""
|
| 92 |
+
Execute preprocessing, grid analysis, and tile mapping in sequence.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
image (Image.Image): Input RGB image.
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
Tuple[Image.Image, dict]: (mosaic image, timing statistics).
|
| 99 |
+
"""
|
| 100 |
+
start_time = time.time()
|
| 101 |
+
|
| 102 |
+
# Step 1: Preprocessing
|
| 103 |
+
preprocess_start = time.time()
|
| 104 |
+
processed_img = self.preprocess_image(image)
|
| 105 |
+
self.processing_time['preprocessing'] = time.time() - preprocess_start
|
| 106 |
+
|
| 107 |
+
# Step 2: Grid analysis
|
| 108 |
+
analysis_start = time.time()
|
| 109 |
+
cell_colors = self.analyze_grid_cells(processed_img)
|
| 110 |
+
self.processing_time['grid_analysis'] = time.time() - analysis_start
|
| 111 |
+
|
| 112 |
+
# Step 3: Tile mapping
|
| 113 |
+
mapping_start = time.time()
|
| 114 |
+
mosaic_array = self.map_tiles_to_grid(cell_colors)
|
| 115 |
+
self.processing_time['tile_mapping'] = time.time() - mapping_start
|
| 116 |
+
|
| 117 |
+
# Convert to PIL Image
|
| 118 |
+
mosaic_img = np_to_pil(mosaic_array)
|
| 119 |
+
|
| 120 |
+
total_time = time.time() - start_time
|
| 121 |
+
self.processing_time['total'] = total_time
|
| 122 |
+
|
| 123 |
+
# Prepare statistics
|
| 124 |
+
stats = {
|
| 125 |
+
'grid_size': self.config.grid,
|
| 126 |
+
'tile_size': self.config.tile_size,
|
| 127 |
+
'output_resolution': f"{mosaic_img.width}x{mosaic_img.height}",
|
| 128 |
+
'processing_time': self.processing_time.copy(),
|
| 129 |
+
'implementation': self.config.impl.value,
|
| 130 |
+
'match_space': self.config.match_space.value
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
return mosaic_img, stats
|
| 134 |
+
|
| 135 |
+
def benchmark_grid_sizes(self, image: Image.Image, grid_sizes: List[int]) -> dict:
|
| 136 |
+
"""
|
| 137 |
+
Benchmark mosaic generation for multiple grid sizes.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
image (Image.Image): Input image.
|
| 141 |
+
grid_sizes (List[int]): Grid sizes (NxN) to evaluate.
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
dict: Mapping of grid size to timing and mosaic metadata.
|
| 145 |
+
"""
|
| 146 |
+
results = {}
|
| 147 |
+
original_grid = self.config.grid
|
| 148 |
+
|
| 149 |
+
for grid_size in grid_sizes:
|
| 150 |
+
self.config.grid = grid_size
|
| 151 |
+
# Update output dimensions to maintain aspect ratio
|
| 152 |
+
self.config.out_w = (image.width // grid_size) * grid_size
|
| 153 |
+
self.config.out_h = (image.height // grid_size) * grid_size
|
| 154 |
+
|
| 155 |
+
# Time the generation
|
| 156 |
+
start_time = time.time()
|
| 157 |
+
mosaic_img, stats = self.generate_mosaic(image)
|
| 158 |
+
total_time = time.time() - start_time
|
| 159 |
+
|
| 160 |
+
results[grid_size] = {
|
| 161 |
+
'processing_time': total_time,
|
| 162 |
+
'output_resolution': f"{mosaic_img.width}x{mosaic_img.height}",
|
| 163 |
+
'total_tiles': grid_size * grid_size
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
# Restore original grid size
|
| 167 |
+
self.config.grid = original_grid
|
| 168 |
+
return results
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _find_all_tile_matches_vectorized(self, cell_colors: np.ndarray) -> np.ndarray:
|
| 172 |
+
"""
|
| 173 |
+
Return the best tile index for every grid cell using NumPy distance matrices.
|
| 174 |
+
|
| 175 |
+
Args:
|
| 176 |
+
cell_colors (np.ndarray): Array of cell mean colors (grid, grid, 3).
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
np.ndarray: Tile indices shaped like the grid.
|
| 180 |
+
"""
|
| 181 |
+
# Ensure tiles are loaded
|
| 182 |
+
self.tile_manager._ensure_tiles_loaded()
|
| 183 |
+
|
| 184 |
+
if not self.tile_manager.tiles:
|
| 185 |
+
return np.zeros(cell_colors.shape[:2], dtype=int)
|
| 186 |
+
|
| 187 |
+
grid_h, grid_w = cell_colors.shape[:2]
|
| 188 |
+
cell_colors_reshaped = cell_colors.reshape(-1, 3)
|
| 189 |
+
|
| 190 |
+
if self.config.match_space == MatchSpace.LAB:
|
| 191 |
+
cell_colors_lab = np.array([self.tile_manager._rgb_to_lab(color) for color in cell_colors_reshaped]) # (N,3)
|
| 192 |
+
tile_colors_array = np.array(self.tile_manager.tile_colors_lab) # (M,3)
|
| 193 |
+
distances = self.tile_manager._calculate_perceptual_distance(cell_colors_lab, tile_colors_array) # (N,M)
|
| 194 |
+
else:
|
| 195 |
+
tile_colors_array = np.array(self.tile_manager.tile_colors) # (M,3)
|
| 196 |
+
distances = self.tile_manager._calculate_rgb_distance(cell_colors_reshaped, tile_colors_array) # (N,M)
|
| 197 |
+
|
| 198 |
+
# Add small randomness per candidate to avoid ties
|
| 199 |
+
noise_factor = 0.01
|
| 200 |
+
distances = distances * (1 + noise_factor * np.random.random(distances.shape))
|
| 201 |
+
|
| 202 |
+
# Find best tile per cell (argmin over tiles axis)
|
| 203 |
+
best_indices = np.argmin(distances, axis=1)
|
| 204 |
+
|
| 205 |
+
# Reshape back to grid
|
| 206 |
+
return best_indices.reshape(grid_h, grid_w)
|
src/pipeline.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
from typing import Dict, List, Tuple, Optional
|
| 5 |
+
import time
|
| 6 |
+
from .config import Config, Implementation
|
| 7 |
+
from .mosaic import MosaicGenerator
|
| 8 |
+
from .metrics import calculate_comprehensive_metrics, interpret_metrics
|
| 9 |
+
from .utils import pil_to_np, np_to_pil
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MosaicPipeline:
|
| 13 |
+
"""Complete pipeline for mosaic generation with performance analysis."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, config: Config):
|
| 16 |
+
self.config = config
|
| 17 |
+
self.mosaic_generator = MosaicGenerator(config)
|
| 18 |
+
self.results = {}
|
| 19 |
+
|
| 20 |
+
def run_full_pipeline(self, image: Image.Image) -> Dict:
|
| 21 |
+
"""
|
| 22 |
+
Run the complete mosaic generation pipeline.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
image: Input PIL Image
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
Dictionary with all results and metrics
|
| 29 |
+
"""
|
| 30 |
+
self.config.validate()
|
| 31 |
+
|
| 32 |
+
results = {
|
| 33 |
+
'input_image': image,
|
| 34 |
+
'config': self.config.__dict__.copy(),
|
| 35 |
+
'timing': {},
|
| 36 |
+
'metrics': {},
|
| 37 |
+
'outputs': {}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
# Generate mosaic
|
| 41 |
+
start_time = time.time()
|
| 42 |
+
mosaic_img, stats = self.mosaic_generator.generate_mosaic(image)
|
| 43 |
+
results['timing'] = stats['processing_time']
|
| 44 |
+
results['outputs']['mosaic'] = mosaic_img
|
| 45 |
+
|
| 46 |
+
# Calculate similarity metrics
|
| 47 |
+
metrics_start = time.time()
|
| 48 |
+
metrics = calculate_comprehensive_metrics(image, mosaic_img)
|
| 49 |
+
results['metrics'] = metrics
|
| 50 |
+
results['metrics_interpretation'] = interpret_metrics(metrics)
|
| 51 |
+
results['timing']['metrics_calculation'] = time.time() - metrics_start
|
| 52 |
+
|
| 53 |
+
# Store additional information
|
| 54 |
+
results['outputs']['processed_image'] = self.mosaic_generator.preprocess_image(image)
|
| 55 |
+
results['grid_info'] = {
|
| 56 |
+
'grid_size': self.config.grid,
|
| 57 |
+
'tile_size': self.config.tile_size,
|
| 58 |
+
'total_tiles': self.config.grid ** 2
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
self.results = results
|
| 62 |
+
return results
|
| 63 |
+
|
| 64 |
+
def benchmark_implementations(self, image: Image.Image) -> Dict:
|
| 65 |
+
"""
|
| 66 |
+
Compare vectorized vs loop-based implementations.
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
image: Input PIL Image
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
Dictionary with performance comparison
|
| 73 |
+
"""
|
| 74 |
+
original_impl = self.config.impl
|
| 75 |
+
|
| 76 |
+
results = {
|
| 77 |
+
'vectorized': {},
|
| 78 |
+
'loop_based': {},
|
| 79 |
+
'comparison': {}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
# Test vectorized implementation
|
| 83 |
+
self.config.impl = Implementation.VECT
|
| 84 |
+
start_time = time.time()
|
| 85 |
+
vec_results = self.run_full_pipeline(image)
|
| 86 |
+
vec_time = time.time() - start_time
|
| 87 |
+
|
| 88 |
+
results['vectorized'] = {
|
| 89 |
+
'processing_time': vec_time,
|
| 90 |
+
'metrics': vec_results['metrics'],
|
| 91 |
+
'mosaic': vec_results['outputs']['mosaic']
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
# Test loop-based implementation
|
| 95 |
+
self.config.impl = Implementation.LOOPS
|
| 96 |
+
start_time = time.time()
|
| 97 |
+
loop_results = self.run_full_pipeline(image)
|
| 98 |
+
loop_time = time.time() - start_time
|
| 99 |
+
|
| 100 |
+
results['loop_based'] = {
|
| 101 |
+
'processing_time': loop_time,
|
| 102 |
+
'metrics': loop_results['metrics'],
|
| 103 |
+
'mosaic': loop_results['outputs']['mosaic']
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
# Calculate comparison
|
| 107 |
+
speedup = loop_time / vec_time if vec_time > 0 else 0
|
| 108 |
+
results['comparison'] = {
|
| 109 |
+
'speedup_factor': speedup,
|
| 110 |
+
'time_difference': loop_time - vec_time,
|
| 111 |
+
'vectorized_faster': vec_time < loop_time
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
# Restore original implementation
|
| 115 |
+
self.config.impl = original_impl
|
| 116 |
+
|
| 117 |
+
return results
|
| 118 |
+
|
| 119 |
+
def benchmark_grid_sizes(self, image: Image.Image, grid_sizes: List[int]) -> Dict:
|
| 120 |
+
"""
|
| 121 |
+
Benchmark performance for different grid sizes.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
image: Input PIL Image
|
| 125 |
+
grid_sizes: List of grid sizes to test
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
Dictionary with grid size performance results
|
| 129 |
+
"""
|
| 130 |
+
results = {}
|
| 131 |
+
original_grid = self.config.grid
|
| 132 |
+
original_out_w = self.config.out_w
|
| 133 |
+
original_out_h = self.config.out_h
|
| 134 |
+
|
| 135 |
+
for grid_size in grid_sizes:
|
| 136 |
+
self.config.grid = grid_size
|
| 137 |
+
|
| 138 |
+
# Calculate appropriate output dimensions
|
| 139 |
+
aspect_ratio = image.width / image.height
|
| 140 |
+
if aspect_ratio > 1:
|
| 141 |
+
# Landscape
|
| 142 |
+
self.config.out_w = (image.width // grid_size) * grid_size
|
| 143 |
+
self.config.out_h = int(self.config.out_w / aspect_ratio // grid_size) * grid_size
|
| 144 |
+
else:
|
| 145 |
+
# Portrait
|
| 146 |
+
self.config.out_h = (image.height // grid_size) * grid_size
|
| 147 |
+
self.config.out_w = int(self.config.out_h * aspect_ratio // grid_size) * grid_size
|
| 148 |
+
|
| 149 |
+
# Time the generation
|
| 150 |
+
start_time = time.time()
|
| 151 |
+
pipeline_results = self.run_full_pipeline(image)
|
| 152 |
+
total_time = time.time() - start_time
|
| 153 |
+
|
| 154 |
+
results[grid_size] = {
|
| 155 |
+
'processing_time': total_time,
|
| 156 |
+
'output_resolution': f"{pipeline_results['outputs']['mosaic'].width}x{pipeline_results['outputs']['mosaic'].height}",
|
| 157 |
+
'total_tiles': grid_size * grid_size,
|
| 158 |
+
'tiles_per_second': (grid_size * grid_size) / total_time if total_time > 0 else 0,
|
| 159 |
+
'metrics': pipeline_results['metrics']
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
# Restore original configuration
|
| 163 |
+
self.config.grid = original_grid
|
| 164 |
+
self.config.out_w = original_out_w
|
| 165 |
+
self.config.out_h = original_out_h
|
| 166 |
+
|
| 167 |
+
return results
|
| 168 |
+
|
| 169 |
+
def analyze_performance_scaling(self, benchmark_results: Dict) -> Dict:
|
| 170 |
+
"""
|
| 171 |
+
Analyze how performance scales with grid size.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
benchmark_results: Results from benchmark_grid_sizes
|
| 175 |
+
|
| 176 |
+
Returns:
|
| 177 |
+
Dictionary with scaling analysis
|
| 178 |
+
"""
|
| 179 |
+
grid_sizes = sorted(benchmark_results.keys())
|
| 180 |
+
processing_times = [benchmark_results[gs]['processing_time'] for gs in grid_sizes]
|
| 181 |
+
total_tiles = [benchmark_results[gs]['total_tiles'] for gs in grid_sizes]
|
| 182 |
+
tiles_per_second = [benchmark_results[gs]['tiles_per_second'] for gs in grid_sizes]
|
| 183 |
+
|
| 184 |
+
# Calculate scaling factors
|
| 185 |
+
scaling_analysis = {
|
| 186 |
+
'grid_sizes': grid_sizes,
|
| 187 |
+
'processing_times': processing_times,
|
| 188 |
+
'total_tiles': total_tiles,
|
| 189 |
+
'tiles_per_second': tiles_per_second,
|
| 190 |
+
'scaling_factors': {}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
if len(grid_sizes) >= 2:
|
| 194 |
+
# Calculate how processing time scales with number of tiles
|
| 195 |
+
tile_ratio = total_tiles[-1] / total_tiles[0]
|
| 196 |
+
time_ratio = processing_times[-1] / processing_times[0]
|
| 197 |
+
|
| 198 |
+
scaling_analysis['scaling_factors'] = {
|
| 199 |
+
'tile_increase_ratio': tile_ratio,
|
| 200 |
+
'time_increase_ratio': time_ratio,
|
| 201 |
+
'scaling_efficiency': tile_ratio / time_ratio if time_ratio > 0 else 0,
|
| 202 |
+
'is_linear_scaling': abs(time_ratio - tile_ratio) / tile_ratio < 0.1
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
return scaling_analysis
|
| 206 |
+
|
| 207 |
+
def generate_report(self, image: Image.Image, benchmark_results: Optional[Dict] = None) -> str:
|
| 208 |
+
"""
|
| 209 |
+
Generate a comprehensive report of the mosaic generation process.
|
| 210 |
+
|
| 211 |
+
Args:
|
| 212 |
+
image: Input PIL Image
|
| 213 |
+
benchmark_results: Optional benchmark results
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
Formatted report string
|
| 217 |
+
"""
|
| 218 |
+
# Run full pipeline if not already done
|
| 219 |
+
if not self.results:
|
| 220 |
+
self.run_full_pipeline(image)
|
| 221 |
+
|
| 222 |
+
report = []
|
| 223 |
+
report.append("=" * 60)
|
| 224 |
+
report.append("MOSAIC GENERATION REPORT")
|
| 225 |
+
report.append("=" * 60)
|
| 226 |
+
|
| 227 |
+
# Configuration
|
| 228 |
+
report.append("\nCONFIGURATION:")
|
| 229 |
+
report.append(f"Grid Size: {self.config.grid}x{self.config.grid}")
|
| 230 |
+
report.append(f"Tile Size: {self.config.tile_size}x{self.config.tile_size}")
|
| 231 |
+
report.append(f"Output Resolution: {self.config.out_w}x{self.config.out_h}")
|
| 232 |
+
report.append(f"Implementation: {self.config.impl.value}")
|
| 233 |
+
report.append(f"Color Matching: {self.config.match_space.value}")
|
| 234 |
+
report.append(f"Total Tiles: {self.config.grid ** 2}")
|
| 235 |
+
|
| 236 |
+
# Processing Time
|
| 237 |
+
report.append("\nPROCESSING TIME:")
|
| 238 |
+
for stage, time_val in self.results['timing'].items():
|
| 239 |
+
report.append(f"{stage.replace('_', ' ').title()}: {time_val:.3f} seconds")
|
| 240 |
+
|
| 241 |
+
# Quality Metrics
|
| 242 |
+
report.append("\nQUALITY METRICS:")
|
| 243 |
+
metrics = self.results['metrics']
|
| 244 |
+
interpretations = self.results['metrics_interpretation']
|
| 245 |
+
|
| 246 |
+
report.append(f"MSE: {metrics['mse']:.6f} ({interpretations['mse']})")
|
| 247 |
+
report.append(f"PSNR: {metrics['psnr']:.2f} dB ({interpretations['psnr']})")
|
| 248 |
+
report.append(f"SSIM: {metrics['ssim']:.4f} ({interpretations['ssim']})")
|
| 249 |
+
report.append(f"RMSE: {metrics['rmse']:.6f}")
|
| 250 |
+
report.append(f"MAE: {metrics['mae']:.6f}")
|
| 251 |
+
|
| 252 |
+
# Benchmark Results
|
| 253 |
+
if benchmark_results:
|
| 254 |
+
report.append("\nBENCHMARK RESULTS:")
|
| 255 |
+
for grid_size, result in benchmark_results.items():
|
| 256 |
+
report.append(f"Grid {grid_size}x{grid_size}:")
|
| 257 |
+
report.append(f" Processing Time: {result['processing_time']:.3f}s")
|
| 258 |
+
report.append(f" Tiles per Second: {result['tiles_per_second']:.1f}")
|
| 259 |
+
report.append(f" Output Resolution: {result['output_resolution']}")
|
| 260 |
+
|
| 261 |
+
report.append("\n" + "=" * 60)
|
| 262 |
+
|
| 263 |
+
return "\n".join(report)
|
src/quantization.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
from sklearn.cluster import KMeans
|
| 5 |
+
from .utils import pil_to_np, np_to_pil
|
| 6 |
+
from .config import Config
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def apply_uniform_quantization(image: Image.Image, levels: int) -> Image.Image:
|
| 10 |
+
"""
|
| 11 |
+
Apply uniform color quantization to reduce color variations.
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
image: Input PIL Image
|
| 15 |
+
levels: Number of quantization levels per channel
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
Quantized PIL Image
|
| 19 |
+
"""
|
| 20 |
+
img_array = pil_to_np(image)
|
| 21 |
+
|
| 22 |
+
# Quantize each channel uniformly
|
| 23 |
+
quantized = np.zeros_like(img_array)
|
| 24 |
+
for channel in range(3):
|
| 25 |
+
# Create quantization levels
|
| 26 |
+
channel_data = img_array[:, :, channel]
|
| 27 |
+
|
| 28 |
+
# Uniform quantization
|
| 29 |
+
quantized_channel = np.round(channel_data * (levels - 1)) / (levels - 1)
|
| 30 |
+
quantized_channel = np.clip(quantized_channel, 0, 1)
|
| 31 |
+
quantized[:, :, channel] = quantized_channel
|
| 32 |
+
|
| 33 |
+
return np_to_pil(quantized)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def apply_kmeans_quantization(image: Image.Image, k_colors: int) -> Image.Image:
|
| 37 |
+
"""
|
| 38 |
+
Apply K-means clustering for color quantization.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
image: Input PIL Image
|
| 42 |
+
k_colors: Number of colors to reduce to
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
Quantized PIL Image
|
| 46 |
+
"""
|
| 47 |
+
img_array = pil_to_np(image)
|
| 48 |
+
h, w, c = img_array.shape
|
| 49 |
+
|
| 50 |
+
# Reshape image to list of pixels
|
| 51 |
+
pixels = img_array.reshape(-1, c)
|
| 52 |
+
|
| 53 |
+
# Apply K-means clustering
|
| 54 |
+
kmeans = KMeans(n_clusters=k_colors, random_state=42, n_init=10)
|
| 55 |
+
kmeans.fit(pixels)
|
| 56 |
+
|
| 57 |
+
# Replace each pixel with its cluster center
|
| 58 |
+
labels = kmeans.labels_
|
| 59 |
+
quantized_pixels = kmeans.cluster_centers_[labels]
|
| 60 |
+
|
| 61 |
+
# Reshape back to image
|
| 62 |
+
quantized_img = quantized_pixels.reshape(h, w, c)
|
| 63 |
+
|
| 64 |
+
return np_to_pil(quantized_img)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def apply_color_quantization(image: Image.Image, config: Config) -> Image.Image:
|
| 68 |
+
"""
|
| 69 |
+
Apply color quantization based on configuration.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
image: Input PIL Image
|
| 73 |
+
config: Configuration object
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
Quantized PIL Image
|
| 77 |
+
"""
|
| 78 |
+
if config.use_uniform_q:
|
| 79 |
+
return apply_uniform_quantization(image, config.q_levels)
|
| 80 |
+
elif config.use_kmeans_q:
|
| 81 |
+
return apply_kmeans_quantization(image, config.k_colors)
|
| 82 |
+
else:
|
| 83 |
+
# No quantization
|
| 84 |
+
return image
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def analyze_quantization_effect(original: Image.Image, quantized: Image.Image) -> dict:
|
| 88 |
+
"""
|
| 89 |
+
Analyze the effect of quantization on the image.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
original: Original image
|
| 93 |
+
quantized: Quantized image
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
Dictionary with analysis results
|
| 97 |
+
"""
|
| 98 |
+
orig_array = pil_to_np(original)
|
| 99 |
+
quant_array = pil_to_np(quantized)
|
| 100 |
+
|
| 101 |
+
# Calculate differences
|
| 102 |
+
diff = np.abs(orig_array - quant_array)
|
| 103 |
+
|
| 104 |
+
# Calculate statistics
|
| 105 |
+
mse = np.mean((orig_array - quant_array) ** 2)
|
| 106 |
+
psnr = 20 * np.log10(1.0 / np.sqrt(mse)) if mse > 0 else float('inf')
|
| 107 |
+
|
| 108 |
+
# Count unique colors
|
| 109 |
+
orig_colors = len(np.unique(orig_array.reshape(-1, 3), axis=0))
|
| 110 |
+
quant_colors = len(np.unique(quant_array.reshape(-1, 3), axis=0))
|
| 111 |
+
|
| 112 |
+
return {
|
| 113 |
+
'mse': float(mse),
|
| 114 |
+
'psnr': float(psnr),
|
| 115 |
+
'mean_difference': float(np.mean(diff)),
|
| 116 |
+
'max_difference': float(np.max(diff)),
|
| 117 |
+
'original_colors': orig_colors,
|
| 118 |
+
'quantized_colors': quant_colors,
|
| 119 |
+
'color_reduction_ratio': orig_colors / quant_colors if quant_colors > 0 else float('inf')
|
| 120 |
+
}
|
src/tiles.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
from datasets import load_dataset
|
| 5 |
+
from typing import List, Tuple, Optional
|
| 6 |
+
import os
|
| 7 |
+
import pickle
|
| 8 |
+
import hashlib
|
| 9 |
+
from scipy.spatial.distance import cdist
|
| 10 |
+
from .utils import pil_to_np, np_to_pil
|
| 11 |
+
from .config import Config, MatchSpace
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TileManager:
|
| 15 |
+
"""Manages a collection of image tiles for mosaic generation."""
|
| 16 |
+
|
| 17 |
+
# Global cache that persists across module reloads
|
| 18 |
+
_global_cache = {}
|
| 19 |
+
|
| 20 |
+
def __init__(self, config: Config):
|
| 21 |
+
self.config = config
|
| 22 |
+
self.tiles = []
|
| 23 |
+
self.tile_colors = []
|
| 24 |
+
self.tile_colors_lab = [] # Pre-computed LAB colors
|
| 25 |
+
self._tiles_loaded = False
|
| 26 |
+
# Don't load tiles immediately - load them lazily
|
| 27 |
+
|
| 28 |
+
def _stable_cache_key(self) -> str:
|
| 29 |
+
"""Create a stable cache key string for disk and memory caches."""
|
| 30 |
+
key = f"ds={self.config.hf_dataset}|split={self.config.hf_split}|limit={self.config.hf_limit}|tile={self.config.tile_size}|norm={self.config.tile_norm_brightness}"
|
| 31 |
+
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
| 32 |
+
|
| 33 |
+
def _ensure_tiles_loaded(self):
|
| 34 |
+
"""Ensure tiles are loaded, using cache if available."""
|
| 35 |
+
if self._tiles_loaded:
|
| 36 |
+
return
|
| 37 |
+
|
| 38 |
+
config_hash = self._stable_cache_key()
|
| 39 |
+
|
| 40 |
+
# Check if we can use cached tiles from global cache
|
| 41 |
+
if config_hash in TileManager._global_cache:
|
| 42 |
+
cached_data = TileManager._global_cache[config_hash]
|
| 43 |
+
self.tiles = cached_data['tiles'].copy()
|
| 44 |
+
self.tile_colors = cached_data['tile_colors'].copy()
|
| 45 |
+
self.tile_colors_lab = cached_data['tile_colors_lab'].copy()
|
| 46 |
+
self._tiles_loaded = True
|
| 47 |
+
print(f"Using cached tiles ({len(self.tiles)} tiles)")
|
| 48 |
+
return
|
| 49 |
+
|
| 50 |
+
# Try disk cache if available
|
| 51 |
+
if self.config.tiles_cache_dir:
|
| 52 |
+
os.makedirs(self.config.tiles_cache_dir, exist_ok=True)
|
| 53 |
+
cache_path = os.path.join(self.config.tiles_cache_dir, f"tiles_{config_hash}.pkl")
|
| 54 |
+
if os.path.exists(cache_path):
|
| 55 |
+
try:
|
| 56 |
+
with open(cache_path, "rb") as f:
|
| 57 |
+
cached_data = pickle.load(f)
|
| 58 |
+
self.tiles = cached_data['tiles']
|
| 59 |
+
self.tile_colors = cached_data['tile_colors']
|
| 60 |
+
self.tile_colors_lab = cached_data['tile_colors_lab']
|
| 61 |
+
self._tiles_loaded = True
|
| 62 |
+
# Also populate in-memory cache
|
| 63 |
+
TileManager._global_cache[config_hash] = {
|
| 64 |
+
'tiles': [tile.copy() for tile in self.tiles],
|
| 65 |
+
'tile_colors': [color.copy() for color in self.tile_colors],
|
| 66 |
+
'tile_colors_lab': [color.copy() for color in self.tile_colors_lab]
|
| 67 |
+
}
|
| 68 |
+
print(f"Loaded tiles from disk cache: {cache_path}")
|
| 69 |
+
return
|
| 70 |
+
except Exception as e:
|
| 71 |
+
print(f"Failed to load disk cache {cache_path}: {e}")
|
| 72 |
+
|
| 73 |
+
# Load tiles from dataset or fallback
|
| 74 |
+
self._load_tiles_from_source()
|
| 75 |
+
|
| 76 |
+
# Cache the tiles in global cache for future use
|
| 77 |
+
TileManager._global_cache[config_hash] = {
|
| 78 |
+
'tiles': [tile.copy() for tile in self.tiles],
|
| 79 |
+
'tile_colors': [color.copy() for color in self.tile_colors],
|
| 80 |
+
'tile_colors_lab': [color.copy() for color in self.tile_colors_lab]
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
# Also persist to disk cache if configured
|
| 84 |
+
if self.config.tiles_cache_dir:
|
| 85 |
+
try:
|
| 86 |
+
os.makedirs(self.config.tiles_cache_dir, exist_ok=True)
|
| 87 |
+
cache_path = os.path.join(self.config.tiles_cache_dir, f"tiles_{config_hash}.pkl")
|
| 88 |
+
with open(cache_path, "wb") as f:
|
| 89 |
+
pickle.dump({
|
| 90 |
+
'tiles': self.tiles,
|
| 91 |
+
'tile_colors': self.tile_colors,
|
| 92 |
+
'tile_colors_lab': self.tile_colors_lab
|
| 93 |
+
}, f)
|
| 94 |
+
print(f"Saved tiles to disk cache: {cache_path}")
|
| 95 |
+
except Exception as e:
|
| 96 |
+
print(f"Failed to save tiles to disk cache: {e}")
|
| 97 |
+
|
| 98 |
+
self._tiles_loaded = True
|
| 99 |
+
|
| 100 |
+
def _load_tiles_from_source(self):
|
| 101 |
+
"""Load tiles from Hugging Face dataset or create fallback."""
|
| 102 |
+
print(f"Loading tiles from {self.config.hf_dataset}...")
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
# Try to load from Hugging Face dataset
|
| 106 |
+
dataset = load_dataset(
|
| 107 |
+
self.config.hf_dataset,
|
| 108 |
+
split=self.config.hf_split,
|
| 109 |
+
cache_dir=self.config.hf_cache_dir if self.config.hf_cache_dir else None,
|
| 110 |
+
streaming=True # keep streaming but respect HF cache_dir
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Limit number of tiles
|
| 114 |
+
tile_count = min(self.config.hf_limit, 200) # Increased for better diversity
|
| 115 |
+
|
| 116 |
+
loaded_count = 0
|
| 117 |
+
for item in dataset:
|
| 118 |
+
if loaded_count >= tile_count:
|
| 119 |
+
break
|
| 120 |
+
|
| 121 |
+
# Get image from dataset
|
| 122 |
+
if 'image' in item:
|
| 123 |
+
img = item['image']
|
| 124 |
+
elif 'img' in item:
|
| 125 |
+
img = item['img']
|
| 126 |
+
else:
|
| 127 |
+
# Try to find image key
|
| 128 |
+
for key in item.keys():
|
| 129 |
+
if isinstance(item[key], Image.Image):
|
| 130 |
+
img = item[key]
|
| 131 |
+
break
|
| 132 |
+
else:
|
| 133 |
+
continue
|
| 134 |
+
|
| 135 |
+
# Convert to RGB and resize
|
| 136 |
+
img = img.convert('RGB')
|
| 137 |
+
img = img.resize(
|
| 138 |
+
(self.config.tile_size, self.config.tile_size),
|
| 139 |
+
Image.LANCZOS
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# Convert to numpy array
|
| 143 |
+
tile_array = pil_to_np(img)
|
| 144 |
+
|
| 145 |
+
# Normalize brightness if enabled
|
| 146 |
+
if self.config.tile_norm_brightness:
|
| 147 |
+
tile_array = self._normalize_brightness(tile_array)
|
| 148 |
+
|
| 149 |
+
self.tiles.append(tile_array)
|
| 150 |
+
|
| 151 |
+
# Calculate representative color for this tile
|
| 152 |
+
tile_color = np.mean(tile_array, axis=(0, 1))
|
| 153 |
+
self.tile_colors.append(tile_color)
|
| 154 |
+
|
| 155 |
+
# Pre-compute LAB color for faster matching
|
| 156 |
+
tile_color_lab = self._rgb_to_lab(tile_color)
|
| 157 |
+
self.tile_colors_lab.append(tile_color_lab)
|
| 158 |
+
|
| 159 |
+
loaded_count += 1
|
| 160 |
+
|
| 161 |
+
print(f"Loaded {len(self.tiles)} tiles successfully")
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
print(f"Error loading tiles from Hugging Face: {e}")
|
| 165 |
+
print("Creating fallback tiles...")
|
| 166 |
+
# Create fallback tiles if loading fails
|
| 167 |
+
self._create_fallback_tiles()
|
| 168 |
+
|
| 169 |
+
def _create_fallback_tiles(self):
|
| 170 |
+
"""Create simple colored tiles as fallback with extensive color palette."""
|
| 171 |
+
print("Creating fallback tiles...")
|
| 172 |
+
colors = [
|
| 173 |
+
# Primary colors
|
| 174 |
+
[1.0, 0.0, 0.0], # Red
|
| 175 |
+
[0.0, 1.0, 0.0], # Green
|
| 176 |
+
[0.0, 0.0, 1.0], # Blue
|
| 177 |
+
[1.0, 1.0, 0.0], # Yellow
|
| 178 |
+
[1.0, 0.0, 1.0], # Magenta
|
| 179 |
+
[0.0, 1.0, 1.0], # Cyan
|
| 180 |
+
|
| 181 |
+
# Grayscale spectrum
|
| 182 |
+
[0.0, 0.0, 0.0], # Black
|
| 183 |
+
[0.1, 0.1, 0.1], # Very Dark Gray
|
| 184 |
+
[0.2, 0.2, 0.2], # Dark Gray
|
| 185 |
+
[0.3, 0.3, 0.3], # Medium Dark Gray
|
| 186 |
+
[0.4, 0.4, 0.4], # Medium Gray
|
| 187 |
+
[0.5, 0.5, 0.5], # Mid Gray
|
| 188 |
+
[0.6, 0.6, 0.6], # Light Gray
|
| 189 |
+
[0.7, 0.7, 0.7], # Lighter Gray
|
| 190 |
+
[0.8, 0.8, 0.8], # Very Light Gray
|
| 191 |
+
[0.9, 0.9, 0.9], # Almost White
|
| 192 |
+
[1.0, 1.0, 1.0], # White
|
| 193 |
+
|
| 194 |
+
# Extended color palette
|
| 195 |
+
[1.0, 0.5, 0.0], # Orange
|
| 196 |
+
[1.0, 0.3, 0.0], # Dark Orange
|
| 197 |
+
[0.5, 0.0, 1.0], # Purple
|
| 198 |
+
[0.3, 0.0, 0.5], # Dark Purple
|
| 199 |
+
[0.0, 0.5, 0.0], # Dark Green
|
| 200 |
+
[0.0, 0.8, 0.0], # Bright Green
|
| 201 |
+
[0.0, 0.0, 0.5], # Dark Blue
|
| 202 |
+
[0.0, 0.0, 0.8], # Bright Blue
|
| 203 |
+
[0.5, 0.5, 0.0], # Olive
|
| 204 |
+
[0.7, 0.7, 0.0], # Yellow Olive
|
| 205 |
+
[0.5, 0.0, 0.5], # Dark Magenta
|
| 206 |
+
[0.8, 0.0, 0.8], # Bright Magenta
|
| 207 |
+
[0.0, 0.5, 0.5], # Teal
|
| 208 |
+
[0.0, 0.8, 0.8], # Bright Teal
|
| 209 |
+
[0.8, 0.6, 0.4], # Tan
|
| 210 |
+
[0.6, 0.4, 0.2], # Brown
|
| 211 |
+
[0.9, 0.9, 0.7], # Cream
|
| 212 |
+
[0.7, 0.5, 0.3], # Light Brown
|
| 213 |
+
[0.4, 0.2, 0.1], # Dark Brown
|
| 214 |
+
[0.9, 0.7, 0.5], # Peach
|
| 215 |
+
[0.5, 0.7, 0.9], # Light Blue
|
| 216 |
+
[0.7, 0.9, 0.5], # Light Green
|
| 217 |
+
[0.9, 0.5, 0.7], # Pink
|
| 218 |
+
[0.3, 0.7, 0.3], # Forest Green
|
| 219 |
+
[0.7, 0.3, 0.3], # Dark Red
|
| 220 |
+
[0.3, 0.3, 0.7], # Navy Blue
|
| 221 |
+
]
|
| 222 |
+
|
| 223 |
+
for color in colors:
|
| 224 |
+
tile = np.full(
|
| 225 |
+
(self.config.tile_size, self.config.tile_size, 3),
|
| 226 |
+
color,
|
| 227 |
+
dtype=np.float32
|
| 228 |
+
)
|
| 229 |
+
self.tiles.append(tile)
|
| 230 |
+
self.tile_colors.append(np.array(color))
|
| 231 |
+
|
| 232 |
+
# Pre-compute LAB color for fallback tiles too
|
| 233 |
+
tile_color_lab = self._rgb_to_lab(np.array(color))
|
| 234 |
+
self.tile_colors_lab.append(tile_color_lab)
|
| 235 |
+
|
| 236 |
+
def _normalize_brightness(self, tile: np.ndarray) -> np.ndarray:
|
| 237 |
+
"""Normalize tile brightness to mean brightness."""
|
| 238 |
+
mean_brightness = np.mean(tile)
|
| 239 |
+
if mean_brightness > 0:
|
| 240 |
+
tile = tile / mean_brightness
|
| 241 |
+
tile = np.clip(tile, 0, 1)
|
| 242 |
+
return tile
|
| 243 |
+
|
| 244 |
+
def get_best_tile(self, target_color: np.ndarray, match_space: MatchSpace) -> np.ndarray:
|
| 245 |
+
"""Find the best matching tile for a given target color using improved matching."""
|
| 246 |
+
# Ensure tiles are loaded
|
| 247 |
+
self._ensure_tiles_loaded()
|
| 248 |
+
|
| 249 |
+
if not self.tiles:
|
| 250 |
+
return np.zeros((self.config.tile_size, self.config.tile_size, 3))
|
| 251 |
+
|
| 252 |
+
if match_space == MatchSpace.LAB:
|
| 253 |
+
# Use pre-computed LAB colors for perceptual matching
|
| 254 |
+
target_lab = self._rgb_to_lab(target_color).reshape(1, -1)
|
| 255 |
+
tile_colors_array = np.array(self.tile_colors_lab)
|
| 256 |
+
|
| 257 |
+
# Use perceptual color distance with weighted components
|
| 258 |
+
distances = self._calculate_perceptual_distance(target_lab, tile_colors_array)
|
| 259 |
+
else:
|
| 260 |
+
# RGB color space matching with brightness weighting
|
| 261 |
+
target_rgb = target_color.reshape(1, -1)
|
| 262 |
+
tile_colors_array = np.array(self.tile_colors)
|
| 263 |
+
distances = self._calculate_rgb_distance(target_rgb, tile_colors_array)
|
| 264 |
+
|
| 265 |
+
# Add some randomness to avoid always picking the same tile
|
| 266 |
+
# This helps with visual variety
|
| 267 |
+
noise_factor = 0.1
|
| 268 |
+
distances = distances * (1 + noise_factor * np.random.random(len(distances)))
|
| 269 |
+
|
| 270 |
+
# Find best match
|
| 271 |
+
best_idx = np.argmin(distances)
|
| 272 |
+
return self.tiles[best_idx]
|
| 273 |
+
|
| 274 |
+
def _rgb_to_lab(self, rgb: np.ndarray) -> np.ndarray:
|
| 275 |
+
"""Improved RGB to LAB conversion approximation."""
|
| 276 |
+
r, g, b = rgb
|
| 277 |
+
|
| 278 |
+
# Better perceptual color space conversion
|
| 279 |
+
# Convert to XYZ color space first (simplified)
|
| 280 |
+
# This is still an approximation but better than the previous version
|
| 281 |
+
|
| 282 |
+
# Gamma correction
|
| 283 |
+
def gamma_correct(c):
|
| 284 |
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
| 285 |
+
|
| 286 |
+
r = gamma_correct(r)
|
| 287 |
+
g = gamma_correct(g)
|
| 288 |
+
b = gamma_correct(b)
|
| 289 |
+
|
| 290 |
+
# RGB to XYZ matrix (sRGB to XYZ)
|
| 291 |
+
x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b
|
| 292 |
+
y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b
|
| 293 |
+
z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b
|
| 294 |
+
|
| 295 |
+
# XYZ to LAB conversion (simplified)
|
| 296 |
+
# Reference white (D65)
|
| 297 |
+
xn, yn, zn = 0.95047, 1.00000, 1.08883
|
| 298 |
+
|
| 299 |
+
fx = x / xn
|
| 300 |
+
fy = y / yn
|
| 301 |
+
fz = z / zn
|
| 302 |
+
|
| 303 |
+
# Apply cube root
|
| 304 |
+
def f(t):
|
| 305 |
+
return t ** (1/3) if t > 0.008856 else (7.787 * t + 16/116)
|
| 306 |
+
|
| 307 |
+
fx, fy, fz = f(fx), f(fy), f(fz)
|
| 308 |
+
|
| 309 |
+
L = 116 * fy - 16
|
| 310 |
+
a = 500 * (fx - fy)
|
| 311 |
+
b_lab = 200 * (fy - fz)
|
| 312 |
+
|
| 313 |
+
return np.array([L, a, b_lab])
|
| 314 |
+
|
| 315 |
+
def _calculate_perceptual_distance(self, target_lab: np.ndarray, tile_colors_lab: np.ndarray) -> np.ndarray:
|
| 316 |
+
"""Calculate perceptual color distances for many targets vs many tiles.
|
| 317 |
+
Returns an array of shape (num_targets, num_tiles).
|
| 318 |
+
"""
|
| 319 |
+
weights = np.array([2.0, 1.0, 1.0])
|
| 320 |
+
# target_lab: (N,3), tile_colors_lab: (M,3)
|
| 321 |
+
# diff -> (N,M,3)
|
| 322 |
+
diff = target_lab[:, None, :] - tile_colors_lab[None, :, :]
|
| 323 |
+
weighted_diff = diff * weights[None, None, :]
|
| 324 |
+
distances = np.sqrt(np.sum(weighted_diff**2, axis=2)) # (N,M)
|
| 325 |
+
return distances
|
| 326 |
+
|
| 327 |
+
def _calculate_rgb_distance(self, target_rgb: np.ndarray, tile_colors_rgb: np.ndarray) -> np.ndarray:
|
| 328 |
+
"""Calculate RGB distances for many targets vs many tiles.
|
| 329 |
+
Returns an array of shape (num_targets, num_tiles).
|
| 330 |
+
"""
|
| 331 |
+
weights = np.array([1.0, 1.0, 1.0])
|
| 332 |
+
diff = target_rgb[:, None, :] - tile_colors_rgb[None, :, :] # (N,M,3)
|
| 333 |
+
weighted_diff = diff * weights[None, None, :]
|
| 334 |
+
distances = np.sqrt(np.sum(weighted_diff**2, axis=2)) # (N,M)
|
| 335 |
+
return distances
|
| 336 |
+
|
| 337 |
+
def get_tile_count(self) -> int:
|
| 338 |
+
"""Get number of available tiles."""
|
| 339 |
+
self._ensure_tiles_loaded()
|
| 340 |
+
return len(self.tiles)
|
| 341 |
+
|
| 342 |
+
def get_tile_stats(self) -> dict:
|
| 343 |
+
"""Get statistics about loaded tiles."""
|
| 344 |
+
self._ensure_tiles_loaded()
|
| 345 |
+
if not self.tiles:
|
| 346 |
+
return {"count": 0}
|
| 347 |
+
|
| 348 |
+
return {
|
| 349 |
+
"count": len(self.tiles),
|
| 350 |
+
"tile_size": self.config.tile_size,
|
| 351 |
+
"color_range": {
|
| 352 |
+
"min": np.min(self.tile_colors, axis=0).tolist(),
|
| 353 |
+
"max": np.max(self.tile_colors, axis=0).tolist(),
|
| 354 |
+
"mean": np.mean(self.tile_colors, axis=0).tolist()
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
@classmethod
|
| 359 |
+
def clear_cache(cls):
|
| 360 |
+
"""Clear the global tile cache."""
|
| 361 |
+
cls._global_cache.clear()
|
| 362 |
+
print("Tile cache cleared")
|
| 363 |
+
|
| 364 |
+
@classmethod
|
| 365 |
+
def get_cache_info(cls):
|
| 366 |
+
"""Get information about the current cache."""
|
| 367 |
+
return {
|
| 368 |
+
"cached_configs": len(cls._global_cache),
|
| 369 |
+
"cache_keys": list(cls._global_cache.keys())
|
| 370 |
+
}
|
src/utils.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
|
| 5 |
+
def pil_to_np(img: Image.Image) -> np.ndarray:
|
| 6 |
+
"""Convert a PIL image into a float32 NumPy array in the [0, 1] range."""
|
| 7 |
+
if img.mode not in ("RGB", "RGBA", "L"):
|
| 8 |
+
img = img.convert("RGB")
|
| 9 |
+
if img.mode == "L":
|
| 10 |
+
img = img.convert("RGB")
|
| 11 |
+
arr = np.asarray(img).astype(np.float32)
|
| 12 |
+
if arr.ndim == 2:
|
| 13 |
+
arr = np.repeat(arr[..., None], 3, axis=2)
|
| 14 |
+
if arr.shape[2] == 4:
|
| 15 |
+
arr = arr[..., :3]
|
| 16 |
+
return arr / 255.0
|
| 17 |
+
|
| 18 |
+
def np_to_pil(arr: np.ndarray) -> Image.Image:
|
| 19 |
+
"""Convert a float32 NumPy array (0–1) into an RGB PIL image."""
|
| 20 |
+
return Image.fromarray(np.clip(arr * 255.0, 0, 255).astype(np.uint8))
|
| 21 |
+
|
| 22 |
+
def resize_and_crop_to_grid(img: Image.Image, width: int, height: int, grid: int) -> Image.Image:
|
| 23 |
+
"""Resize and center-crop an image so both dimensions are multiples of the grid."""
|
| 24 |
+
img = img.convert("RGB").resize((width, height), Image.LANCZOS)
|
| 25 |
+
H, W = img.height, img.width
|
| 26 |
+
H2, W2 = (H // grid) * grid, (W // grid) * grid
|
| 27 |
+
if H2 != H or W2 != W:
|
| 28 |
+
left = (W - W2) // 2
|
| 29 |
+
top = (H - H2) // 2
|
| 30 |
+
img = img.crop((left, top, left + W2, top + H2))
|
| 31 |
+
return img
|
| 32 |
+
|
| 33 |
+
def block_view(arr: np.ndarray, bh: int, bw: int) -> np.ndarray:
|
| 34 |
+
"""Return a strided view that exposes the image as (grid_h, grid_w, bh, bw, C) blocks."""
|
| 35 |
+
H, W, C = arr.shape
|
| 36 |
+
if H % bh or W % bw:
|
| 37 |
+
raise ValueError("Array dimensions must be divisible by the block size")
|
| 38 |
+
shape = (H // bh, W // bw, bh, bw, C)
|
| 39 |
+
strides = (arr.strides[0] * bh, arr.strides[1] * bw, arr.strides[0], arr.strides[1], arr.strides[2])
|
| 40 |
+
return np.lib.stride_tricks.as_strided(arr, shape=shape, strides=strides)
|
| 41 |
+
|
| 42 |
+
def cell_means(arr: np.ndarray, grid: int) -> np.ndarray:
|
| 43 |
+
"""Return weighted mean RGB values for every grid cell using pure NumPy ops."""
|
| 44 |
+
H, W, _ = arr.shape
|
| 45 |
+
bh, bw = H // grid, W // grid
|
| 46 |
+
blocks = block_view(arr, bh, bw) # (grid, grid, bh, bw, 3)
|
| 47 |
+
|
| 48 |
+
center_h = (bh - 1) / 2.0
|
| 49 |
+
center_w = (bw - 1) / 2.0
|
| 50 |
+
yy, xx = np.meshgrid(np.arange(bh), np.arange(bw), indexing="ij")
|
| 51 |
+
dist = np.sqrt((yy - center_h) ** 2 + (xx - center_w) ** 2)
|
| 52 |
+
max_dist = np.sqrt(center_h**2 + center_w**2) or 1.0
|
| 53 |
+
weights = 1.0 - (dist / max_dist) * 0.5
|
| 54 |
+
weights = weights.astype(np.float32)
|
| 55 |
+
weights /= weights.sum()
|
| 56 |
+
|
| 57 |
+
weighted = blocks * weights[None, None, :, :, None]
|
| 58 |
+
return weighted.sum(axis=(2, 3))
|
test_images/test.jpeg
ADDED
|