Teoman21 commited on
Commit
b68205e
·
1 Parent(s): 899c09f

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 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: Lab 5
3
- emoji: 🐨
4
- colorFrom: pink
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 5.49.1
8
- app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
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