Lohith Venkat Chamakura commited on
Commit
0c3c4c4
·
1 Parent(s): 12cda37

Update app

Browse files
README.md CHANGED
@@ -1,95 +1,384 @@
1
- ---
2
- title: Image Mosaic Generator
3
- emoji: 🧩
4
- colorFrom: blue
5
- colorTo: green
6
- sdk: gradio
7
- sdk_version: 4.44.1
8
- app_file: app.py
9
- pinned: false
10
- ---
11
 
12
- # 🧩 Image Mosaic Generator
13
 
14
- Reconstruct an image as a **photo mosaic** built from a set of smaller tile images.
15
- Each grid cell of the input is replaced by a tile whose **average CIELAB color** is closest to the cell’s mean.
16
 
17
- This project includes both a **vectorized (NumPy)** implementation for speed and a **loop-based** implementation for clarity and benchmarking.
 
 
 
 
 
 
 
 
18
 
19
- ![demo](assets/mario_like.png)
20
 
21
- ---
22
 
23
- ## Features
 
24
 
25
- - 📸 **Mosaic generation** using skimage sample images + a Mario-like pixel sprite as tiles.
26
- - 🎨 **Optional color quantization** (Pillow median-cut).
27
- - ⚡ Two implementations:
28
- - `vectorized` — fast NumPy broadcasting.
29
- - `loop` — slower, but illustrates the algorithm.
30
- - 📊 **Similarity metrics**: Mean Squared Error (MSE) & Structural Similarity (SSIM).
31
- - 🖥️ **Interactive Gradio app** for local use or Hugging Face Spaces.
32
- - 🔍 **Performance study** (runtime vs grid size, SSIM vs grid size).
33
 
34
- ---
 
 
 
35
 
36
- ## 🚀 Demo
 
 
 
 
 
 
 
 
 
37
 
38
- Try it live on [Hugging Face Spaces](https://huggingface.co/spaces/lowwhit/image-mosaic-generator) *(if you deploy there)*.
39
- Or run locally:
 
 
40
 
41
- ```bash
42
- git clone https://huggingface.co/spaces/lowwhit/image-mosaic-generator/tree/main
43
- cd image-mosaic-generator
 
44
 
45
- # (optional) create a venv
46
- python -m venv .venv && source .venv/bin/activate
47
 
48
- # install dependencies
49
- pip install -r requirements.txt
50
 
51
- # run app
52
- python app.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  ```
54
 
55
- ## 📂 Project Structure
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
- ```bash
58
- .
59
- ├── app.py # main Gradio app (vectorized + loop algorithms)
60
- ├── requirements.txt # dependencies
61
- ├── README.md # this file
62
- └── assets/ # auto-generated sample images + Mario sprite
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  ```
64
 
65
- ## ⚙️ How It Works
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
- 1. **Preprocessing**
68
- - Load image, resize to max side, crop so dimensions are multiples of grid size.
69
- - (Optional) apply median-cut color quantization.
70
 
71
- 2. **Tile set construction**
72
- - Crop skimage sample images + sprite into squares, resize to tile size.
73
- - Convert each tile to **CIELAB** and store average color.
74
 
75
- 3. **Mosaic generation**
76
- - For each input grid cell: compute mean LAB color.
77
- - Find the tile with nearest mean LAB (Euclidean distance).
78
- - Place tile in output mosaic.
79
 
80
- 4. **Metrics**
81
- - Compute **MSE** and **SSIM** between original and mosaic.
 
 
82
 
83
- ---
84
 
85
- ## 📊 Example Results
86
 
87
- | Algorithm | Grid Size | Runtime (s) | MSE | SSIM |
88
- |--------------|-----------|-------------|--------|-------|
89
- | Vectorized | 32×32 | ~0.25 | 0.0123 | 0.84 |
90
- | Loop-based | 32×32 | ~2.90 | 0.0123 | 0.84 |
91
 
92
- - **MSE (Mean Squared Error):** Measures raw pixel-wise differences. Lower = more similar.
93
- - **SSIM (Structural Similarity):** Captures perceptual similarity (structure, luminance, contrast). Higher = more similar.
94
 
95
- > Both algorithms give identical mosaics (same MSE & SSIM), but the vectorized version is **much faster**.
 
 
1
+ # Optimized Image Mosaic Generator
 
 
 
 
 
 
 
 
 
2
 
3
+ A high-performance image mosaic generator that reconstructs input images as mosaics composed of small tiles using optimized vectorized operations. This project demonstrates significant performance improvements over the baseline implementation through algorithmic optimizations and vectorized NumPy operations.
4
 
5
+ ## Features
 
6
 
7
+ - **Optimized Performance**: Uses fully vectorized NumPy operations for fast mosaic generation
8
+ - **Two Algorithms**: Vectorized (fully optimized) and loop-based (optimized hybrid) methods
9
+ - **Color Matching**: Uses CIELAB color space for perceptually accurate color matching
10
+ - **Flexible Configuration**: Adjustable grid size, tile size, and color quantization
11
+ - **Quality Metrics**: Computes MSE and SSIM to evaluate mosaic quality
12
+ - **Performance Metrics**: Real-time processing time display in the web interface
13
+ - **Input Validation**: Comprehensive parameter validation with informative error messages
14
+ - **Graceful Error Handling**: Handles missing tiles and invalid inputs gracefully
15
+ - **Web Interface**: Easy-to-use Gradio web interface with performance tracking
16
 
17
+ ## Installation
18
 
19
+ ### Prerequisites
20
 
21
+ - Python 3.8 or higher
22
+ - pip package manager
23
 
24
+ ### Step-by-Step Installation
 
 
 
 
 
 
 
25
 
26
+ 1. **Clone or navigate to the project directory:**
27
+ ```bash
28
+ cd optimized-code
29
+ ```
30
 
31
+ 2. **Create a virtual environment (recommended):**
32
+ ```bash
33
+ python -m venv venv
34
+
35
+ # On Windows:
36
+ venv\Scripts\activate
37
+
38
+ # On macOS/Linux:
39
+ source venv/bin/activate
40
+ ```
41
 
42
+ 3. **Install dependencies:**
43
+ ```bash
44
+ pip install -r requirements.txt
45
+ ```
46
 
47
+ 4. **Verify installation:**
48
+ ```bash
49
+ python -c "import gradio, numpy, PIL, skimage; print('All dependencies installed successfully!')"
50
+ ```
51
 
52
+ ### Dependencies
 
53
 
54
+ The project requires the following packages (see `requirements.txt`):
 
55
 
56
+ - `gradio==5.49.1`: Web interface framework
57
+ - `numpy==2.1.2`: Numerical operations and array processing
58
+ - `pillow==10.4.0`: Image processing and manipulation
59
+ - `scikit-image==0.24.0`: Color space conversion and similarity metrics
60
+ - `scipy>=1.11.0`: Optional KD-tree support for fast nearest neighbor search
61
+
62
+ ## Usage
63
+
64
+ ### Web Interface (Recommended)
65
+
66
+ 1. **Start the application:**
67
+ ```bash
68
+ python app.py
69
+ ```
70
+
71
+ 2. **Access the interface:**
72
+ - The application will display a local URL (typically `http://127.0.0.1:7860`)
73
+ - Open this URL in your web browser
74
+
75
+ 3. **Generate a mosaic:**
76
+ - Upload an image or select from the provided examples
77
+ - Adjust parameters:
78
+ - **Grid cells**: Number of cells per side (8-96, default: 32)
79
+ - **Tile size**: Size of each tile in pixels (8-64, default: 24)
80
+ - **Quantize to K colors**: Color quantization (0-64, 0 = disabled)
81
+ - **Algorithm**: Choose between "vectorized" (fast) or "loop" (optimized hybrid)
82
+ - Click "Build Mosaic"
83
+ - View the result and performance metrics
84
+
85
+ 4. **Performance Metrics Display:**
86
+ The interface displays:
87
+ - **Quality metrics**: MSE (Mean Squared Error) and SSIM (Structural Similarity Index)
88
+ - **Performance metrics**: Total processing time, mosaic generation time, preprocessing time
89
+ - **Image information**: Final size, grid dimensions, tile size
90
+
91
+ ### Command Line Usage
92
+
93
+ ```python
94
+ from mosaic_generator import (
95
+ load_and_preprocess_image,
96
+ build_tile_set,
97
+ mosaic_fully_vectorized,
98
+ mosaic_loop_optimized,
99
+ compute_metrics,
100
+ DEFAULT_TILE_PATHS,
101
+ DEFAULT_TILE_SIZE,
102
+ )
103
+ from PIL import Image
104
+
105
+ # Load and preprocess image
106
+ img = Image.open("input.jpg")
107
+ preprocessed = load_and_preprocess_image(
108
+ img,
109
+ grid_cells=32,
110
+ quantize_colors=None # Optional: set to integer for color quantization
111
+ )
112
+
113
+ # Build tile set
114
+ tiles = build_tile_set(
115
+ DEFAULT_TILE_PATHS,
116
+ tile_size=24,
117
+ crops_per_image=4
118
+ )
119
+
120
+ # Generate mosaic (choose one method)
121
+ mosaic = mosaic_fully_vectorized(preprocessed, tiles, grid_cells=32)
122
+ # OR
123
+ mosaic = mosaic_loop_optimized(preprocessed, tiles, grid_cells=32)
124
+
125
+ # Compute quality metrics
126
+ mse, ssim = compute_metrics(preprocessed, mosaic)
127
+ print(f"MSE: {mse:.5f}, SSIM: {ssim:.4f}")
128
+
129
+ # Save the result
130
+ mosaic.save("output_mosaic.png")
131
+ ```
132
+
133
+ ### Advanced Usage Examples
134
+
135
+ #### Custom Tile Set
136
+
137
+ ```python
138
+ from mosaic_generator import build_tile_set
139
+
140
+ # Use your own tile images
141
+ custom_tile_paths = [
142
+ "path/to/tile1.jpg",
143
+ "path/to/tile2.jpg",
144
+ "path/to/tile3.jpg",
145
+ ]
146
+
147
+ tiles = build_tile_set(
148
+ custom_tile_paths,
149
+ tile_size=32,
150
+ crops_per_image=4
151
+ )
152
  ```
153
 
154
+ #### Batch Processing
155
+
156
+ ```python
157
+ from mosaic_generator import load_and_preprocess_image, build_tile_set, mosaic_fully_vectorized
158
+ from PIL import Image
159
+ import os
160
+
161
+ # Build tile set once
162
+ tiles = build_tile_set(DEFAULT_TILE_PATHS, tile_size=24)
163
+
164
+ # Process multiple images
165
+ input_dir = "input_images/"
166
+ output_dir = "output_mosaics/"
167
 
168
+ for filename in os.listdir(input_dir):
169
+ if filename.endswith(('.jpg', '.png', '.jpeg')):
170
+ img = Image.open(os.path.join(input_dir, filename))
171
+ preprocessed = load_and_preprocess_image(img, grid_cells=32)
172
+ mosaic = mosaic_fully_vectorized(preprocessed, tiles, grid_cells=32)
173
+ mosaic.save(os.path.join(output_dir, f"mosaic_{filename}"))
174
+ ```
175
+
176
+ ## Project Structure
177
+
178
+ ```
179
+ optimized-code/
180
+ ├── mosaic_generator/
181
+ │ ├── __init__.py # Package initialization and exports
182
+ │ ├── image_processor.py # Image loading, preprocessing, grid division
183
+ │ ├── tile_manager.py # Tile loading, caching, feature extraction
184
+ │ ├── mosaic_builder.py # Main mosaic construction logic
185
+ │ ├── metrics.py # Similarity metrics (MSE, SSIM)
186
+ │ ├── config.py # Configuration constants
187
+ │ └── utils.py # Helper functions and validation utilities
188
+ ├── app.py # Gradio web interface
189
+ ├── requirements.txt # Python dependencies
190
+ └── README.md # This file
191
  ```
192
 
193
+ ## Module Documentation
194
+
195
+ ### `mosaic_generator.config`
196
+ Configuration constants including default tile size, grid cells, asset paths, and other default parameters.
197
+
198
+ ### `mosaic_generator.utils`
199
+ Helper functions and validation utilities:
200
+ - `generate_assets()`: Creates sample images and sprites
201
+ - `_save_skimage_samples()`: Saves sample images from skimage
202
+ - `_make_mario_like_sprite()`: Creates a Mario-like pixel sprite
203
+ - `_multi_crops()`: Extracts multiple crops from an image
204
+ - `validate_grid_cells()`: Validates grid size parameters
205
+ - `validate_tile_size()`: Validates tile size parameters
206
+ - `validate_image_size()`: Validates image dimensions
207
+ - `validate_tile_paths()`: Validates and handles missing tile files
208
+
209
+ ### `mosaic_generator.image_processor`
210
+ Image loading and preprocessing:
211
+ - `load_and_preprocess_image()`: Loads and preprocesses images (resize, quantization, crop)
212
+ - `image_to_cells_mean_lab()`: Converts image to grid cells and computes mean LAB colors (optimized with single LAB conversion)
213
+
214
+ ### `mosaic_generator.tile_manager`
215
+ Tile management and matching:
216
+ - `TileSet`: Data structure holding tiles and precomputed LAB colors
217
+ - `build_tile_set()`: Builds a tile set from image paths with error handling
218
+ - `TileSet.find_nearest_tile_vectorized()`: Vectorized tile matching using broadcasting
219
+ - `TileManager`: Class for managing tile sets with caching
220
+
221
+ ### `mosaic_generator.mosaic_builder`
222
+ Core mosaic generation algorithms:
223
+ - `mosaic_fully_vectorized()`: Fully optimized vectorized implementation (no loops)
224
+ - `mosaic_loop_optimized()`: Optimized loop-based implementation with vectorized matching
225
+ - `MosaicBuilder`: Class-based interface for mosaic generation
226
+
227
+ ### `mosaic_generator.metrics`
228
+ Similarity metrics:
229
+ - `compute_metrics()`: Computes MSE and SSIM between original and mosaic
230
+
231
+ ## Performance Benchmarks
232
+
233
+ ### Comparison with Lab 1 Baseline
234
+
235
+ Comprehensive performance analysis was conducted comparing the optimized implementation against the Lab 1 baseline. The benchmarks were run on multiple image sizes (256×256, 512×512, 1024×1024) and grid configurations (16×16, 32×32, 64×64).
236
+
237
+ #### Overall Performance Summary
238
+
239
+ | Method | Average Speedup | Original Time | Optimized Time |
240
+ |--------|----------------|--------------|----------------|
241
+ | **Vectorized** | **1.06x** | 0.0174s | 0.0168s |
242
+ | **Loop** | **4.81x** | 0.1477s | 0.0291s |
243
+
244
+ #### Detailed Results by Grid Size
245
+
246
+ **Vectorized Method:**
247
+ - 16×16 grid: 1.03x speedup
248
+ - 32×32 grid: 1.05x speedup
249
+ - 64×64 grid: 1.10x speedup
250
+
251
+ **Loop Method:**
252
+ - 16×16 grid: 2.62x speedup
253
+ - 32×32 grid: 4.67x speedup
254
+ - 64×64 grid: 7.15x speedup
255
+
256
+ #### Key Findings
257
+
258
+ 1. **Vectorized Method**: Already highly optimized in Lab 1, showing modest but consistent improvements (~6% faster)
259
+
260
+ 2. **Loop Method**: Dramatic improvements (~4.8x average speedup) due to:
261
+ - Single LAB conversion for entire image (instead of per-cell)
262
+ - Vectorized distance calculations (eliminating inner loops)
263
+ - Precomputed tile features
264
+
265
+ 3. **Scalability**: Performance improvements increase with grid size, with the loop method showing up to 8.9x speedup for 64×64 grids
266
+
267
+ 4. **Correctness**: All optimizations maintain identical output quality (MSE difference: 0.000000)
268
+
269
+ #### Performance Visualization
270
+
271
+ Detailed performance charts and visualizations are available in the `profiling_analysis.ipynb` notebook located in the parent directory. The notebook includes:
272
+
273
+ - **Runtime comparison charts** by method and grid size
274
+ - **Speedup factor visualizations** showing improvements across different configurations
275
+ - **Performance breakdown** by operation type (preprocessing, tile matching, mosaic assembly)
276
+ - **Detailed timing analysis** for each optimization technique
277
+ - **Visual comparisons** of original vs optimized implementations
278
+
279
+ **To view the performance analysis:**
280
+
281
+ 1. Navigate to the project root directory:
282
+ ```bash
283
+ cd ..
284
+ ```
285
+
286
+ 2. Open the notebook using Jupyter:
287
+ ```bash
288
+ jupyter notebook profiling_analysis.ipynb
289
+ ```
290
+
291
+ Or if using JupyterLab:
292
+ ```bash
293
+ jupyter lab profiling_analysis.ipynb
294
+ ```
295
+
296
+ 3. The notebook contains embedded visualizations showing:
297
+ - Bar charts comparing speedup factors for vectorized and loop methods
298
+ - Performance time comparisons across different grid sizes
299
+ - Detailed analysis of optimization impact
300
+
301
+ **Note**: All performance benchmarks were conducted on the same hardware to ensure fair comparison. Results may vary based on system specifications.
302
+
303
+ ## Optimizations Implemented
304
+
305
+ The optimized code includes several key performance improvements:
306
+
307
+ ### 1. Single LAB Conversion
308
+ **Impact**: Eliminates redundant color space conversions
309
+ - **Before**: Converted each grid cell to LAB color space individually
310
+ - **After**: Converts entire image to LAB once, then extracts cell means
311
+ - **Benefit**: Reduces color space conversions from N² to 1 (where N = grid cells per side)
312
+
313
+ ### 2. Vectorized Operations
314
+ **Impact**: Leverages NumPy's optimized C implementations
315
+ - **Before**: Nested loops for distance calculations
316
+ - **After**: Broadcasting operations compute all distances simultaneously
317
+ - **Benefit**: Utilizes SIMD instructions and parallel processing
318
+
319
+ ### 3. Advanced Indexing
320
+ **Impact**: Eliminates loops in tile placement
321
+ - **Before**: Loops through each grid cell to place tiles
322
+ - **After**: Advanced NumPy indexing places all tiles at once
323
+ - **Benefit**: Reduces Python loop overhead
324
+
325
+ ### 4. Precomputed Features
326
+ **Impact**: Caches expensive computations
327
+ - **Before**: Computed tile mean LAB colors during matching
328
+ - **After**: Precomputed and stored in TileSet
329
+ - **Benefit**: Eliminates redundant calculations
330
+
331
+ ### 5. KD-Tree Support
332
+ **Impact**: Faster nearest neighbor search for large tile sets
333
+ - **Implementation**: Automatic KD-tree construction if scipy is available
334
+ - **Benefit**: O(log n) search instead of O(n) for large tile sets
335
+
336
+ ## Input Validation
337
+
338
+ The implementation includes comprehensive input validation:
339
+
340
+ - **Grid cells**: Validates range (4-128), type, and positive values
341
+ - **Tile size**: Validates range (4-256px), type, and positive values
342
+ - **Image dimensions**: Validates minimum (32px) and maximum (4096px) dimensions
343
+ - **Image-grid compatibility**: Ensures image can be divided into grid cells without excessive cropping
344
+ - **Tile paths**: Validates file existence and image file validity
345
+ - **Missing tiles**: Gracefully handles missing tile files, continuing with available tiles
346
+
347
+ All validation errors provide informative messages explaining:
348
+ - What parameter is invalid
349
+ - What the valid range is
350
+ - Why the validation failed
351
+ - How to fix the issue
352
+
353
+ ## Error Handling
354
+
355
+ The application handles errors gracefully:
356
+
357
+ - **Missing tiles**: Warns about missing tiles but continues with available ones
358
+ - **Invalid images**: Provides clear error messages for corrupted or invalid image files
359
+ - **Parameter errors**: Validates all inputs before processing
360
+ - **Processing errors**: Catches and reports errors with helpful messages
361
 
362
+ ## Troubleshooting
 
 
363
 
364
+ ### Common Issues
 
 
365
 
366
+ 1. **Import errors**: Ensure all dependencies are installed
367
+ ```bash
368
+ pip install -r requirements.txt
369
+ ```
370
 
371
+ 2. **Missing assets**: Assets are auto-generated on first run. If issues occur:
372
+ ```bash
373
+ python -c "from mosaic_generator.utils import generate_assets; generate_assets()"
374
+ ```
375
 
376
+ 3. **Memory errors**: Reduce grid size or tile size for very large images
377
 
378
+ 4. **Slow performance**: Use the vectorized method for best performance
379
 
 
 
 
 
380
 
381
+ ## Acknowledgments
 
382
 
383
+ - Sample images from scikit-image data module
384
+ - Gradio framework for the web interface
app.py CHANGED
@@ -1,230 +1,168 @@
1
- # app.py
2
- # =============================================================================
3
- # Hugging Face Spaces app: Image Mosaic Generator (Gradio)
4
- # - Rebuilds an input image as a mosaic of small tiles.
5
- # - Offers two algorithms: vectorized (NumPy) and loop (Python loops).
6
- # - Uses CIELAB mean color per grid cell to pick nearest tile.
7
- # - Includes optional color quantization (median-cut via Pillow).
8
- # - Provides MSE & SSIM metrics.
9
- # - Auto-generates sample images (skimage + Mario-like sprite).
10
- #
11
- # Spaces-specific notes:
12
- # - No external network calls; assets generated on first run.
13
- # - No SSR flags needed; we bypass API schema generation paths that can crash.
14
- # - On Spaces, `share=False`; elsewhere, `share=True` for convenience.
15
- # =============================================================================
16
 
17
- from __future__ import annotations
 
 
18
 
19
  import os
20
- from dataclasses import dataclass
21
- from typing import List, Optional, Tuple
22
 
23
- import numpy as np
24
- from PIL import Image
25
- from skimage import color, metrics, data
26
  import gradio as gr
 
27
 
28
- # ---------- Assets: write sample images & Mario-like sprite ----------
29
- ASSETS_DIR = "assets"
30
- os.makedirs(ASSETS_DIR, exist_ok=True)
31
-
32
- def _save_skimage_samples() -> List[str]:
33
- samples = [
34
- (data.astronaut(), "astronaut.png"),
35
- (data.chelsea(), "chelsea_cat.png"),
36
- (data.coffee(), "coffee.png"),
37
- (data.rocket(), "rocket.png"),
38
- (data.camera(), "camera.png"),
39
- (data.text(), "text.png"),
40
- ]
41
- paths: List[str] = []
42
- for arr, name in samples:
43
- img = Image.fromarray(arr)
44
- path = os.path.join(ASSETS_DIR, name)
45
- if not os.path.exists(path):
46
- img.save(path)
47
- paths.append(path)
48
- return paths
49
-
50
- def _make_mario_like_sprite(scale: int = 8) -> str:
51
- palette = {
52
- 0: (255, 255, 255), # white
53
- 1: (255, 205, 148), # skin
54
- 2: (200, 30, 30), # red
55
- 3: (40, 80, 200), # blue
56
- 4: (120, 70, 30), # brown
57
- 5: (10, 10, 10), # black
58
- 6: (240, 200, 60), # yellow
59
- }
60
- grid = np.array([
61
- [0,0,0,0,0,2,2,2,2,0,0,0,0,0,0,0],
62
- [0,0,0,0,2,2,2,2,2,2,0,0,0,0,0,0],
63
- [0,0,0,4,4,1,1,1,1,4,4,0,0,0,0,0],
64
- [0,0,4,1,1,1,1,1,1,1,1,4,0,0,0,0],
65
- [0,0,4,1,5,1,1,1,1,5,1,4,0,0,0,0],
66
- [0,0,4,1,1,1,1,1,1,1,1,4,0,0,0,0],
67
- [0,0,0,4,4,1,1,1,1,4,4,0,0,0,0,0],
68
- [0,0,0,0,3,3,3,3,3,3,0,0,0,0,0,0],
69
- [0,0,0,3,3,3,3,3,3,3,3,0,0,0,0,0],
70
- [0,0,4,4,3,4,3,3,3,4,4,4,0,0,0,0],
71
- [0,4,4,4,4,4,4,4,4,4,4,4,4,0,0,0],
72
- [0,0,0,2,2,0,0,0,0,2,2,0,0,0,0,0],
73
- [0,0,2,2,2,0,0,0,0,2,2,2,0,0,0,0],
74
- [0,2,2,2,2,2,2,0,2,2,2,2,2,0,0,0],
75
- [0,2,2,0,0,2,2,2,2,0,0,2,2,0,0,0],
76
- [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
77
- ], dtype=np.uint8)
78
-
79
- h, w = grid.shape
80
- rgb = np.zeros((h, w, 3), dtype=np.uint8)
81
- for k, col in palette.items():
82
- rgb[grid == k] = col
83
- img = Image.fromarray(rgb).resize((w*scale, h*scale), resample=Image.NEAREST)
84
- path = os.path.join(ASSETS_DIR, "mario_like.png")
85
- if not os.path.exists(path):
86
- img.save(path)
87
- return path
88
-
89
- sample_paths = _save_skimage_samples()
90
- mario_path = _make_mario_like_sprite(scale=8)
91
-
92
- # ---------- Core classes & functions ----------
93
- @dataclass
94
- class TileSet:
95
- tiles_rgb: np.ndarray
96
- means_lab: np.ndarray
97
- tile_h: int
98
- tile_w: int
99
-
100
- def _multi_crops(img: Image.Image, how_many: int = 4) -> List[Image.Image]:
101
- w, h = img.size
102
- s = min(w, h)
103
- return [
104
- img.crop(((w-s)//2, (h-s)//2, (w+s)//2, (h+s)//2)), # center
105
- img.crop((0, 0, s, s)), # TL
106
- img.crop((w-s, 0, w, s)), # TR
107
- img.crop((0, h-s, s, h)), # BL
108
- ][:how_many]
109
-
110
- def build_tile_set(image_paths: List[str], tile_size: int = 24, crops_per_image: int = 4) -> TileSet:
111
- tiles, means = [], []
112
- for path in image_paths:
113
- pil = Image.open(path).convert("RGB")
114
- for c in _multi_crops(pil, how_many=crops_per_image):
115
- t = c.resize((tile_size, tile_size), resample=Image.LANCZOS)
116
- arr = np.asarray(t, dtype=np.uint8)
117
- tiles.append(arr)
118
- lab = color.rgb2lab(arr / 255.0)
119
- means.append(lab.reshape(-1, 3).mean(axis=0))
120
- return TileSet(np.stack(tiles, 0), np.stack(means, 0), tile_size, tile_size)
121
-
122
- def load_and_preprocess_image(image_path_or_pil: Image.Image | str,
123
- grid_cells: int = 32,
124
- quantize_colors: Optional[int] = None,
125
- max_side_px: int = 768) -> Image.Image:
126
- img = (image_path_or_pil if isinstance(image_path_or_pil, Image.Image)
127
- else Image.open(image_path_or_pil)).convert("RGB")
128
- if quantize_colors is not None and quantize_colors > 0:
129
- img = img.quantize(colors=int(quantize_colors), method=Image.MEDIANCUT).convert("RGB")
130
- w, h = img.size
131
- scale = max_side_px / max(w, h)
132
- if scale < 1.0:
133
- img = img.resize((int(round(w * scale)), int(round(h * scale))), resample=Image.LANCZOS)
134
- w, h = img.size
135
- w_crop = (w // grid_cells) * grid_cells
136
- h_crop = (h // grid_cells) * grid_cells
137
- left, top = (w - w_crop) // 2, (h - h_crop) // 2
138
- return img.crop((left, top, left + w_crop, top + h_crop))
139
-
140
- def image_to_cells_mean_lab(img: Image.Image, grid_cells: int):
141
- arr = np.asarray(img, dtype=np.uint8)
142
- h, w, _ = arr.shape
143
- rows = cols = grid_cells
144
- cell_h, cell_w = h // rows, w // cols
145
- arr = arr[:rows*cell_h, :cols*cell_w, :]
146
- grid = arr.reshape(rows, cell_h, cols, cell_w, 3).swapaxes(1, 2)
147
- grid_lab = color.rgb2lab(grid / 255.0)
148
- means = grid_lab.mean(axis=(2, 3))
149
- return means, (rows, cols), (cell_h, cell_w)
150
-
151
- def mosaic_vectorized(img: Image.Image, tiles: TileSet, grid_cells: int) -> Image.Image:
152
- cell_means_lab, (rows, cols), _ = image_to_cells_mean_lab(img, grid_cells)
153
- diff = cell_means_lab[..., None, :] - tiles.means_lab[None, None, :, :]
154
- dists = np.sum(diff**2, axis=-1)
155
- best_idx = np.argmin(dists, axis=-1)
156
- out_h, out_w = rows * tiles.tile_h, cols * tiles.tile_w
157
- out = np.zeros((out_h, out_w, 3), dtype=np.uint8)
158
- for r in range(rows):
159
- for c in range(cols):
160
- t = tiles.tiles_rgb[best_idx[r, c]]
161
- out[r*tiles.tile_h:(r+1)*tiles.tile_h, c*tiles.tile_w:(c+1)*tiles.tile_w, :] = t
162
- return Image.fromarray(out)
163
-
164
- def mosaic_loop(img: Image.Image, tiles: TileSet, grid_cells: int) -> Image.Image:
165
- arr = np.asarray(img, dtype=np.uint8)
166
- h, w, _ = arr.shape
167
- rows = cols = grid_cells
168
- cell_h, cell_w = h // rows, w // cols
169
- arr = arr[:rows*cell_h, :cols*cell_w, :]
170
- out_h, out_w = rows * tiles.tile_h, cols * tiles.tile_w
171
- out = np.zeros((out_h, out_w, 3), dtype=np.uint8)
172
- for r in range(rows):
173
- for c in range(cols):
174
- cell = arr[r*cell_h:(r+1)*cell_h, c*cell_w:(c+1)*cell_w, :]
175
- lab = color.rgb2lab(cell / 255.0)
176
- mean = lab.reshape(-1, 3).mean(axis=0)
177
- best_j, best_d = None, float("inf")
178
- for j in range(tiles.means_lab.shape[0]):
179
- d = float(np.sum((mean - tiles.means_lab[j])**2))
180
- if d < best_d:
181
- best_d, best_j = d, j
182
- t = tiles.tiles_rgb[best_j]
183
- out[r*tiles.tile_h:(r+1)*tiles.tile_h, c*tiles.tile_w:(c+1)*tiles.tile_w, :] = t
184
- return Image.fromarray(out)
185
-
186
- def compute_metrics(original_rgb: Image.Image, mosaic_rgb: Image.Image):
187
- M = mosaic_rgb.resize(original_rgb.size, resample=Image.NEAREST)
188
- a = np.asarray(original_rgb.convert("RGB"), dtype=np.float32) / 255.0
189
- b = np.asarray(M.convert("RGB"), dtype=np.float32) / 255.0
190
- mse = float(np.mean((a - b) ** 2))
191
- ssim = float(metrics.structural_similarity(a, b, channel_axis=2, data_range=1.0))
192
- return mse, ssim
193
-
194
- # ---------- Default tiles ----------
195
- DEFAULT_TILE_SIZE = 24
196
- DEFAULT_TILE_PATHS = [
197
- os.path.join(ASSETS_DIR, "astronaut.png"),
198
- os.path.join(ASSETS_DIR, "chelsea_cat.png"),
199
- os.path.join(ASSETS_DIR, "coffee.png"),
200
- os.path.join(ASSETS_DIR, "rocket.png"),
201
- os.path.join(ASSETS_DIR, "camera.png"),
202
- os.path.join(ASSETS_DIR, "text.png"),
203
- os.path.join(ASSETS_DIR, "mario_like.png"),
204
- ]
205
- DEFAULT_TILES = build_tile_set(DEFAULT_TILE_PATHS, tile_size=DEFAULT_TILE_SIZE, crops_per_image=4)
206
-
207
- # ---------- Gradio UI ----------
208
- def build_and_run_mosaic(input_img: Image.Image,
209
- grid_cells: int = 32,
210
- tile_size: int = DEFAULT_TILE_SIZE,
211
- quantize_k: int = 0,
212
- method: str = "vectorized"):
213
- if input_img is None:
214
- return None, "Please provide an image."
215
- tiles = DEFAULT_TILES if tile_size == DEFAULT_TILE_SIZE else build_tile_set(
216
- DEFAULT_TILE_PATHS, tile_size=tile_size, crops_per_image=4
217
  )
218
- qk = None if quantize_k in (0, None) else int(quantize_k)
219
- base = load_and_preprocess_image(input_img, grid_cells=grid_cells, quantize_colors=qk)
220
- if method == "vectorized":
221
- mos = mosaic_vectorized(base, tiles, grid_cells)
222
- else:
223
- mos = mosaic_loop(base, tiles, grid_cells)
224
- mse, ssim = compute_metrics(base, mos)
225
- msg = f"MSE: {mse:.5f} | SSIM: {ssim:.4f} | Size: {base.size[0]}x{base.size[1]} | Grid: {grid_cells}x{grid_cells} | Tile: {tile_size}px"
226
- return mos, msg
227
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  EXAMPLES = [
229
  os.path.join(ASSETS_DIR, "astronaut.png"),
230
  os.path.join(ASSETS_DIR, "chelsea_cat.png"),
@@ -233,24 +171,47 @@ EXAMPLES = [
233
  os.path.join(ASSETS_DIR, "mario_like.png"),
234
  ]
235
 
 
236
  with gr.Blocks() as demo:
237
- gr.Markdown("## 🧩 Image Mosaic Generator\nUpload or pick an example, then tune parameters.")
 
 
 
 
238
 
239
  with gr.Row():
240
  with gr.Column():
241
  inp = gr.Image(type="pil", label="Input image", height=320)
242
- grid = gr.Slider(8, 96, value=32, step=1, label="Grid cells per side (N×N)")
243
- tile = gr.Slider(8, 64, value=DEFAULT_TILE_SIZE, step=1, label="Tile size (px)")
244
- quant = gr.Slider(0, 64, value=0, step=1, label="Quantize to K colors (0 = off)")
245
- method = gr.Radio(["vectorized", "loop"], value="vectorized", label="Algorithm")
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  run = gr.Button("Build Mosaic", variant="primary")
247
 
248
  with gr.Column():
249
  out_img = gr.Image(type="pil", label="Mosaic", height=320)
250
- out_txt = gr.Textbox(label="Metrics", interactive=False)
 
 
 
 
 
251
  gr.Examples(EXAMPLES, inputs=inp)
252
 
253
- # Bind events inside Blocks; set per-event concurrency (no deprecated queue args)
254
  run.click(
255
  build_and_run_mosaic,
256
  inputs=[inp, grid, tile, quant, method],
@@ -258,36 +219,7 @@ with gr.Blocks() as demo:
258
  concurrency_limit=10,
259
  )
260
 
261
- # ---- After Blocks: Spaces-friendly launch & schema bypass ----
262
- if __name__ == "__main__":
263
- # import types
264
-
265
- # # Strong bypass: prevent API schema generation (avoids /info crashes in some combos)
266
- # try:
267
- # demo.get_api_info = types.MethodType(lambda self: {}, demo)
268
- # except Exception:
269
- # pass
270
- # try:
271
- # import gradio.routes as _gr_routes
272
- # def _noop_api_info(*args, **kwargs):
273
- # return {}
274
- # _gr_routes.api_info = _noop_api_info
275
- # except Exception:
276
- # pass
277
-
278
- # # On Spaces, a share link is not needed; elsewhere it's handy
279
- # on_spaces = bool(os.getenv("SPACE_ID"))
280
- # share_flag = False if on_spaces else True
281
 
282
- # # Optional request queue (buffer)
283
- # demo.queue(max_size=64)
284
-
285
- # demo.launch(
286
- # server_name="0.0.0.0",
287
- # server_port=int(os.getenv("PORT", "7860")),
288
- # share=share_flag,
289
- # show_api=False, # don't expose schema-based docs
290
- # prevent_thread_lock=True,
291
- # max_threads=40
292
- # )
293
  demo.launch()
 
 
1
+ """
2
+ Gradio interface for the optimized image mosaic generator.
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
+ This module provides a web-based interface using Gradio for generating
5
+ image mosaics with various configuration options.
6
+ """
7
 
8
  import os
9
+ import time
10
+ from typing import Optional
11
 
 
 
 
12
  import gradio as gr
13
+ from PIL import Image
14
 
15
+ from mosaic_generator.config import (
16
+ ASSETS_DIR,
17
+ DEFAULT_GRID_CELLS,
18
+ DEFAULT_TILE_PATHS,
19
+ DEFAULT_TILE_SIZE,
20
+ )
21
+ from mosaic_generator.image_processor import load_and_preprocess_image
22
+ from mosaic_generator.metrics import compute_metrics
23
+ from mosaic_generator.mosaic_builder import (
24
+ mosaic_fully_vectorized,
25
+ mosaic_loop_optimized,
26
+ )
27
+ from mosaic_generator.tile_manager import build_tile_set
28
+ from mosaic_generator.utils import (
29
+ ValidationError,
30
+ generate_assets,
31
+ validate_grid_cells,
32
+ validate_image_grid_compatibility,
33
+ validate_image_size,
34
+ validate_tile_paths,
35
+ validate_tile_size,
36
+ )
37
+
38
+ # Generate assets on startup
39
+ generate_assets()
40
+
41
+ # Build default tile set (with error handling)
42
+ try:
43
+ DEFAULT_TILES = build_tile_set(
44
+ DEFAULT_TILE_PATHS,
45
+ tile_size=DEFAULT_TILE_SIZE,
46
+ crops_per_image=4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  )
48
+ except Exception as e:
49
+ print(f"Warning: Failed to build default tile set on startup: {str(e)}")
50
+ print("Tiles will be built on-demand when needed.")
51
+ DEFAULT_TILES = None
52
+
53
+
54
+ def build_and_run_mosaic(
55
+ input_img: Image.Image,
56
+ grid_cells: int = DEFAULT_GRID_CELLS,
57
+ tile_size: int = DEFAULT_TILE_SIZE,
58
+ quantize_k: int = 0,
59
+ method: str = "vectorized"
60
+ ) -> tuple[Optional[Image.Image], str]:
61
+ """
62
+ Build and run mosaic generation with given parameters.
63
+
64
+ Includes performance timing metrics for processing time analysis.
65
+
66
+ Args:
67
+ input_img: Input PIL Image to convert to mosaic.
68
+ grid_cells: Number of grid cells per side (N×N).
69
+ tile_size: Size of each tile in pixels.
70
+ quantize_k: Number of colors for quantization (0 = disabled).
71
+ method: Algorithm to use ("vectorized" or "loop").
72
+
73
+ Returns:
74
+ tuple[Optional[Image.Image], str]: Tuple of (mosaic image, metrics message).
75
+ """
76
+ start_time = time.perf_counter()
77
+
78
+ try:
79
+ # Validate input image
80
+ if input_img is None:
81
+ return None, "Error: Please provide an image."
82
+
83
+ # Validate parameters
84
+ validate_grid_cells(grid_cells)
85
+ validate_tile_size(tile_size)
86
+ validate_image_size(input_img)
87
+ validate_image_grid_compatibility(input_img, grid_cells)
88
+
89
+ # Validate tile paths and handle missing tiles gracefully
90
+ valid_paths, missing_paths = validate_tile_paths(DEFAULT_TILE_PATHS)
91
+
92
+ # Build tile set (reuse default if same size and paths are unchanged)
93
+ tile_build_start = time.perf_counter()
94
+ if (tile_size == DEFAULT_TILE_SIZE
95
+ and len(valid_paths) == len(DEFAULT_TILE_PATHS)
96
+ and DEFAULT_TILES is not None):
97
+ tiles = DEFAULT_TILES
98
+ tile_build_time = time.perf_counter() - tile_build_start
99
+ else:
100
+ tiles = build_tile_set(
101
+ valid_paths, tile_size=tile_size, crops_per_image=4
102
+ )
103
+ tile_build_time = time.perf_counter() - tile_build_start
104
+
105
+ # Validate that we have tiles
106
+ if tiles is None or len(tiles.tiles_rgb) == 0:
107
+ return None, (
108
+ "Error: No valid tiles available. "
109
+ "Please ensure tile images exist and are valid image files."
110
+ )
111
+
112
+ # Preprocess image
113
+ preprocess_start = time.perf_counter()
114
+ qk = None if quantize_k in (0, None) else int(quantize_k)
115
+ base = load_and_preprocess_image(
116
+ input_img,
117
+ grid_cells=grid_cells,
118
+ quantize_colors=qk
119
+ )
120
+ preprocess_time = time.perf_counter() - preprocess_start
121
+
122
+ # Generate mosaic (core algorithm - most important timing)
123
+ mosaic_start = time.perf_counter()
124
+ if method == "vectorized":
125
+ mos = mosaic_fully_vectorized(base, tiles, grid_cells)
126
+ else:
127
+ mos = mosaic_loop_optimized(base, tiles, grid_cells)
128
+ mosaic_time = time.perf_counter() - mosaic_start
129
+
130
+ # Compute metrics
131
+ mse, ssim = compute_metrics(base, mos)
132
+
133
+ total_time = time.perf_counter() - start_time
134
+
135
+ # Build metrics message with performance information
136
+ msg = (
137
+ f"MSE: {mse:.5f} | SSIM: {ssim:.4f} | "
138
+ f"Size: {base.size[0]}x{base.size[1]} | "
139
+ f"Grid: {grid_cells}x{grid_cells} | "
140
+ f"Tile: {tile_size}px"
141
+ )
142
+
143
+ # Add performance metrics
144
+ msg += (
145
+ f"\n⏱️ Performance: "
146
+ f"Total: {total_time:.3f}s | "
147
+ f"Mosaic: {mosaic_time:.3f}s | "
148
+ f"Preprocess: {preprocess_time:.3f}s"
149
+ )
150
+
151
+ if tile_build_time > 0.001: # Only show if significant
152
+ msg += f" | Tile Build: {tile_build_time:.3f}s"
153
+
154
+ if missing_paths:
155
+ msg += f"\n⚠️ Warning: {len(missing_paths)} tile(s) missing"
156
+
157
+ return mos, msg
158
+
159
+ except ValidationError as e:
160
+ return None, f"Validation Error: {str(e)}"
161
+ except Exception as e:
162
+ return None, f"Error: {str(e)}"
163
+
164
+
165
+ # Example images
166
  EXAMPLES = [
167
  os.path.join(ASSETS_DIR, "astronaut.png"),
168
  os.path.join(ASSETS_DIR, "chelsea_cat.png"),
 
171
  os.path.join(ASSETS_DIR, "mario_like.png"),
172
  ]
173
 
174
+ # Build Gradio interface
175
  with gr.Blocks() as demo:
176
+ gr.Markdown(
177
+ "## 🧩 Image Mosaic Generator\n"
178
+ "Upload or pick an example, then tune parameters.\n\n"
179
+ "**Features:** Performance metrics display, input validation, and optimized algorithms."
180
+ )
181
 
182
  with gr.Row():
183
  with gr.Column():
184
  inp = gr.Image(type="pil", label="Input image", height=320)
185
+ grid = gr.Slider(
186
+ 8, 96, value=DEFAULT_GRID_CELLS, step=1,
187
+ label="Grid cells per side (N×N)"
188
+ )
189
+ tile = gr.Slider(
190
+ 8, 64, value=DEFAULT_TILE_SIZE, step=1,
191
+ label="Tile size (px)"
192
+ )
193
+ quant = gr.Slider(
194
+ 0, 64, value=0, step=1,
195
+ label="Quantize to K colors (0 = off)"
196
+ )
197
+ method = gr.Radio(
198
+ ["vectorized", "loop"],
199
+ value="vectorized",
200
+ label="Algorithm"
201
+ )
202
  run = gr.Button("Build Mosaic", variant="primary")
203
 
204
  with gr.Column():
205
  out_img = gr.Image(type="pil", label="Mosaic", height=320)
206
+ out_txt = gr.Textbox(
207
+ label="Metrics & Performance",
208
+ interactive=False,
209
+ lines=4,
210
+ placeholder="Metrics and processing time will appear here..."
211
+ )
212
  gr.Examples(EXAMPLES, inputs=inp)
213
 
214
+ # Bind events
215
  run.click(
216
  build_and_run_mosaic,
217
  inputs=[inp, grid, tile, quant, method],
 
219
  concurrency_limit=10,
220
  )
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
+ if __name__ == "__main__":
 
 
 
 
 
 
 
 
 
 
224
  demo.launch()
225
+
mosaic_generator/__init__.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mosaic Generator Package
3
+
4
+ A high-performance image mosaic generator that reconstructs input images
5
+ as mosaics composed of small tiles using optimized vectorized operations.
6
+
7
+ Main components:
8
+ - image_processor: Image loading, preprocessing, and grid division
9
+ - tile_manager: Tile loading, caching, and feature extraction
10
+ - mosaic_builder: Main mosaic construction algorithms
11
+ - metrics: Similarity metrics (MSE, SSIM)
12
+ - config: Configuration constants
13
+ - utils: Helper functions
14
+ """
15
+
16
+ from mosaic_generator.config import (
17
+ ASSETS_DIR,
18
+ DEFAULT_CROPS_PER_IMAGE,
19
+ DEFAULT_GRID_CELLS,
20
+ DEFAULT_MAX_SIDE_PX,
21
+ DEFAULT_TILE_PATHS,
22
+ DEFAULT_TILE_SIZE,
23
+ )
24
+ from mosaic_generator.image_processor import (
25
+ image_to_cells_mean_lab,
26
+ load_and_preprocess_image,
27
+ )
28
+ from mosaic_generator.metrics import compute_metrics
29
+ from mosaic_generator.mosaic_builder import (
30
+ MosaicBuilder,
31
+ mosaic_fully_vectorized,
32
+ mosaic_loop_optimized,
33
+ )
34
+ from mosaic_generator.tile_manager import TileManager, TileSet, build_tile_set
35
+ from mosaic_generator.utils import generate_assets
36
+
37
+ __all__ = [
38
+ # Configuration
39
+ "ASSETS_DIR",
40
+ "DEFAULT_CROPS_PER_IMAGE",
41
+ "DEFAULT_GRID_CELLS",
42
+ "DEFAULT_MAX_SIDE_PX",
43
+ "DEFAULT_TILE_PATHS",
44
+ "DEFAULT_TILE_SIZE",
45
+ # Image processing
46
+ "image_to_cells_mean_lab",
47
+ "load_and_preprocess_image",
48
+ # Tile management
49
+ "TileManager",
50
+ "TileSet",
51
+ "build_tile_set",
52
+ # Mosaic building
53
+ "MosaicBuilder",
54
+ "mosaic_fully_vectorized",
55
+ "mosaic_loop_optimized",
56
+ # Metrics
57
+ "compute_metrics",
58
+ # Utilities
59
+ "generate_assets",
60
+ ]
61
+
mosaic_generator/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (1.58 kB). View file
 
mosaic_generator/__pycache__/config.cpython-313.pyc ADDED
Binary file (1.26 kB). View file
 
mosaic_generator/__pycache__/image_processor.cpython-313.pyc ADDED
Binary file (6.84 kB). View file
 
mosaic_generator/__pycache__/metrics.cpython-313.pyc ADDED
Binary file (1.94 kB). View file
 
mosaic_generator/__pycache__/mosaic_builder.cpython-313.pyc ADDED
Binary file (9.79 kB). View file
 
mosaic_generator/__pycache__/tile_manager.cpython-313.pyc ADDED
Binary file (8.72 kB). View file
 
mosaic_generator/__pycache__/utils.cpython-313.pyc ADDED
Binary file (14.3 kB). View file
 
mosaic_generator/config.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration constants for the mosaic generator.
3
+
4
+ This module contains default values and configuration settings used throughout
5
+ the mosaic generation pipeline.
6
+ """
7
+
8
+ import os
9
+
10
+ # Directory to store generated assets
11
+ ASSETS_DIR = "assets"
12
+
13
+ # Default tile size in pixels
14
+ DEFAULT_TILE_SIZE = 24
15
+
16
+ # Default grid cells per side
17
+ DEFAULT_GRID_CELLS = 32
18
+
19
+ # Default maximum side dimension in pixels for image preprocessing
20
+ DEFAULT_MAX_SIDE_PX = 768
21
+
22
+ # Default number of crops per image for tile diversity
23
+ DEFAULT_CROPS_PER_IMAGE = 4
24
+
25
+ # Paths to default tile images
26
+ DEFAULT_TILE_PATHS = [
27
+ os.path.join(ASSETS_DIR, "astronaut.png"),
28
+ os.path.join(ASSETS_DIR, "chelsea_cat.png"),
29
+ os.path.join(ASSETS_DIR, "coffee.png"),
30
+ os.path.join(ASSETS_DIR, "rocket.png"),
31
+ os.path.join(ASSETS_DIR, "camera.png"),
32
+ os.path.join(ASSETS_DIR, "text.png"),
33
+ os.path.join(ASSETS_DIR, "mario_like.png"),
34
+ ]
35
+
mosaic_generator/image_processor.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Image processing functions for loading, preprocessing, and grid division.
3
+
4
+ This module handles image loading, preprocessing (resizing, quantization),
5
+ and converting images into grid cells for mosaic generation.
6
+ """
7
+
8
+ from typing import Optional, Tuple
9
+
10
+ import numpy as np
11
+ from PIL import Image
12
+ from skimage import color
13
+
14
+ from mosaic_generator.config import DEFAULT_GRID_CELLS, DEFAULT_MAX_SIDE_PX
15
+ from mosaic_generator.utils import (
16
+ ValidationError,
17
+ validate_grid_cells,
18
+ validate_image_size,
19
+ )
20
+
21
+
22
+ def load_and_preprocess_image(
23
+ image_path_or_pil: Image.Image | str,
24
+ grid_cells: int = DEFAULT_GRID_CELLS,
25
+ quantize_colors: Optional[int] = None,
26
+ max_side_px: int = DEFAULT_MAX_SIDE_PX
27
+ ) -> Image.Image:
28
+ """
29
+ Load and preprocess an image for mosaic generation.
30
+
31
+ Steps: load, optional color quantization, resize, crop to grid multiple.
32
+
33
+ Args:
34
+ image_path_or_pil: Either a PIL Image or path to image file.
35
+ grid_cells: Number of grid cells per side (image will be cropped to multiple of this).
36
+ quantize_colors: Optional number of colors for quantization (None/0 = disabled).
37
+ max_side_px: Maximum dimension (width or height) in pixels.
38
+
39
+ Returns:
40
+ Image.Image: Preprocessed PIL Image (RGB, resized, cropped to grid multiple).
41
+
42
+ Raises:
43
+ ValidationError: If image cannot be loaded or parameters are invalid.
44
+ """
45
+ # Validate grid_cells parameter
46
+ validate_grid_cells(grid_cells)
47
+
48
+ # Load image (handle both PIL Image and file path)
49
+ try:
50
+ if isinstance(image_path_or_pil, Image.Image):
51
+ img = image_path_or_pil.convert("RGB")
52
+ else:
53
+ if not isinstance(image_path_or_pil, str):
54
+ raise ValidationError(
55
+ f"Expected PIL Image or file path (string), got {type(image_path_or_pil).__name__}"
56
+ )
57
+ try:
58
+ img = Image.open(image_path_or_pil).convert("RGB")
59
+ except FileNotFoundError:
60
+ raise ValidationError(
61
+ f"Image file not found: {image_path_or_pil}. "
62
+ f"Please ensure the file exists and the path is correct."
63
+ )
64
+ except Exception as e:
65
+ raise ValidationError(
66
+ f"Failed to load image from {image_path_or_pil}: {str(e)}. "
67
+ f"Please ensure the file is a valid image format."
68
+ )
69
+ except ValidationError:
70
+ raise
71
+ except Exception as e:
72
+ raise ValidationError(
73
+ f"Unexpected error loading image: {str(e)}"
74
+ )
75
+
76
+ # Validate image dimensions
77
+ validate_image_size(img)
78
+
79
+ # Optional color quantization using median-cut algorithm
80
+ if quantize_colors is not None and quantize_colors > 0:
81
+ try:
82
+ quantize_colors = int(quantize_colors)
83
+ if quantize_colors < 2:
84
+ raise ValidationError(
85
+ f"Quantize colors must be at least 2, got: {quantize_colors}"
86
+ )
87
+ if quantize_colors > 256:
88
+ raise ValidationError(
89
+ f"Quantize colors cannot exceed 256, got: {quantize_colors}"
90
+ )
91
+ img = img.quantize(colors=quantize_colors, method=Image.MEDIANCUT).convert("RGB")
92
+ except ValueError as e:
93
+ raise ValidationError(
94
+ f"Invalid quantize_colors value: {quantize_colors}. {str(e)}"
95
+ )
96
+
97
+ # Resize if image is too large
98
+ w, h = img.size
99
+ if max_side_px <= 0:
100
+ raise ValidationError(
101
+ f"max_side_px must be positive, got: {max_side_px}"
102
+ )
103
+ scale = max_side_px / max(w, h)
104
+ if scale < 1.0:
105
+ img = img.resize((int(round(w * scale)), int(round(h * scale))), resample=Image.LANCZOS)
106
+
107
+ # Crop to ensure dimensions are multiples of grid_cells
108
+ w, h = img.size
109
+ w_crop = (w // grid_cells) * grid_cells
110
+ h_crop = (h // grid_cells) * grid_cells
111
+
112
+ if w_crop == 0 or h_crop == 0:
113
+ raise ValidationError(
114
+ f"Image dimensions ({w}x{h}px) are too small for grid size {grid_cells}x{grid_cells}. "
115
+ f"After cropping to grid multiples, dimensions would be {w_crop}x{h_crop}px, "
116
+ f"which is invalid. Please use a larger image or smaller grid size."
117
+ )
118
+
119
+ left, top = (w - w_crop) // 2, (h - h_crop) // 2
120
+ return img.crop((left, top, left + w_crop, top + h_crop))
121
+
122
+
123
+ def image_to_cells_mean_lab(
124
+ img: Image.Image,
125
+ grid_cells: int
126
+ ) -> Tuple[np.ndarray, Tuple[int, int], Tuple[int, int]]:
127
+ """
128
+ Convert image to grid cells and compute mean LAB color for each cell.
129
+
130
+ Uses vectorized operations for efficiency. Converts entire image to LAB
131
+ once, then extracts cell means.
132
+
133
+ Args:
134
+ img: Input PIL Image.
135
+ grid_cells: Number of grid cells per side (grid_cells x grid_cells total).
136
+
137
+ Returns:
138
+ Tuple containing:
139
+ - cell_means_lab: Array of shape (rows, cols, 3) with mean LAB per cell.
140
+ - (rows, cols): Grid dimensions.
141
+ - (cell_h, cell_w): Cell dimensions in pixels.
142
+
143
+ Raises:
144
+ ValidationError: If image dimensions are incompatible with grid size.
145
+ """
146
+ # Validate grid_cells
147
+ validate_grid_cells(grid_cells)
148
+
149
+ # Validate image
150
+ if img is None:
151
+ raise ValidationError("Image cannot be None")
152
+
153
+ arr = np.asarray(img, dtype=np.uint8)
154
+ h, w, _ = arr.shape
155
+ rows = cols = grid_cells
156
+ cell_h, cell_w = h // rows, w // cols
157
+
158
+ if cell_h == 0 or cell_w == 0:
159
+ raise ValidationError(
160
+ f"Image dimensions ({w}x{h}px) are too small for grid size {grid_cells}x{grid_cells}. "
161
+ f"Cell dimensions would be {cell_w}x{cell_h}px, which is invalid."
162
+ )
163
+
164
+ # Crop to exact multiple of grid
165
+ arr = arr[:rows*cell_h, :cols*cell_w, :]
166
+
167
+ # OPTIMIZATION: Convert ENTIRE image to LAB ONCE (not per cell)
168
+ # This is the key optimization - single conversion instead of many
169
+ arr_lab = color.rgb2lab(arr / 255.0) # Single conversion for entire image
170
+
171
+ # Reshape into grid: (rows, cell_h, cols, cell_w, 3) -> (rows, cols, cell_h, cell_w, 3)
172
+ grid_lab = arr_lab.reshape(rows, cell_h, cols, cell_w, 3).swapaxes(1, 2)
173
+
174
+ # Compute mean LAB color per cell: average over cell_h and cell_w dimensions
175
+ means = grid_lab.mean(axis=(2, 3)) # Result: (rows, cols, 3)
176
+ return means, (rows, cols), (cell_h, cell_w)
177
+
mosaic_generator/metrics.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Similarity metrics for comparing original and mosaic images.
3
+
4
+ This module provides functions to compute similarity metrics between
5
+ the original input image and the generated mosaic.
6
+ """
7
+
8
+ import numpy as np
9
+ from PIL import Image
10
+ from skimage import metrics
11
+
12
+
13
+ def compute_metrics(
14
+ original_rgb: Image.Image,
15
+ mosaic_rgb: Image.Image
16
+ ) -> tuple[float, float]:
17
+ """
18
+ Compute similarity metrics between original and mosaic images.
19
+
20
+ Metrics:
21
+ - MSE (Mean Squared Error): Lower is better
22
+ - SSIM (Structural Similarity Index): Higher is better (0-1 range)
23
+
24
+ Args:
25
+ original_rgb: Original input image.
26
+ mosaic_rgb: Generated mosaic image.
27
+
28
+ Returns:
29
+ tuple[float, float]: Tuple of (mse, ssim) as floats.
30
+ """
31
+ # Resize mosaic to original size for fair comparison
32
+ M = mosaic_rgb.resize(original_rgb.size, resample=Image.NEAREST)
33
+
34
+ # Convert to normalized float arrays
35
+ a = np.asarray(original_rgb.convert("RGB"), dtype=np.float32) / 255.0
36
+ b = np.asarray(M.convert("RGB"), dtype=np.float32) / 255.0
37
+
38
+ # Compute MSE
39
+ mse = float(np.mean((a - b) ** 2))
40
+
41
+ # Compute SSIM (structural similarity)
42
+ ssim = float(metrics.structural_similarity(a, b, channel_axis=2, data_range=1.0))
43
+
44
+ return mse, ssim
45
+
mosaic_generator/mosaic_builder.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main mosaic construction logic.
3
+
4
+ This module contains the core algorithms for building mosaics from input images
5
+ using optimized vectorized operations.
6
+ """
7
+
8
+ import numpy as np
9
+ from PIL import Image
10
+ from skimage import color
11
+
12
+ from mosaic_generator.image_processor import image_to_cells_mean_lab
13
+ from mosaic_generator.tile_manager import TileSet
14
+ from mosaic_generator.utils import ValidationError, validate_grid_cells
15
+
16
+
17
+ class MosaicBuilder:
18
+ """
19
+ Main mosaic construction class.
20
+
21
+ Provides optimized methods for building image mosaics using vectorized
22
+ operations and efficient algorithms.
23
+ """
24
+
25
+ def __init__(self, tiles: TileSet):
26
+ """
27
+ Initialize MosaicBuilder with a tile set.
28
+
29
+ Args:
30
+ tiles: TileSet containing available tiles for mosaic construction.
31
+ """
32
+ self.tiles = tiles
33
+
34
+ def build_vectorized(
35
+ self,
36
+ img: Image.Image,
37
+ grid_cells: int
38
+ ) -> Image.Image:
39
+ """
40
+ Fully optimized mosaic generation with ALL operations vectorized.
41
+
42
+ No nested loops - everything uses NumPy array operations.
43
+
44
+ Optimizations:
45
+ 1. Single LAB conversion for entire image
46
+ 2. Vectorized distance calculation
47
+ 3. Vectorized tile placement using advanced indexing
48
+
49
+ Args:
50
+ img: Input PIL Image.
51
+ grid_cells: Number of grid cells per side.
52
+
53
+ Returns:
54
+ Image.Image: PIL Image of the generated mosaic.
55
+
56
+ Raises:
57
+ ValidationError: If parameters are invalid or tiles are missing.
58
+ """
59
+ # Validate inputs
60
+ validate_grid_cells(grid_cells)
61
+
62
+ if img is None:
63
+ raise ValidationError("Image cannot be None")
64
+
65
+ if self.tiles is None:
66
+ raise ValidationError("TileSet is None. Cannot build mosaic without tiles.")
67
+
68
+ if len(self.tiles.tiles_rgb) == 0:
69
+ raise ValidationError(
70
+ "TileSet is empty. No tiles available for mosaic generation. "
71
+ "Please ensure tile images are loaded correctly."
72
+ )
73
+
74
+ if self.tiles.tile_h <= 0 or self.tiles.tile_w <= 0:
75
+ raise ValidationError(
76
+ f"Invalid tile dimensions: {self.tiles.tile_h}x{self.tiles.tile_w}px. "
77
+ f"Tile dimensions must be positive."
78
+ )
79
+
80
+ # Step 1: Compute mean LAB for each grid cell (optimized - single conversion)
81
+ cell_means_lab, (rows, cols), _ = image_to_cells_mean_lab(img, grid_cells)
82
+
83
+ # Step 2: Find best matching tile for each cell (vectorized)
84
+ best_idx = self.tiles.find_nearest_tile_vectorized(cell_means_lab) # (rows, cols)
85
+
86
+ # Step 3: Vectorized tile placement - NO LOOPS!
87
+ out_h, out_w = rows * self.tiles.tile_h, cols * self.tiles.tile_w
88
+
89
+ # OPTIMIZATION: Use advanced indexing to place all tiles at once
90
+ # Flatten the indices
91
+ tile_indices = best_idx.flatten() # (rows*cols,)
92
+
93
+ # Validate tile indices are within bounds
94
+ if np.any(tile_indices < 0) or np.any(tile_indices >= len(self.tiles.tiles_rgb)):
95
+ raise ValidationError(
96
+ f"Tile index out of bounds. "
97
+ f"Indices range: [{tile_indices.min()}, {tile_indices.max()}], "
98
+ f"but only {len(self.tiles.tiles_rgb)} tiles available."
99
+ )
100
+
101
+ # Select all tiles at once using advanced indexing
102
+ selected_tiles = self.tiles.tiles_rgb[tile_indices] # (rows*cols, tile_h, tile_w, 3)
103
+
104
+ # Reshape to grid layout: (rows, cols, tile_h, tile_w, 3)
105
+ grid_tiles = selected_tiles.reshape(rows, cols, self.tiles.tile_h, self.tiles.tile_w, 3)
106
+
107
+ # Reshape to final output: (out_h, out_w, 3)
108
+ # We need to interleave rows with tile_h and cols with tile_w
109
+ # Transpose to (rows, tile_h, cols, tile_w, 3) then reshape
110
+ out = grid_tiles.transpose(0, 2, 1, 3, 4).reshape(out_h, out_w, 3)
111
+
112
+ return Image.fromarray(out.astype(np.uint8))
113
+
114
+ def build_loop_optimized(
115
+ self,
116
+ img: Image.Image,
117
+ grid_cells: int
118
+ ) -> Image.Image:
119
+ """
120
+ Optimized loop method that addresses key bottlenecks.
121
+
122
+ Optimizations:
123
+ 1. Convert entire image to LAB ONCE (not per cell)
124
+ 2. Use vectorized distance calculation for tile matching
125
+ 3. Still uses loops for clarity, but much faster
126
+
127
+ Args:
128
+ img: Input PIL Image.
129
+ grid_cells: Number of grid cells per side.
130
+
131
+ Returns:
132
+ Image.Image: PIL Image of the generated mosaic.
133
+
134
+ Raises:
135
+ ValidationError: If parameters are invalid or tiles are missing.
136
+ """
137
+ # Validate inputs
138
+ validate_grid_cells(grid_cells)
139
+
140
+ if img is None:
141
+ raise ValidationError("Image cannot be None")
142
+
143
+ if self.tiles is None:
144
+ raise ValidationError("TileSet is None. Cannot build mosaic without tiles.")
145
+
146
+ if len(self.tiles.tiles_rgb) == 0:
147
+ raise ValidationError(
148
+ "TileSet is empty. No tiles available for mosaic generation. "
149
+ "Please ensure tile images are loaded correctly."
150
+ )
151
+
152
+ if self.tiles.tile_h <= 0 or self.tiles.tile_w <= 0:
153
+ raise ValidationError(
154
+ f"Invalid tile dimensions: {self.tiles.tile_h}x{self.tiles.tile_w}px. "
155
+ f"Tile dimensions must be positive."
156
+ )
157
+
158
+ # Convert to numpy array
159
+ arr = np.asarray(img, dtype=np.uint8)
160
+ h, w, _ = arr.shape
161
+ rows = cols = grid_cells
162
+ cell_h, cell_w = h // rows, w // cols
163
+
164
+ if cell_h == 0 or cell_w == 0:
165
+ raise ValidationError(
166
+ f"Image dimensions ({w}x{h}px) are too small for grid size {grid_cells}x{grid_cells}. "
167
+ f"Cell dimensions would be {cell_w}x{cell_h}px, which is invalid."
168
+ )
169
+
170
+ arr = arr[:rows*cell_h, :cols*cell_w, :]
171
+
172
+ # OPTIMIZATION #1: Convert ENTIRE image to LAB ONCE
173
+ arr_lab = color.rgb2lab(arr / 255.0) # Single conversion!
174
+
175
+ # Initialize output mosaic
176
+ out_h, out_w = rows * self.tiles.tile_h, cols * self.tiles.tile_w
177
+ out = np.zeros((out_h, out_w, 3), dtype=np.uint8)
178
+
179
+ # Process each grid cell
180
+ for r in range(rows):
181
+ for c in range(cols):
182
+ # Extract cell region from LAB space (no conversion needed!)
183
+ cell_lab = arr_lab[r*cell_h:(r+1)*cell_h, c*cell_w:(c+1)*cell_w, :]
184
+
185
+ # Compute mean LAB color
186
+ mean = cell_lab.reshape(-1, 3).mean(axis=0) # (3,)
187
+
188
+ # OPTIMIZATION #2: Vectorized distance calculation (no inner loop!)
189
+ diff = mean - self.tiles.means_lab # (num_tiles, 3)
190
+ dists = np.sum(diff**2, axis=1) # (num_tiles,)
191
+ best_j = np.argmin(dists) # Single operation instead of loop
192
+
193
+ # Validate tile index
194
+ if best_j < 0 or best_j >= len(self.tiles.tiles_rgb):
195
+ raise ValidationError(
196
+ f"Tile index out of bounds: {best_j} (available: 0-{len(self.tiles.tiles_rgb)-1})"
197
+ )
198
+
199
+ # Place best matching tile
200
+ t = self.tiles.tiles_rgb[best_j]
201
+ out[r*self.tiles.tile_h:(r+1)*self.tiles.tile_h,
202
+ c*self.tiles.tile_w:(c+1)*self.tiles.tile_w, :] = t
203
+
204
+ return Image.fromarray(out)
205
+
206
+
207
+ # Convenience functions for backward compatibility
208
+ def mosaic_fully_vectorized(
209
+ img: Image.Image,
210
+ tiles: TileSet,
211
+ grid_cells: int
212
+ ) -> Image.Image:
213
+ """
214
+ Fully optimized mosaic generation with ALL operations vectorized.
215
+
216
+ Convenience function that creates a MosaicBuilder and calls build_vectorized.
217
+
218
+ Args:
219
+ img: Input PIL Image.
220
+ tiles: TileSet containing available tiles.
221
+ grid_cells: Number of grid cells per side.
222
+
223
+ Returns:
224
+ Image.Image: PIL Image of the generated mosaic.
225
+ """
226
+ builder = MosaicBuilder(tiles)
227
+ return builder.build_vectorized(img, grid_cells)
228
+
229
+
230
+ def mosaic_loop_optimized(
231
+ img: Image.Image,
232
+ tiles: TileSet,
233
+ grid_cells: int
234
+ ) -> Image.Image:
235
+ """
236
+ Optimized loop method that addresses key bottlenecks.
237
+
238
+ Convenience function that creates a MosaicBuilder and calls build_loop_optimized.
239
+
240
+ Args:
241
+ img: Input PIL Image.
242
+ tiles: TileSet containing available tiles.
243
+ grid_cells: Number of grid cells per side.
244
+
245
+ Returns:
246
+ Image.Image: PIL Image of the generated mosaic.
247
+ """
248
+ builder = MosaicBuilder(tiles)
249
+ return builder.build_loop_optimized(img, grid_cells)
250
+
mosaic_generator/tile_manager.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tile management: loading, caching, and feature extraction.
3
+
4
+ This module handles the TileSet class that manages tile images, precomputed
5
+ features, and provides efficient tile matching capabilities.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import List, Optional
10
+
11
+ import numpy as np
12
+ from PIL import Image
13
+ from skimage import color
14
+
15
+ from mosaic_generator.config import DEFAULT_CROPS_PER_IMAGE, DEFAULT_TILE_SIZE
16
+ from mosaic_generator.utils import ValidationError, _multi_crops, validate_tile_paths
17
+
18
+
19
+ @dataclass
20
+ class TileSet:
21
+ """
22
+ Data structure to hold tile images and their precomputed mean LAB colors.
23
+
24
+ This allows fast color matching without recomputing LAB conversions.
25
+ Optionally includes a KD-tree for fast nearest neighbor search.
26
+ """
27
+ tiles_rgb: np.ndarray # Shape: (num_tiles, tile_h, tile_w, 3), dtype: uint8
28
+ means_lab: np.ndarray # Shape: (num_tiles, 3), dtype: float64 - mean LAB color per tile
29
+ tile_h: int # Height of each tile in pixels
30
+ tile_w: int # Width of each tile in pixels
31
+ _kdtree: Optional[object] = None # Optional KD-tree for fast nearest neighbor search
32
+
33
+ def __post_init__(self) -> None:
34
+ """
35
+ Initialize KD-tree for fast nearest neighbor search if scipy is available.
36
+ """
37
+ try:
38
+ from scipy.spatial import cKDTree
39
+ self._kdtree = cKDTree(self.means_lab)
40
+ except ImportError:
41
+ self._kdtree = None
42
+
43
+ def find_nearest_tile_vectorized(self, cell_means_lab: np.ndarray) -> np.ndarray:
44
+ """
45
+ Find nearest tile for each cell using vectorized operations.
46
+
47
+ Much faster than looping through tiles. Uses broadcasting to compute
48
+ all distances simultaneously.
49
+
50
+ Args:
51
+ cell_means_lab: Array of shape (rows, cols, 3) with mean LAB per cell.
52
+
53
+ Returns:
54
+ np.ndarray: Array of shape (rows, cols) with tile indices.
55
+ """
56
+ # Use broadcasting to compute all distances at once
57
+ # cell_means_lab: (rows, cols, 3)
58
+ # self.means_lab: (num_tiles, 3)
59
+ # diff: (rows, cols, num_tiles, 3)
60
+ diff = cell_means_lab[..., None, :] - self.means_lab[None, None, :, :]
61
+
62
+ # Compute squared Euclidean distances
63
+ dists = np.sum(diff**2, axis=-1) # (rows, cols, num_tiles)
64
+
65
+ # Find best matching tile index for each cell
66
+ best_idx = np.argmin(dists, axis=-1) # (rows, cols)
67
+ return best_idx
68
+
69
+
70
+ def build_tile_set(
71
+ image_paths: List[str],
72
+ tile_size: int = DEFAULT_TILE_SIZE,
73
+ crops_per_image: int = DEFAULT_CROPS_PER_IMAGE
74
+ ) -> TileSet:
75
+ """
76
+ Build a set of tiles from input images.
77
+
78
+ For each image, extracts multiple crops, resizes to tile_size, and computes mean LAB color.
79
+ Handles missing tiles gracefully by skipping invalid paths and using only available tiles.
80
+
81
+ Args:
82
+ image_paths: List of paths to source images.
83
+ tile_size: Size of each tile (tile_size x tile_size pixels).
84
+ crops_per_image: Number of crops to extract per source image.
85
+
86
+ Returns:
87
+ TileSet: TileSet object containing all tiles and their mean LAB colors.
88
+
89
+ Raises:
90
+ ValidationError: If no valid tiles can be loaded from the provided paths.
91
+ """
92
+ # Validate and filter tile paths
93
+ valid_paths, missing_paths = validate_tile_paths(image_paths)
94
+
95
+ if missing_paths:
96
+ print(f"Warning: Skipping {len(missing_paths)} missing tile image(s): {', '.join(missing_paths)}")
97
+
98
+ tiles, means = [], []
99
+ failed_paths = []
100
+
101
+ for path in valid_paths:
102
+ try:
103
+ # Load and convert to RGB
104
+ pil = Image.open(path).convert("RGB")
105
+ # Extract multiple crops for diversity
106
+ for c in _multi_crops(pil, how_many=crops_per_image):
107
+ # Resize crop to tile size using high-quality resampling
108
+ t = c.resize((tile_size, tile_size), resample=Image.LANCZOS)
109
+ arr = np.asarray(t, dtype=np.uint8)
110
+ tiles.append(arr)
111
+ # Convert to LAB color space and compute mean color
112
+ lab = color.rgb2lab(arr / 255.0)
113
+ means.append(lab.reshape(-1, 3).mean(axis=0))
114
+ except Exception as e:
115
+ failed_paths.append(path)
116
+ print(f"Warning: Failed to load tile from {path}: {str(e)}")
117
+ continue
118
+
119
+ if not tiles:
120
+ error_msg = (
121
+ f"Failed to load any tiles from the provided paths. "
122
+ f"Valid paths checked: {len(valid_paths)}, "
123
+ f"Failed to load: {len(failed_paths)}"
124
+ )
125
+ if failed_paths:
126
+ error_msg += f" Failed paths: {', '.join(failed_paths)}"
127
+ if missing_paths:
128
+ error_msg += f" Missing paths: {', '.join(missing_paths)}"
129
+ raise ValidationError(error_msg)
130
+
131
+ if failed_paths:
132
+ print(f"Warning: Successfully loaded tiles from {len(valid_paths) - len(failed_paths)}/{len(valid_paths)} images. "
133
+ f"Using {len(tiles)} total tiles.")
134
+
135
+ # Stack all tiles and means into arrays
136
+ tiles_arr = np.stack(tiles, axis=0)
137
+ means_arr = np.stack(means, axis=0)
138
+
139
+ # Return TileSet (will automatically build KD-tree)
140
+ return TileSet(tiles_rgb=tiles_arr, means_lab=means_arr, tile_h=tile_size, tile_w=tile_size)
141
+
142
+
143
+ class TileManager:
144
+ """
145
+ Tile manager class that handles loading, caching, and feature extraction.
146
+
147
+ Manages TileSet instances and provides methods for building and managing
148
+ tile collections with precomputed features for efficient matching.
149
+ """
150
+
151
+ def __init__(
152
+ self,
153
+ image_paths: List[str],
154
+ tile_size: int = DEFAULT_TILE_SIZE,
155
+ crops_per_image: int = DEFAULT_CROPS_PER_IMAGE
156
+ ):
157
+ """
158
+ Initialize TileManager with image paths.
159
+
160
+ Args:
161
+ image_paths: List of paths to source images.
162
+ tile_size: Size of each tile (tile_size x tile_size pixels).
163
+ crops_per_image: Number of crops to extract per source image.
164
+ """
165
+ self.image_paths = image_paths
166
+ self.tile_size = tile_size
167
+ self.crops_per_image = crops_per_image
168
+ self._tile_set: Optional[TileSet] = None
169
+
170
+ def build_tiles(self) -> TileSet:
171
+ """
172
+ Build tile set from configured image paths.
173
+
174
+ Loads images, extracts crops, resizes to tile size, and computes
175
+ mean LAB colors. Results are cached for subsequent calls.
176
+
177
+ Returns:
178
+ TileSet: TileSet object containing all tiles and their mean LAB colors.
179
+ """
180
+ if self._tile_set is None:
181
+ self._tile_set = build_tile_set(
182
+ self.image_paths,
183
+ tile_size=self.tile_size,
184
+ crops_per_image=self.crops_per_image
185
+ )
186
+ return self._tile_set
187
+
188
+ @property
189
+ def tile_set(self) -> TileSet:
190
+ """
191
+ Get the tile set, building it if necessary.
192
+
193
+ Returns:
194
+ TileSet: The managed tile set.
195
+ """
196
+ return self.build_tiles()
197
+
198
+ def clear_cache(self) -> None:
199
+ """
200
+ Clear the cached tile set.
201
+
202
+ Forces a rebuild on the next access.
203
+ """
204
+ self._tile_set = None
205
+
206
+ def reload(self) -> TileSet:
207
+ """
208
+ Force reload of tiles from image paths.
209
+
210
+ Clears cache and rebuilds the tile set.
211
+
212
+ Returns:
213
+ TileSet: Newly built tile set.
214
+ """
215
+ self.clear_cache()
216
+ return self.build_tiles()
217
+
mosaic_generator/utils.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Helper utility functions for the mosaic generator.
3
+
4
+ This module contains utility functions used across the mosaic generation pipeline,
5
+ including asset generation and image cropping utilities.
6
+ """
7
+
8
+ import os
9
+ from typing import List, Tuple
10
+
11
+ import numpy as np
12
+ from PIL import Image
13
+ from skimage import data
14
+
15
+ from mosaic_generator.config import ASSETS_DIR
16
+
17
+
18
+ class ValidationError(Exception):
19
+ """Custom exception for validation errors with informative messages."""
20
+ pass
21
+
22
+
23
+ def _save_skimage_samples() -> List[str]:
24
+ """
25
+ Save sample images from skimage data module to assets directory.
26
+
27
+ Returns:
28
+ List[str]: List of file paths to the saved images.
29
+ """
30
+ samples = [
31
+ (data.astronaut(), "astronaut.png"),
32
+ (data.chelsea(), "chelsea_cat.png"),
33
+ (data.coffee(), "coffee.png"),
34
+ (data.rocket(), "rocket.png"),
35
+ (data.camera(), "camera.png"),
36
+ (data.text(), "text.png"),
37
+ ]
38
+ paths: List[str] = []
39
+ for arr, name in samples:
40
+ img = Image.fromarray(arr)
41
+ path = os.path.join(ASSETS_DIR, name)
42
+ if not os.path.exists(path):
43
+ img.save(path)
44
+ paths.append(path)
45
+ return paths
46
+
47
+
48
+ def _make_mario_like_sprite(scale: int = 8) -> str:
49
+ """
50
+ Create a Mario-like pixel sprite and save it.
51
+
52
+ Uses a color palette and grid pattern to create a simple sprite image.
53
+
54
+ Args:
55
+ scale: Scaling factor for the sprite (default 8).
56
+
57
+ Returns:
58
+ str: Path to the saved sprite image.
59
+ """
60
+ palette = {
61
+ 0: (255, 255, 255), # white
62
+ 1: (255, 205, 148), # skin
63
+ 2: (200, 30, 30), # red
64
+ 3: (40, 80, 200), # blue
65
+ 4: (120, 70, 30), # brown
66
+ 5: (10, 10, 10), # black
67
+ 6: (240, 200, 60), # yellow
68
+ }
69
+ grid = np.array([
70
+ [0,0,0,0,0,2,2,2,2,0,0,0,0,0,0,0],
71
+ [0,0,0,0,2,2,2,2,2,2,0,0,0,0,0,0],
72
+ [0,0,0,4,4,1,1,1,1,4,4,0,0,0,0,0],
73
+ [0,0,4,1,1,1,1,1,1,1,1,4,0,0,0,0],
74
+ [0,0,4,1,5,1,1,1,1,5,1,4,0,0,0,0],
75
+ [0,0,4,1,1,1,1,1,1,1,1,4,0,0,0,0],
76
+ [0,0,0,4,4,1,1,1,1,4,4,0,0,0,0,0],
77
+ [0,0,0,0,3,3,3,3,3,3,0,0,0,0,0,0],
78
+ [0,0,0,3,3,3,3,3,3,3,3,0,0,0,0,0],
79
+ [0,0,4,4,3,4,3,3,3,4,4,4,0,0,0,0],
80
+ [0,4,4,4,4,4,4,4,4,4,4,4,4,0,0,0],
81
+ [0,0,0,2,2,0,0,0,0,2,2,0,0,0,0,0],
82
+ [0,0,2,2,2,0,0,0,0,2,2,2,0,0,0,0],
83
+ [0,2,2,2,2,2,2,0,2,2,2,2,2,0,0,0],
84
+ [0,2,2,0,0,2,2,2,2,0,0,2,2,0,0,0],
85
+ [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
86
+ ], dtype=np.uint8)
87
+
88
+ h, w = grid.shape
89
+ rgb = np.zeros((h, w, 3), dtype=np.uint8)
90
+ for k, col in palette.items():
91
+ rgb[grid == k] = col
92
+
93
+ img = Image.fromarray(rgb).resize((w*scale, h*scale), resample=Image.NEAREST)
94
+ path = os.path.join(ASSETS_DIR, "mario_like.png")
95
+ if not os.path.exists(path):
96
+ img.save(path)
97
+ return path
98
+
99
+
100
+ def _multi_crops(img: Image.Image, how_many: int = 4) -> List[Image.Image]:
101
+ """
102
+ Extract multiple square crops from an image to create tile diversity.
103
+
104
+ Crops from center and corners to get varied content.
105
+
106
+ Args:
107
+ img: Input PIL Image.
108
+ how_many: Number of crops to return (max 4).
109
+
110
+ Returns:
111
+ List[Image.Image]: List of cropped PIL Images.
112
+ """
113
+ w, h = img.size
114
+ s = min(w, h) # Size of square crop
115
+ return [
116
+ img.crop(((w-s)//2, (h-s)//2, (w+s)//2, (h+s)//2)), # center crop
117
+ img.crop((0, 0, s, s)), # top-left corner
118
+ img.crop((w-s, 0, w, s)), # top-right corner
119
+ img.crop((0, h-s, s, h)), # bottom-left corner
120
+ ][:how_many]
121
+
122
+
123
+ def generate_assets() -> None:
124
+ """
125
+ Generate all required assets (sample images and sprite).
126
+
127
+ Creates the assets directory if it doesn't exist and generates
128
+ all sample images and the Mario-like sprite.
129
+ """
130
+ os.makedirs(ASSETS_DIR, exist_ok=True)
131
+ _save_skimage_samples()
132
+ _make_mario_like_sprite(scale=8)
133
+
134
+
135
+ def validate_grid_cells(grid_cells: int, min_cells: int = 4, max_cells: int = 128) -> None:
136
+ """
137
+ Validate grid cells parameter.
138
+
139
+ Args:
140
+ grid_cells: Number of grid cells per side.
141
+ min_cells: Minimum allowed grid cells (default: 4).
142
+ max_cells: Maximum allowed grid cells (default: 128).
143
+
144
+ Raises:
145
+ ValidationError: If grid_cells is invalid.
146
+ """
147
+ if not isinstance(grid_cells, (int, float)):
148
+ raise ValidationError(
149
+ f"Grid cells must be a number, got {type(grid_cells).__name__}: {grid_cells}"
150
+ )
151
+
152
+ grid_cells = int(grid_cells)
153
+
154
+ if grid_cells < min_cells:
155
+ raise ValidationError(
156
+ f"Grid cells ({grid_cells}) must be at least {min_cells}. "
157
+ f"Smaller values would create too few cells for a meaningful mosaic."
158
+ )
159
+
160
+ if grid_cells > max_cells:
161
+ raise ValidationError(
162
+ f"Grid cells ({grid_cells}) exceeds maximum of {max_cells}. "
163
+ f"Larger values may cause performance issues or memory errors."
164
+ )
165
+
166
+ if grid_cells <= 0:
167
+ raise ValidationError(
168
+ f"Grid cells must be positive, got: {grid_cells}"
169
+ )
170
+
171
+
172
+ def validate_tile_size(tile_size: int, min_size: int = 4, max_size: int = 256) -> None:
173
+ """
174
+ Validate tile size parameter.
175
+
176
+ Args:
177
+ tile_size: Size of each tile in pixels.
178
+ min_size: Minimum allowed tile size (default: 4).
179
+ max_size: Maximum allowed tile size (default: 256).
180
+
181
+ Raises:
182
+ ValidationError: If tile_size is invalid.
183
+ """
184
+ if not isinstance(tile_size, (int, float)):
185
+ raise ValidationError(
186
+ f"Tile size must be a number, got {type(tile_size).__name__}: {tile_size}"
187
+ )
188
+
189
+ tile_size = int(tile_size)
190
+
191
+ if tile_size < min_size:
192
+ raise ValidationError(
193
+ f"Tile size ({tile_size}px) must be at least {min_size}px. "
194
+ f"Smaller tiles would be too small to display properly."
195
+ )
196
+
197
+ if tile_size > max_size:
198
+ raise ValidationError(
199
+ f"Tile size ({tile_size}px) exceeds maximum of {max_size}px. "
200
+ f"Larger tiles may cause performance issues or memory errors."
201
+ )
202
+
203
+ if tile_size <= 0:
204
+ raise ValidationError(
205
+ f"Tile size must be positive, got: {tile_size}px"
206
+ )
207
+
208
+
209
+ def validate_image_size(img: Image.Image, min_dimension: int = 32, max_dimension: int = 4096) -> Tuple[int, int]:
210
+ """
211
+ Validate image dimensions.
212
+
213
+ Args:
214
+ img: PIL Image to validate.
215
+ min_dimension: Minimum dimension (width or height) in pixels (default: 32).
216
+ max_dimension: Maximum dimension (width or height) in pixels (default: 4096).
217
+
218
+ Returns:
219
+ Tuple[int, int]: Image width and height.
220
+
221
+ Raises:
222
+ ValidationError: If image dimensions are invalid.
223
+ """
224
+ if img is None:
225
+ raise ValidationError("Image cannot be None. Please provide a valid image.")
226
+
227
+ if not isinstance(img, Image.Image):
228
+ raise ValidationError(
229
+ f"Expected PIL Image, got {type(img).__name__}"
230
+ )
231
+
232
+ width, height = img.size
233
+
234
+ if width <= 0 or height <= 0:
235
+ raise ValidationError(
236
+ f"Image dimensions must be positive, got: {width}x{height}px"
237
+ )
238
+
239
+ min_side = min(width, height)
240
+ max_side = max(width, height)
241
+
242
+ if min_side < min_dimension:
243
+ raise ValidationError(
244
+ f"Image is too small: {width}x{height}px. "
245
+ f"Minimum dimension must be at least {min_dimension}px. "
246
+ f"Current smallest dimension: {min_side}px."
247
+ )
248
+
249
+ if max_side > max_dimension:
250
+ raise ValidationError(
251
+ f"Image is too large: {width}x{height}px. "
252
+ f"Maximum dimension must be at most {max_dimension}px. "
253
+ f"Current largest dimension: {max_side}px. "
254
+ f"Consider resizing the image before processing."
255
+ )
256
+
257
+ return width, height
258
+
259
+
260
+ def validate_image_grid_compatibility(img: Image.Image, grid_cells: int) -> None:
261
+ """
262
+ Validate that image dimensions are compatible with grid size.
263
+
264
+ Checks that image can be divided into grid cells without excessive cropping.
265
+
266
+ Args:
267
+ img: PIL Image to validate.
268
+ grid_cells: Number of grid cells per side.
269
+
270
+ Raises:
271
+ ValidationError: If image and grid are incompatible.
272
+ """
273
+ width, height = img.size
274
+
275
+ # After preprocessing, image will be cropped to multiples of grid_cells
276
+ # Check if the resulting size would be too small
277
+ w_crop = (width // grid_cells) * grid_cells
278
+ h_crop = (height // grid_cells) * grid_cells
279
+
280
+ if w_crop < grid_cells or h_crop < grid_cells:
281
+ raise ValidationError(
282
+ f"Image dimensions ({width}x{height}px) are too small for grid size {grid_cells}x{grid_cells}. "
283
+ f"After cropping to grid multiples, the image would be {w_crop}x{h_crop}px, "
284
+ f"which is insufficient. Please use a larger image or smaller grid size."
285
+ )
286
+
287
+ # Warn if too much of the image would be cropped
288
+ crop_ratio_w = w_crop / width if width > 0 else 0
289
+ crop_ratio_h = h_crop / height if height > 0 else 0
290
+
291
+ if crop_ratio_w < 0.5 or crop_ratio_h < 0.5:
292
+ raise ValidationError(
293
+ f"Image dimensions ({width}x{height}px) are poorly aligned with grid size {grid_cells}x{grid_cells}. "
294
+ f"More than 50% of the image would be cropped. "
295
+ f"Consider adjusting the grid size or using a differently sized image."
296
+ )
297
+
298
+
299
+ def validate_tile_paths(image_paths: List[str]) -> Tuple[List[str], List[str]]:
300
+ """
301
+ Validate that tile image paths exist and return valid/missing paths.
302
+
303
+ Args:
304
+ image_paths: List of paths to tile images.
305
+
306
+ Returns:
307
+ Tuple[List[str], List[str]]: Tuple of (valid_paths, missing_paths).
308
+
309
+ Raises:
310
+ ValidationError: If no valid tile paths are found.
311
+ """
312
+ if not image_paths:
313
+ raise ValidationError(
314
+ "No tile image paths provided. Cannot build mosaic without tiles."
315
+ )
316
+
317
+ valid_paths = []
318
+ missing_paths = []
319
+
320
+ for path in image_paths:
321
+ if not isinstance(path, str):
322
+ raise ValidationError(
323
+ f"Tile path must be a string, got {type(path).__name__}: {path}"
324
+ )
325
+
326
+ if os.path.exists(path):
327
+ # Verify it's actually an image file
328
+ try:
329
+ with Image.open(path) as test_img:
330
+ test_img.verify()
331
+ valid_paths.append(path)
332
+ except Exception as e:
333
+ raise ValidationError(
334
+ f"Tile path exists but is not a valid image file: {path}. "
335
+ f"Error: {str(e)}"
336
+ )
337
+ else:
338
+ missing_paths.append(path)
339
+
340
+ if not valid_paths:
341
+ raise ValidationError(
342
+ f"None of the provided tile paths exist or are valid images. "
343
+ f"Missing paths: {', '.join(missing_paths)}. "
344
+ f"Please ensure tile images are available at the specified paths."
345
+ )
346
+
347
+ if missing_paths:
348
+ # Log warning but don't fail - we can work with partial tile set
349
+ print(f"Warning: {len(missing_paths)} tile image(s) not found: {', '.join(missing_paths)}")
350
+ print(f"Using {len(valid_paths)} available tile image(s).")
351
+
352
+ return valid_paths, missing_paths
353
+
requirements.txt CHANGED
@@ -1,4 +1,6 @@
1
- gradio==4.44.1
2
- numpy==2.1.2
3
- pillow==10.4.0
4
- scikit-image==0.24.0
 
 
 
1
+ gradio==5.49.1
2
+ numpy==2.2.2
3
+ pillow==11.1.0
4
+ scikit-image==0.25.0
5
+ scipy>=1.11.0
6
+