Spaces:
Sleeping
Sleeping
Lohith Venkat Chamakura commited on
Commit ·
0c3c4c4
1
Parent(s): 12cda37
Update app
Browse files- README.md +356 -67
- app.py +190 -258
- mosaic_generator/__init__.py +61 -0
- mosaic_generator/__pycache__/__init__.cpython-313.pyc +0 -0
- mosaic_generator/__pycache__/config.cpython-313.pyc +0 -0
- mosaic_generator/__pycache__/image_processor.cpython-313.pyc +0 -0
- mosaic_generator/__pycache__/metrics.cpython-313.pyc +0 -0
- mosaic_generator/__pycache__/mosaic_builder.cpython-313.pyc +0 -0
- mosaic_generator/__pycache__/tile_manager.cpython-313.pyc +0 -0
- mosaic_generator/__pycache__/utils.cpython-313.pyc +0 -0
- mosaic_generator/config.py +35 -0
- mosaic_generator/image_processor.py +177 -0
- mosaic_generator/metrics.py +45 -0
- mosaic_generator/mosaic_builder.py +250 -0
- mosaic_generator/tile_manager.py +217 -0
- mosaic_generator/utils.py +353 -0
- requirements.txt +6 -4
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 |
-
|
| 13 |
|
| 14 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
| 22 |
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
| 44 |
|
| 45 |
-
#
|
| 46 |
-
python -m venv .venv && source .venv/bin/activate
|
| 47 |
|
| 48 |
-
|
| 49 |
-
pip install -r requirements.txt
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
```
|
| 54 |
|
| 55 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
-
|
| 58 |
-
.
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
```
|
| 64 |
|
| 65 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
|
| 68 |
-
- Load image, resize to max side, crop so dimensions are multiples of grid size.
|
| 69 |
-
- (Optional) apply median-cut color quantization.
|
| 70 |
|
| 71 |
-
|
| 72 |
-
- Crop skimage sample images + sprite into squares, resize to tile size.
|
| 73 |
-
- Convert each tile to **CIELAB** and store average color.
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
| 82 |
|
| 83 |
-
|
| 84 |
|
| 85 |
-
|
| 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 |
-
|
| 93 |
-
- **SSIM (Structural Similarity):** Captures perceptual similarity (structure, luminance, contrast). Higher = more similar.
|
| 94 |
|
| 95 |
-
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 18 |
|
| 19 |
import os
|
| 20 |
-
|
| 21 |
-
from typing import
|
| 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 |
-
|
| 29 |
-
ASSETS_DIR
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
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 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
with gr.Row():
|
| 240 |
with gr.Column():
|
| 241 |
inp = gr.Image(type="pil", label="Input image", height=320)
|
| 242 |
-
grid = gr.Slider(
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
gr.Examples(EXAMPLES, inputs=inp)
|
| 252 |
|
| 253 |
-
# Bind events
|
| 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 |
-
|
| 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==
|
| 2 |
-
numpy==2.
|
| 3 |
-
pillow==
|
| 4 |
-
scikit-image==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 |
+
|