Spaces:
Sleeping
Sleeping
code improvements
Browse files- README.md +51 -12
- app.py +64 -8
- improvements.tex +41 -0
- logic/imgGrid.py +19 -9
- logic/imgPreprocess.py +25 -11
- logic/perfMetric.py +116 -13
- logic/tileMapping.py +87 -25
- logic/validation.py +113 -0
- performance_benchmark.png +2 -2
README.md
CHANGED
|
@@ -1,12 +1,51 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Image Mosaic Generator
|
| 2 |
+
|
| 3 |
+
Turn any image into a photomosaic composed of color-matched tiles while collecting detailed performance metrics. This Gradio app ships with built-in benchmarking tools, profiling utilities, and reproducible analysis documented in `profiling_analysis.ipynb`.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
- **Interactive mosaic builder** with selectable grid size, color palette, and reference image size.
|
| 7 |
+
- **Performance benchmark tab** that sweeps multiple configurations, plots the results, and logs cProfile + line_profiler stats.
|
| 8 |
+
- **Automatic tile caching & vectorized mapping** for sub-second generation on 1024×1024 inputs.
|
| 9 |
+
- **Validation & error messaging** to catch invalid grid/image combinations before processing.
|
| 10 |
+
|
| 11 |
+
## Installation
|
| 12 |
+
```bash
|
| 13 |
+
# clone the repository
|
| 14 |
+
git clone https://huggingface.co/spaces/meryadri/ImageMosaicGenerator
|
| 15 |
+
cd ImageMosaicGenerator
|
| 16 |
+
|
| 17 |
+
# create and activate a virtual environment (optional but recommended)
|
| 18 |
+
python3 -m venv venv
|
| 19 |
+
source venv/bin/activate # Windows: venv\Scripts\activate
|
| 20 |
+
|
| 21 |
+
# install dependencies
|
| 22 |
+
pip install -r requirements.txt
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## Usage
|
| 26 |
+
### Local Gradio app
|
| 27 |
+
```bash
|
| 28 |
+
python app.py
|
| 29 |
+
```
|
| 30 |
+
Visit `http://127.0.0.1:7860` and either upload an image or choose one of the curated examples. Ensure the selected grid size divides the target image size (e.g., 512px image with 32×32 grid) for best results.
|
| 31 |
+
|
| 32 |
+
### Command-line benchmarking
|
| 33 |
+
The easiest way to reproduce the profiling numbers is to run the Performance Benchmark tab once. The first pass warms the tile cache; subsequent runs deliver the <10ms timings shown in the notebook.
|
| 34 |
+
|
| 35 |
+
## Deployed Demo
|
| 36 |
+
The project is available as a Hugging Face Space: [ImageMosaicGenerator](https://huggingface.co/spaces/meryadri/ImageMosaicGenerator). The space automatically stays in sync with this repository.
|
| 37 |
+
|
| 38 |
+
## Profiling Report
|
| 39 |
+
See [`profiling_analysis.ipynb`](profiling_analysis.ipynb) for:
|
| 40 |
+
- benchmark data (before/after speedups)
|
| 41 |
+
- cProfile summaries & line-profiler excerpts
|
| 42 |
+
- discussion of optimized bottlenecks (tile caching, vectorized grid processing, preprocessing reuse)
|
| 43 |
+
|
| 44 |
+
## Contributing
|
| 45 |
+
1. Fork the repository and open a feature branch.
|
| 46 |
+
2. Run `python app.py` and ensure both Gradio tabs still function.
|
| 47 |
+
3. Update the profiling notebook or README if your change affects performance.
|
| 48 |
+
4. Submit a pull request with a brief summary of the improvement.
|
| 49 |
+
|
| 50 |
+
## License
|
| 51 |
+
This project inherits the usage terms of the Hugging Face Space it powers. Refer to the Space card for additional information.
|
app.py
CHANGED
|
@@ -8,19 +8,53 @@ from logic.imgPreprocess import resize_img, color_quantize
|
|
| 8 |
from logic.imgGrid import segment_image_grid
|
| 9 |
from logic.tileMapping import load_tile_images, map_tiles_to_grid
|
| 10 |
from logic.perfMetric import mse, ssim_metric, timed, create_performance_report, run_performance_benchmark
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
DEFAULT_IMAGE_SIZE = 512
|
| 13 |
BENCHMARK_IMAGE_PATH = "data/test_images/marten.jpg"
|
| 14 |
BENCHMARK_IMAGE_SIZES = [256, 512, 1024]
|
| 15 |
BENCHMARK_GRID_SIZES = [16, 32, 64]
|
| 16 |
|
| 17 |
-
def preprocess_and_mosaic(
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
start_total = time.time()
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
# Step 1: Image preprocessing
|
| 22 |
start_preprocess = time.time()
|
| 23 |
-
resized_img = resize_img(
|
| 24 |
quantized_img, color_centers = color_quantize(resized_img, n_colors)
|
| 25 |
preprocess_time = time.time() - start_preprocess
|
| 26 |
|
|
@@ -42,7 +76,10 @@ def preprocess_and_mosaic(image: Image.Image, grid_size: int, n_colors: int, ima
|
|
| 42 |
# Step 3: Tile mapping
|
| 43 |
# Step 3a: Load tiles (timed)
|
| 44 |
start_tile_load = time.time()
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
| 46 |
tile_load_time = time.time() - start_tile_load
|
| 47 |
|
| 48 |
# Step 3b: Map tiles to grid (timed)
|
|
@@ -77,7 +114,15 @@ def preprocess_and_mosaic(image: Image.Image, grid_size: int, n_colors: int, ima
|
|
| 77 |
return orig_disp, seg_disp, mosaic_disp, performance_info
|
| 78 |
|
| 79 |
def performance_benchmark(image: Image.Image, n_colors: int = 16):
|
| 80 |
-
"""Run
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
preprocess_funcs = {
|
| 82 |
'resize': resize_img,
|
| 83 |
'quantize': color_quantize
|
|
@@ -89,8 +134,13 @@ def performance_benchmark(image: Image.Image, n_colors: int = 16):
|
|
| 89 |
except FileNotFoundError:
|
| 90 |
return None, f"Benchmark image not found at {BENCHMARK_IMAGE_PATH}"
|
| 91 |
else:
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
return run_performance_benchmark(
|
| 95 |
preprocess_funcs,
|
| 96 |
segment_image_grid,
|
|
@@ -119,6 +169,12 @@ benchmark_examples = [[BENCHMARK_IMAGE_PATH, 16]]
|
|
| 119 |
with gr.Blocks(title="Mosaic Generator - Performance Analysis") as demo:
|
| 120 |
gr.Markdown("# 🌸 Mosaic Generator by Adrien Mery")
|
| 121 |
gr.Markdown("Transform your images into beautiful mosaics using colorful photo tiles. Includes comprehensive performance analysis!")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
with gr.Tabs():
|
| 124 |
with gr.Tab("🎨 Mosaic Generator"):
|
|
|
|
| 8 |
from logic.imgGrid import segment_image_grid
|
| 9 |
from logic.tileMapping import load_tile_images, map_tiles_to_grid
|
| 10 |
from logic.perfMetric import mse, ssim_metric, timed, create_performance_report, run_performance_benchmark
|
| 11 |
+
from logic.validation import (
|
| 12 |
+
ValidationError,
|
| 13 |
+
ensure_grid_divisibility,
|
| 14 |
+
ensure_image,
|
| 15 |
+
ensure_positive_int,
|
| 16 |
+
)
|
| 17 |
|
| 18 |
DEFAULT_IMAGE_SIZE = 512
|
| 19 |
BENCHMARK_IMAGE_PATH = "data/test_images/marten.jpg"
|
| 20 |
BENCHMARK_IMAGE_SIZES = [256, 512, 1024]
|
| 21 |
BENCHMARK_GRID_SIZES = [16, 32, 64]
|
| 22 |
|
| 23 |
+
def preprocess_and_mosaic(
|
| 24 |
+
image: Image.Image,
|
| 25 |
+
grid_size: int,
|
| 26 |
+
n_colors: int,
|
| 27 |
+
image_size: int = DEFAULT_IMAGE_SIZE,
|
| 28 |
+
):
|
| 29 |
+
"""Generate a mosaic image and accompanying diagnostic data.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
image (PIL.Image.Image): User supplied image.
|
| 33 |
+
grid_size (int): Requested grid dimension (NxN).
|
| 34 |
+
n_colors (int): Number of quantized colors.
|
| 35 |
+
image_size (int, optional): Target square size. Defaults to ``DEFAULT_IMAGE_SIZE``.
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
tuple[PIL.Image.Image, PIL.Image.Image, PIL.Image.Image, str]:
|
| 39 |
+
Original display, segmentation preview, mosaic result, and textual metrics.
|
| 40 |
+
"""
|
| 41 |
start_total = time.time()
|
| 42 |
+
try:
|
| 43 |
+
validated_image = ensure_image(image)
|
| 44 |
+
ensure_positive_int(n_colors, "Color palette size", minimum=2, maximum=64)
|
| 45 |
+
ensure_positive_int(grid_size, "Grid size", minimum=2, maximum=512)
|
| 46 |
+
ensure_positive_int(image_size, "Image size", minimum=64, maximum=2048)
|
| 47 |
+
divisible = ensure_grid_divisibility(image_size, grid_size)
|
| 48 |
+
except (ValidationError, ValueError) as exc:
|
| 49 |
+
raise gr.Error(str(exc)) from exc
|
| 50 |
+
if not divisible:
|
| 51 |
+
gr.Warning(
|
| 52 |
+
"Image size does not divide evenly by the grid. The app will crop to the nearest valid region."
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
# Step 1: Image preprocessing
|
| 56 |
start_preprocess = time.time()
|
| 57 |
+
resized_img = resize_img(validated_image, image_size)
|
| 58 |
quantized_img, color_centers = color_quantize(resized_img, n_colors)
|
| 59 |
preprocess_time = time.time() - start_preprocess
|
| 60 |
|
|
|
|
| 76 |
# Step 3: Tile mapping
|
| 77 |
# Step 3a: Load tiles (timed)
|
| 78 |
start_tile_load = time.time()
|
| 79 |
+
try:
|
| 80 |
+
tiles, tile_colors = load_tile_images('data/tiles', n_colors, cell_size)
|
| 81 |
+
except (FileNotFoundError, ValidationError) as exc:
|
| 82 |
+
raise gr.Error(str(exc)) from exc
|
| 83 |
tile_load_time = time.time() - start_tile_load
|
| 84 |
|
| 85 |
# Step 3b: Map tiles to grid (timed)
|
|
|
|
| 114 |
return orig_disp, seg_disp, mosaic_disp, performance_info
|
| 115 |
|
| 116 |
def performance_benchmark(image: Image.Image, n_colors: int = 16):
|
| 117 |
+
"""Run the automated benchmark on either the uploaded or default image.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
image (PIL.Image.Image): Optional user image.
|
| 121 |
+
n_colors (int, optional): Color palette size. Defaults to 16.
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
tuple[str, str] | tuple[None, str]: Benchmark plots and textual summary.
|
| 125 |
+
"""
|
| 126 |
preprocess_funcs = {
|
| 127 |
'resize': resize_img,
|
| 128 |
'quantize': color_quantize
|
|
|
|
| 134 |
except FileNotFoundError:
|
| 135 |
return None, f"Benchmark image not found at {BENCHMARK_IMAGE_PATH}"
|
| 136 |
else:
|
| 137 |
+
try:
|
| 138 |
+
benchmark_image = ensure_image(image)
|
| 139 |
+
except ValidationError as exc:
|
| 140 |
+
raise gr.Error(str(exc)) from exc
|
| 141 |
+
|
| 142 |
+
ensure_positive_int(n_colors, "Color palette size", minimum=2, maximum=64)
|
| 143 |
+
|
| 144 |
return run_performance_benchmark(
|
| 145 |
preprocess_funcs,
|
| 146 |
segment_image_grid,
|
|
|
|
| 169 |
with gr.Blocks(title="Mosaic Generator - Performance Analysis") as demo:
|
| 170 |
gr.Markdown("# 🌸 Mosaic Generator by Adrien Mery")
|
| 171 |
gr.Markdown("Transform your images into beautiful mosaics using colorful photo tiles. Includes comprehensive performance analysis!")
|
| 172 |
+
with gr.Accordion("Usage Tips", open=False):
|
| 173 |
+
gr.Markdown(
|
| 174 |
+
"- Choose matching image/grid sizes so cells remain square.\n"
|
| 175 |
+
"- Upload high-resolution images for better mosaics.\n"
|
| 176 |
+
"- Benchmark tab caches tiles automatically; rerun after the first pass for fastest results."
|
| 177 |
+
)
|
| 178 |
|
| 179 |
with gr.Tabs():
|
| 180 |
with gr.Tab("🎨 Mosaic Generator"):
|
improvements.tex
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
\documentclass{article}
|
| 2 |
+
\usepackage[margin=1in]{geometry}
|
| 3 |
+
\usepackage{enumitem}
|
| 4 |
+
\title{Code Quality Enhancements for Image Mosaic Generator}
|
| 5 |
+
\author{Adrien Mery}
|
| 6 |
+
\date{\today}
|
| 7 |
+
\begin{document}
|
| 8 |
+
\maketitle
|
| 9 |
+
|
| 10 |
+
\section{Overview}
|
| 11 |
+
This document summarizes the structural and quality improvements recently applied to the Image Mosaic Generator project. The focus was on improving reliability, user feedback, and maintainability while keeping the performance benchmarking goals intact.
|
| 12 |
+
|
| 13 |
+
\section{Error Handling and Validation}
|
| 14 |
+
\begin{itemize}[leftmargin=*]
|
| 15 |
+
\item Introduced \texttt{logic/validation.py} to centralize checks such as ensuring uploaded images exist, verifying integer parameters, and gracefully handling missing resources. All Gradio callbacks now catch \texttt{ValidationError} and surface clear UI messages.
|
| 16 |
+
\item Grid divisibility enforcement now returns a boolean rather than raising, allowing non-square combinations while still warning users and cropping appropriately.
|
| 17 |
+
\item Tile loading raises informative messages when directories are missing or contain no valid assets, preventing silent failures.
|
| 18 |
+
\end{itemize}
|
| 19 |
+
|
| 20 |
+
\section{Docstrings and Module Organization}
|
| 21 |
+
\begin{itemize}[leftmargin=*]
|
| 22 |
+
\item Every public function across \texttt{app.py}, \texttt{logic/imgPreprocess.py}, \texttt{logic/imgGrid.py}, \texttt{logic/tileMapping.py}, and \texttt{logic/perfMetric.py} now includes a complete docstring describing purpose, parameters, return values, and usage notes.
|
| 23 |
+
\item Profiling utilities were clarified with structured argument descriptions so future contributors can extend the benchmark pipeline confidently.
|
| 24 |
+
\end{itemize}
|
| 25 |
+
|
| 26 |
+
\section{Gradio Interface Improvements}
|
| 27 |
+
\begin{itemize}[leftmargin=*]
|
| 28 |
+
\item Added an accordion with usage tips that explains grid/image best practices and warns that caching improves on second runs.
|
| 29 |
+
\item Validation feedback now appears via \texttt{gr.Error} and \texttt{gr.Warning} so users immediately understand why a configuration is invalid.
|
| 30 |
+
\end{itemize}
|
| 31 |
+
|
| 32 |
+
\section{Documentation}
|
| 33 |
+
\begin{itemize}[leftmargin=*]
|
| 34 |
+
\item Replaced the placeholder README with a comprehensive guide covering installation, virtual environment setup, Gradio usage, and a link to the deployed Hugging Face Space.
|
| 35 |
+
\item The README references \texttt{profiling\_analysis.ipynb}, which documents before/after benchmarks, profiling data, and optimization rationale.
|
| 36 |
+
\end{itemize}
|
| 37 |
+
|
| 38 |
+
\section{Conclusion}
|
| 39 |
+
These updates modernize the project by separating concerns, improving resilience, and documenting behavior. The modular validation layer, richer docstrings, and detailed README collectively reduce onboarding friction and make future optimization work easier to reason about.
|
| 40 |
+
|
| 41 |
+
\end{document}
|
logic/imgGrid.py
CHANGED
|
@@ -1,18 +1,28 @@
|
|
| 1 |
import numpy as np
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
Args:
|
| 8 |
-
image (np.ndarray):
|
| 9 |
-
grid_size (int): Number of
|
| 10 |
-
color_centers (np.ndarray):
|
| 11 |
-
|
| 12 |
Returns:
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
|
|
|
| 15 |
h, w = image.shape[:2]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
cell_size = h // grid_size
|
| 17 |
|
| 18 |
cells = image[:grid_size*cell_size, :grid_size*cell_size].reshape(
|
|
|
|
| 1 |
import numpy as np
|
| 2 |
|
| 3 |
+
from logic.validation import ValidationError, ensure_positive_int
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def segment_image_grid(image: np.ndarray, grid_size: int, color_centers: np.ndarray) -> np.ndarray:
|
| 7 |
+
"""Assign each grid cell to the closest quantized color.
|
| 8 |
+
|
| 9 |
Args:
|
| 10 |
+
image (np.ndarray): Square BGR image.
|
| 11 |
+
grid_size (int): Number of cells per dimension.
|
| 12 |
+
color_centers (np.ndarray): KMeans color centers (``n_colors x 3``).
|
| 13 |
+
|
| 14 |
Returns:
|
| 15 |
+
np.ndarray: ``grid_size x grid_size`` labels.
|
| 16 |
+
|
| 17 |
+
Raises:
|
| 18 |
+
ValidationError: If the grid size is invalid.
|
| 19 |
"""
|
| 20 |
+
ensure_positive_int(grid_size, "Grid size", minimum=2)
|
| 21 |
h, w = image.shape[:2]
|
| 22 |
+
if grid_size > h:
|
| 23 |
+
raise ValidationError("Grid size cannot exceed the number of pixels in the resized image.")
|
| 24 |
+
if h % grid_size != 0:
|
| 25 |
+
raise ValidationError("Grid size must evenly divide the resized image dimensions.")
|
| 26 |
cell_size = h // grid_size
|
| 27 |
|
| 28 |
cells = image[:grid_size*cell_size, :grid_size*cell_size].reshape(
|
logic/imgPreprocess.py
CHANGED
|
@@ -3,19 +3,29 @@ import cv2
|
|
| 3 |
from sklearn.cluster import KMeans
|
| 4 |
from PIL import Image
|
| 5 |
|
|
|
|
|
|
|
| 6 |
IMAGE_SIZE = 400
|
| 7 |
SAMPLE_SIZE = 50000
|
| 8 |
|
| 9 |
|
| 10 |
def resize_img(image: Image.Image, size: int = IMAGE_SIZE) -> Image.Image:
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
Args:
|
| 14 |
-
image (PIL.Image.Image):
|
| 15 |
-
size (int): Target
|
|
|
|
| 16 |
Returns:
|
| 17 |
-
PIL.Image.Image:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"""
|
|
|
|
| 19 |
width, height = image.size
|
| 20 |
if width != height:
|
| 21 |
min_size = min(width, height)
|
|
@@ -27,15 +37,19 @@ def resize_img(image: Image.Image, size: int = IMAGE_SIZE) -> Image.Image:
|
|
| 27 |
|
| 28 |
|
| 29 |
def color_quantize(image: Image.Image, n_colors: int = 16):
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
Args:
|
| 33 |
-
image (PIL.Image.Image):
|
| 34 |
-
n_colors (int): Number of
|
|
|
|
| 35 |
Returns:
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
| 38 |
"""
|
|
|
|
| 39 |
img_np = np.array(image)
|
| 40 |
original_shape = img_np.shape
|
| 41 |
img_flat = img_np.reshape(-1, 3)
|
|
|
|
| 3 |
from sklearn.cluster import KMeans
|
| 4 |
from PIL import Image
|
| 5 |
|
| 6 |
+
from logic.validation import ValidationError, ensure_positive_int
|
| 7 |
+
|
| 8 |
IMAGE_SIZE = 400
|
| 9 |
SAMPLE_SIZE = 50000
|
| 10 |
|
| 11 |
|
| 12 |
def resize_img(image: Image.Image, size: int = IMAGE_SIZE) -> Image.Image:
|
| 13 |
+
"""Resize an image to a square of the requested size.
|
| 14 |
+
|
| 15 |
Args:
|
| 16 |
+
image (PIL.Image.Image): Image to resize.
|
| 17 |
+
size (int, optional): Target side length in pixels. Defaults to ``IMAGE_SIZE``.
|
| 18 |
+
|
| 19 |
Returns:
|
| 20 |
+
PIL.Image.Image: Cropped and resized copy in RGB.
|
| 21 |
+
|
| 22 |
+
Raises:
|
| 23 |
+
ValidationError: If ``size`` is not positive.
|
| 24 |
+
|
| 25 |
+
Example:
|
| 26 |
+
>>> resized = resize_img(Image.open('input.png'), 512)
|
| 27 |
"""
|
| 28 |
+
ensure_positive_int(size, "Image size", minimum=32)
|
| 29 |
width, height = image.size
|
| 30 |
if width != height:
|
| 31 |
min_size = min(width, height)
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
def color_quantize(image: Image.Image, n_colors: int = 16):
|
| 40 |
+
"""Reduce the number of colors with KMeans clustering.
|
| 41 |
+
|
| 42 |
Args:
|
| 43 |
+
image (PIL.Image.Image): Image to quantize.
|
| 44 |
+
n_colors (int, optional): Number of clusters. Defaults to 16.
|
| 45 |
+
|
| 46 |
Returns:
|
| 47 |
+
tuple[PIL.Image.Image, np.ndarray]: Quantized image and cluster centers in BGR order.
|
| 48 |
+
|
| 49 |
+
Raises:
|
| 50 |
+
ValidationError: If ``n_colors`` is outside ``[2, 64]``.
|
| 51 |
"""
|
| 52 |
+
ensure_positive_int(n_colors, "Color palette size", minimum=2, maximum=64)
|
| 53 |
img_np = np.array(image)
|
| 54 |
original_shape = img_np.shape
|
| 55 |
img_flat = img_np.reshape(-1, 3)
|
logic/perfMetric.py
CHANGED
|
@@ -20,11 +20,19 @@ except ImportError: # pragma: no cover - optional dependency
|
|
| 20 |
LineProfiler = None
|
| 21 |
|
| 22 |
def mse(img1: np.ndarray, img2: np.ndarray) -> float:
|
| 23 |
-
"""Calculate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
return float(np.mean((img1.astype(np.float32) - img2.astype(np.float32))**2))
|
| 25 |
|
| 26 |
def ssim_metric(img1: np.ndarray, img2: np.ndarray) -> float:
|
| 27 |
-
"""
|
| 28 |
img1_gray = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
|
| 29 |
img2_gray = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
|
| 30 |
ssim_value = ssim(img1_gray, img2_gray)
|
|
@@ -33,7 +41,14 @@ def ssim_metric(img1: np.ndarray, img2: np.ndarray) -> float:
|
|
| 33 |
return float(ssim_value)
|
| 34 |
|
| 35 |
def timed(func):
|
| 36 |
-
"""Decorator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def wrapper(*args, **kwargs):
|
| 38 |
start = time.time()
|
| 39 |
result = func(*args, **kwargs)
|
|
@@ -41,11 +56,21 @@ def timed(func):
|
|
| 41 |
return result, elapsed
|
| 42 |
return wrapper
|
| 43 |
|
| 44 |
-
def create_performance_report(
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
performance_info = f"""
|
| 50 |
📊 PERFORMANCE METRICS
|
| 51 |
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
@@ -89,7 +114,21 @@ def run_performance_benchmark(
|
|
| 89 |
image_sizes=None,
|
| 90 |
grid_sizes=None,
|
| 91 |
):
|
| 92 |
-
"""Run comprehensive performance benchmark across combinations of grid and image sizes.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
if image is None:
|
| 94 |
return None, "Please upload an image first!"
|
| 95 |
|
|
@@ -184,6 +223,15 @@ def run_performance_benchmark(
|
|
| 184 |
return create_benchmark_plots(results, profile_report, line_profile_report, line_profile_config)
|
| 185 |
|
| 186 |
def summarize_top_functions(stats_obj, limit=5):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
if stats_obj is None:
|
| 188 |
return "No profiling data"
|
| 189 |
stats_items = stats_obj.stats.items()
|
|
@@ -196,6 +244,15 @@ def summarize_top_functions(stats_obj, limit=5):
|
|
| 196 |
|
| 197 |
|
| 198 |
def format_overall_profile_report(stats_obj, limit=10):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
if stats_obj is None:
|
| 200 |
return ""
|
| 201 |
stream = io.StringIO()
|
|
@@ -205,8 +262,31 @@ def format_overall_profile_report(stats_obj, limit=10):
|
|
| 205 |
return stream.getvalue()
|
| 206 |
|
| 207 |
|
| 208 |
-
def run_line_profile_analysis(
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
if LineProfiler is None:
|
| 211 |
return "line_profiler not installed. Run `pip install line_profiler` to enable."
|
| 212 |
|
|
@@ -249,7 +329,17 @@ def run_line_profile_analysis(preprocess_func, segment_func, load_tiles_func, ma
|
|
| 249 |
|
| 250 |
|
| 251 |
def create_benchmark_plots(results, profile_report=None, line_profile_report=None, line_profile_config=None):
|
| 252 |
-
"""Create performance benchmark visualization plots.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 10))
|
| 254 |
|
| 255 |
cells = [r['Grid Cells'] for r in results]
|
|
@@ -362,7 +452,20 @@ def create_benchmark_summary(
|
|
| 362 |
line_profile_report=None,
|
| 363 |
line_profile_config=None
|
| 364 |
):
|
| 365 |
-
"""Create a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
fastest = min(results, key=lambda x: x['Total Time (s)'])
|
| 367 |
best_quality = max(results, key=lambda x: x['SSIM'])
|
| 368 |
best_balance = min(results, key=lambda x: x['Total Time (s)'] * (1 - x['SSIM']))
|
|
|
|
| 20 |
LineProfiler = None
|
| 21 |
|
| 22 |
def mse(img1: np.ndarray, img2: np.ndarray) -> float:
|
| 23 |
+
"""Calculate mean squared error between two images.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
img1 (np.ndarray): Reference image.
|
| 27 |
+
img2 (np.ndarray): Comparison image.
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
float: Mean squared error value.
|
| 31 |
+
"""
|
| 32 |
return float(np.mean((img1.astype(np.float32) - img2.astype(np.float32))**2))
|
| 33 |
|
| 34 |
def ssim_metric(img1: np.ndarray, img2: np.ndarray) -> float:
|
| 35 |
+
"""Return the structural similarity index (SSIM) for two images."""
|
| 36 |
img1_gray = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
|
| 37 |
img2_gray = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
|
| 38 |
ssim_value = ssim(img1_gray, img2_gray)
|
|
|
|
| 41 |
return float(ssim_value)
|
| 42 |
|
| 43 |
def timed(func):
|
| 44 |
+
"""Decorator that returns both function output and elapsed time.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
func (Callable): Function to wrap.
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
Callable: Wrapper returning ``(result, seconds)``.
|
| 51 |
+
"""
|
| 52 |
def wrapper(*args, **kwargs):
|
| 53 |
start = time.time()
|
| 54 |
result = func(*args, **kwargs)
|
|
|
|
| 56 |
return result, elapsed
|
| 57 |
return wrapper
|
| 58 |
|
| 59 |
+
def create_performance_report(
|
| 60 |
+
actual_grid_size: int,
|
| 61 |
+
cell_size: int,
|
| 62 |
+
n_colors: int,
|
| 63 |
+
actual_image_size: int,
|
| 64 |
+
preprocess_time: float,
|
| 65 |
+
seg_time: float,
|
| 66 |
+
tile_load_time: float,
|
| 67 |
+
mosaic_time: float,
|
| 68 |
+
metrics_time: float,
|
| 69 |
+
total_time: float,
|
| 70 |
+
mse_val: float,
|
| 71 |
+
ssim_val: float,
|
| 72 |
+
) -> str:
|
| 73 |
+
"""Generate a formatted performance report string for display."""
|
| 74 |
performance_info = f"""
|
| 75 |
📊 PERFORMANCE METRICS
|
| 76 |
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
| 114 |
image_sizes=None,
|
| 115 |
grid_sizes=None,
|
| 116 |
):
|
| 117 |
+
"""Run comprehensive performance benchmark across combinations of grid and image sizes.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
preprocess_func (dict): Dict containing ``resize`` and ``quantize`` callables.
|
| 121 |
+
segment_func (Callable): Grid segmentation function.
|
| 122 |
+
load_tiles_func (Callable): Tile loader.
|
| 123 |
+
map_tiles_func (Callable): Tile mapping function.
|
| 124 |
+
image (PIL.Image.Image): Source image.
|
| 125 |
+
n_colors (int, optional): Number of quantized colors. Defaults to 16.
|
| 126 |
+
image_sizes (list[int] | None): Sizes to test.
|
| 127 |
+
grid_sizes (list[int] | None): Grid sizes to test.
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
tuple[str, str]: Tuple containing chart path and textual report.
|
| 131 |
+
"""
|
| 132 |
if image is None:
|
| 133 |
return None, "Please upload an image first!"
|
| 134 |
|
|
|
|
| 223 |
return create_benchmark_plots(results, profile_report, line_profile_report, line_profile_config)
|
| 224 |
|
| 225 |
def summarize_top_functions(stats_obj, limit=5):
|
| 226 |
+
"""Summarize the most expensive functions from ``pstats`` output.
|
| 227 |
+
|
| 228 |
+
Args:
|
| 229 |
+
stats_obj (pstats.Stats | None): Profile statistics object.
|
| 230 |
+
limit (int, optional): Maximum rows to include. Defaults to 5.
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
str: Human readable summary.
|
| 234 |
+
"""
|
| 235 |
if stats_obj is None:
|
| 236 |
return "No profiling data"
|
| 237 |
stats_items = stats_obj.stats.items()
|
|
|
|
| 244 |
|
| 245 |
|
| 246 |
def format_overall_profile_report(stats_obj, limit=10):
|
| 247 |
+
"""Return formatted ``pstats`` output for the aggregate benchmark run.
|
| 248 |
+
|
| 249 |
+
Args:
|
| 250 |
+
stats_obj (pstats.Stats | None): Combined stats.
|
| 251 |
+
limit (int, optional): Maximum number of rows. Defaults to 10.
|
| 252 |
+
|
| 253 |
+
Returns:
|
| 254 |
+
str: Printable statistics table.
|
| 255 |
+
"""
|
| 256 |
if stats_obj is None:
|
| 257 |
return ""
|
| 258 |
stream = io.StringIO()
|
|
|
|
| 262 |
return stream.getvalue()
|
| 263 |
|
| 264 |
|
| 265 |
+
def run_line_profile_analysis(
|
| 266 |
+
preprocess_func,
|
| 267 |
+
segment_func,
|
| 268 |
+
load_tiles_func,
|
| 269 |
+
map_tiles_func,
|
| 270 |
+
image: Image.Image,
|
| 271 |
+
n_colors: int,
|
| 272 |
+
img_size: int,
|
| 273 |
+
grid_size: int,
|
| 274 |
+
):
|
| 275 |
+
"""Execute ``line_profiler`` for a sample configuration.
|
| 276 |
+
|
| 277 |
+
Args:
|
| 278 |
+
preprocess_func (dict): Dict containing preprocess callables.
|
| 279 |
+
segment_func (Callable): Grid segmentation function.
|
| 280 |
+
load_tiles_func (Callable): Tile loader.
|
| 281 |
+
map_tiles_func (Callable): Tile mapper.
|
| 282 |
+
image (PIL.Image.Image): Base image.
|
| 283 |
+
n_colors (int): Number of quantized colors.
|
| 284 |
+
img_size (int): Image size for profiling scenario.
|
| 285 |
+
grid_size (int): Grid size for profiling scenario.
|
| 286 |
+
|
| 287 |
+
Returns:
|
| 288 |
+
str: Line-profiler text output.
|
| 289 |
+
"""
|
| 290 |
if LineProfiler is None:
|
| 291 |
return "line_profiler not installed. Run `pip install line_profiler` to enable."
|
| 292 |
|
|
|
|
| 329 |
|
| 330 |
|
| 331 |
def create_benchmark_plots(results, profile_report=None, line_profile_report=None, line_profile_config=None):
|
| 332 |
+
"""Create performance benchmark visualization plots.
|
| 333 |
+
|
| 334 |
+
Args:
|
| 335 |
+
results (list[dict]): Measurements from ``run_performance_benchmark``.
|
| 336 |
+
profile_report (str | None): Aggregate cProfile table.
|
| 337 |
+
line_profile_report (str | None): Line-profiler snippet.
|
| 338 |
+
line_profile_config (tuple[int, int] | None): Image/grid pair used for profiling.
|
| 339 |
+
|
| 340 |
+
Returns:
|
| 341 |
+
tuple[str, str]: Path to the saved plot image and textual summary.
|
| 342 |
+
"""
|
| 343 |
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 10))
|
| 344 |
|
| 345 |
cells = [r['Grid Cells'] for r in results]
|
|
|
|
| 452 |
line_profile_report=None,
|
| 453 |
line_profile_config=None
|
| 454 |
):
|
| 455 |
+
"""Create a textual benchmark summary with profiling data.
|
| 456 |
+
|
| 457 |
+
Args:
|
| 458 |
+
results (list[dict]): Raw measurement entries.
|
| 459 |
+
mse_vals (list[float]): MSE metrics per configuration.
|
| 460 |
+
ssim_vals (list[float]): SSIM metrics per configuration.
|
| 461 |
+
df (pandas.DataFrame): Tabular version of ``results``.
|
| 462 |
+
profile_report (str | None): Aggregate cProfile output.
|
| 463 |
+
line_profile_report (str | None): Line-profiler output.
|
| 464 |
+
line_profile_config (tuple[int, int] | None): Profiling configuration detail.
|
| 465 |
+
|
| 466 |
+
Returns:
|
| 467 |
+
str: Markdown-formatted summary text.
|
| 468 |
+
"""
|
| 469 |
fastest = min(results, key=lambda x: x['Total Time (s)'])
|
| 470 |
best_quality = max(results, key=lambda x: x['SSIM'])
|
| 471 |
best_balance = min(results, key=lambda x: x['Total Time (s)'] * (1 - x['SSIM']))
|
logic/tileMapping.py
CHANGED
|
@@ -4,40 +4,73 @@ from PIL import Image
|
|
| 4 |
import cv2
|
| 5 |
from typing import Dict, Tuple
|
| 6 |
|
|
|
|
|
|
|
| 7 |
_TILE_SOURCE_CACHE: Dict[str, list] = {}
|
| 8 |
_TILE_RESIZE_CACHE: Dict[Tuple[str, int], Tuple[np.ndarray, np.ndarray]] = {}
|
| 9 |
|
| 10 |
-
def calculate_average_color(image):
|
| 11 |
-
"""Calculate the average BGR color of an image
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
return np.mean(image.reshape(-1, 3), axis=0)
|
| 13 |
|
| 14 |
-
def color_distance(color1, color2):
|
| 15 |
-
"""
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
| 20 |
if tile_dir in _TILE_SOURCE_CACHE:
|
| 21 |
return _TILE_SOURCE_CACHE[tile_dir]
|
| 22 |
base_tiles = []
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
_TILE_SOURCE_CACHE[tile_dir] = base_tiles
|
| 37 |
return base_tiles
|
| 38 |
|
| 39 |
|
| 40 |
-
def _ensure_resized_tiles(tile_dir, cell_size):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
key = (tile_dir, cell_size)
|
| 42 |
if key in _TILE_RESIZE_CACHE:
|
| 43 |
return _TILE_RESIZE_CACHE[key]
|
|
@@ -60,8 +93,20 @@ def _ensure_resized_tiles(tile_dir, cell_size):
|
|
| 60 |
return cache_value
|
| 61 |
|
| 62 |
|
| 63 |
-
def load_tile_images(tile_dir, n_colors, cell_size):
|
| 64 |
-
"""Load
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
tiles_arr, tile_colors = _ensure_resized_tiles(tile_dir, cell_size)
|
| 66 |
if len(tiles_arr) < n_colors:
|
| 67 |
deficit = n_colors - len(tiles_arr)
|
|
@@ -77,8 +122,25 @@ def load_tile_images(tile_dir, n_colors, cell_size):
|
|
| 77 |
tile_colors = np.concatenate([tile_colors, np.vstack(random_colors)], axis=0)
|
| 78 |
return tiles_arr, tile_colors
|
| 79 |
|
| 80 |
-
def map_tiles_to_grid(
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
grid_size = grid_labels.shape[0]
|
| 83 |
tiles_arr = np.asarray(tiles)
|
| 84 |
|
|
|
|
| 4 |
import cv2
|
| 5 |
from typing import Dict, Tuple
|
| 6 |
|
| 7 |
+
from logic.validation import ValidationError, ensure_file_exists
|
| 8 |
+
|
| 9 |
_TILE_SOURCE_CACHE: Dict[str, list] = {}
|
| 10 |
_TILE_RESIZE_CACHE: Dict[Tuple[str, int], Tuple[np.ndarray, np.ndarray]] = {}
|
| 11 |
|
| 12 |
+
def calculate_average_color(image: np.ndarray) -> np.ndarray:
|
| 13 |
+
"""Calculate the average BGR color of an image.
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
image (np.ndarray): Tile image in BGR format.
|
| 17 |
+
|
| 18 |
+
Returns:
|
| 19 |
+
np.ndarray: Average color ``[b, g, r]``.
|
| 20 |
+
"""
|
| 21 |
return np.mean(image.reshape(-1, 3), axis=0)
|
| 22 |
|
| 23 |
+
def color_distance(color1: np.ndarray, color2: np.ndarray) -> float:
|
| 24 |
+
"""Return the Euclidean distance between two colors.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
color1 (np.ndarray): First color vector.
|
| 28 |
+
color2 (np.ndarray): Second color vector.
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
float: Euclidean distance.
|
| 32 |
+
"""
|
| 33 |
+
return float(np.linalg.norm(color1 - color2))
|
| 34 |
+
|
| 35 |
+
def _ensure_tile_sources(tile_dir: str):
|
| 36 |
+
"""Load base BGR tiles from disk once per directory.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
tile_dir (str): Directory containing tile imagery.
|
| 40 |
|
| 41 |
+
Returns:
|
| 42 |
+
list[np.ndarray]: List of base-resolution BGR tiles.
|
| 43 |
+
"""
|
| 44 |
if tile_dir in _TILE_SOURCE_CACHE:
|
| 45 |
return _TILE_SOURCE_CACHE[tile_dir]
|
| 46 |
base_tiles = []
|
| 47 |
+
ensure_file_exists(tile_dir, "Tile directory")
|
| 48 |
+
for f in sorted(os.listdir(tile_dir)):
|
| 49 |
+
if not f.lower().endswith(('.png', '.jpg', '.jpeg', '.png', '.webp', '.bmp')):
|
| 50 |
+
continue
|
| 51 |
+
path = os.path.join(tile_dir, f)
|
| 52 |
+
try:
|
| 53 |
+
img = Image.open(path).convert('RGB')
|
| 54 |
+
tile_array = np.array(img)
|
| 55 |
+
tile_bgr = cv2.cvtColor(tile_array, cv2.COLOR_RGB2BGR)
|
| 56 |
+
base_tiles.append(tile_bgr)
|
| 57 |
+
except Exception as exc:
|
| 58 |
+
print(f"Error loading {path}: {exc}")
|
| 59 |
+
continue
|
| 60 |
_TILE_SOURCE_CACHE[tile_dir] = base_tiles
|
| 61 |
return base_tiles
|
| 62 |
|
| 63 |
|
| 64 |
+
def _ensure_resized_tiles(tile_dir: str, cell_size: int):
|
| 65 |
+
"""Resize cached tiles to a particular cell size.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
tile_dir (str): Source directory.
|
| 69 |
+
cell_size (int): Desired tile size.
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
tuple[np.ndarray, np.ndarray]: Resized tiles and their colors.
|
| 73 |
+
"""
|
| 74 |
key = (tile_dir, cell_size)
|
| 75 |
if key in _TILE_RESIZE_CACHE:
|
| 76 |
return _TILE_RESIZE_CACHE[key]
|
|
|
|
| 93 |
return cache_value
|
| 94 |
|
| 95 |
|
| 96 |
+
def load_tile_images(tile_dir: str, n_colors: int, cell_size: int):
|
| 97 |
+
"""Load tiles, raising informative errors when assets are missing.
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
tile_dir (str): Directory containing tile images.
|
| 101 |
+
n_colors (int): Number of representative colors required.
|
| 102 |
+
cell_size (int): Target cell dimension in pixels.
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
tuple[np.ndarray, np.ndarray]: Arrays of tiles and their average colors.
|
| 106 |
+
|
| 107 |
+
Raises:
|
| 108 |
+
ValidationError: If no tiles could be produced.
|
| 109 |
+
"""
|
| 110 |
tiles_arr, tile_colors = _ensure_resized_tiles(tile_dir, cell_size)
|
| 111 |
if len(tiles_arr) < n_colors:
|
| 112 |
deficit = n_colors - len(tiles_arr)
|
|
|
|
| 122 |
tile_colors = np.concatenate([tile_colors, np.vstack(random_colors)], axis=0)
|
| 123 |
return tiles_arr, tile_colors
|
| 124 |
|
| 125 |
+
def map_tiles_to_grid(
|
| 126 |
+
grid_labels: np.ndarray,
|
| 127 |
+
tiles: np.ndarray,
|
| 128 |
+
cell_size: int,
|
| 129 |
+
color_centers: np.ndarray | None = None,
|
| 130 |
+
tile_colors: np.ndarray | None = None,
|
| 131 |
+
) -> np.ndarray:
|
| 132 |
+
"""Assemble the mosaic image by replacing each cell with a tile.
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
grid_labels (np.ndarray): ``grid_size x grid_size`` indices.
|
| 136 |
+
tiles (np.ndarray): Stack of tile images.
|
| 137 |
+
cell_size (int): Tile dimension in pixels.
|
| 138 |
+
color_centers (np.ndarray | None): Optional color palette for matching.
|
| 139 |
+
tile_colors (np.ndarray | None): Average colors per tile.
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
np.ndarray: Mosaic image in BGR format.
|
| 143 |
+
"""
|
| 144 |
grid_size = grid_labels.shape[0]
|
| 145 |
tiles_arr = np.asarray(tiles)
|
| 146 |
|
logic/validation.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utility validators for user-provided parameters and resources.
|
| 2 |
+
|
| 3 |
+
This module centralizes sanity checks for user supplied values so they can
|
| 4 |
+
be reused from both the Gradio callbacks and lower level logic modules.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Iterable
|
| 10 |
+
|
| 11 |
+
from PIL import Image
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ValidationError(ValueError):
|
| 15 |
+
"""Raised when user parameters cannot be processed safely."""
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def ensure_image(image: Image.Image | None) -> Image.Image:
|
| 19 |
+
"""Return a validated PIL image.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
image (PIL.Image.Image | None): User uploaded image.
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
PIL.Image.Image: The validated image converted to RGB mode.
|
| 26 |
+
|
| 27 |
+
Raises:
|
| 28 |
+
ValidationError: If the image is missing.
|
| 29 |
+
"""
|
| 30 |
+
if image is None:
|
| 31 |
+
raise ValidationError("Please upload an image or select an example before generating a mosaic.")
|
| 32 |
+
if image.mode != "RGB":
|
| 33 |
+
image = image.convert("RGB")
|
| 34 |
+
return image
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def ensure_positive_int(value: int, name: str, minimum: int = 1, maximum: int | None = None) -> int:
|
| 38 |
+
"""Validate that an integer parameter falls within an accepted range.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
value (int): Parameter to validate.
|
| 42 |
+
name (str): Human readable parameter name for error messages.
|
| 43 |
+
minimum (int, optional): Inclusive lower bound. Defaults to 1.
|
| 44 |
+
maximum (int | None, optional): Inclusive upper bound when provided.
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
int: The validated integer.
|
| 48 |
+
|
| 49 |
+
Raises:
|
| 50 |
+
ValidationError: If the value is outside of the allowed range.
|
| 51 |
+
"""
|
| 52 |
+
if not isinstance(value, int):
|
| 53 |
+
raise ValidationError(f"{name} must be an integer.")
|
| 54 |
+
if value < minimum:
|
| 55 |
+
raise ValidationError(f"{name} must be at least {minimum}.")
|
| 56 |
+
if maximum is not None and value > maximum:
|
| 57 |
+
raise ValidationError(f"{name} must be less than or equal to {maximum}.")
|
| 58 |
+
return value
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def ensure_grid_divisibility(image_size: int, grid_size: int) -> bool:
|
| 62 |
+
"""Return whether the grid divides the image size, without raising.
|
| 63 |
+
|
| 64 |
+
Mosaic generation crops the resized image to the nearest size that fits the
|
| 65 |
+
requested grid. Instead of blocking users when the division is imperfect,
|
| 66 |
+
this helper simply returns ``False`` so callers may display an informational
|
| 67 |
+
warning if desired while still proceeding with the closest valid size.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
image_size (int): Target square image size in pixels.
|
| 71 |
+
grid_size (int): Number of grid cells per axis.
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
bool: ``True`` if divisible, ``False`` otherwise.
|
| 75 |
+
"""
|
| 76 |
+
if grid_size <= 0 or image_size <= 0:
|
| 77 |
+
raise ValidationError("Image and grid sizes must be positive integers.")
|
| 78 |
+
if grid_size > image_size:
|
| 79 |
+
raise ValidationError("Grid size cannot exceed the resized image dimensions.")
|
| 80 |
+
return image_size % grid_size == 0
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def ensure_file_exists(path: str | Path, description: str) -> Path:
|
| 84 |
+
"""Validate that a file path exists.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
path (str | Path): Path to the required resource.
|
| 88 |
+
description (str): Friendly description for the user.
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
pathlib.Path: Normalized path.
|
| 92 |
+
|
| 93 |
+
Raises:
|
| 94 |
+
FileNotFoundError: If the path does not exist.
|
| 95 |
+
"""
|
| 96 |
+
normalized = Path(path)
|
| 97 |
+
if not normalized.exists():
|
| 98 |
+
raise FileNotFoundError(f"{description} not found at {normalized}.")
|
| 99 |
+
return normalized
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def ensure_non_empty(collection: Iterable, description: str) -> None:
|
| 103 |
+
"""Verify that an iterable contains elements.
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
collection (Iterable): Collection to check.
|
| 107 |
+
description (str): Description of the expected content.
|
| 108 |
+
|
| 109 |
+
Raises:
|
| 110 |
+
ValidationError: If the iterable is empty.
|
| 111 |
+
"""
|
| 112 |
+
if not any(True for _ in collection):
|
| 113 |
+
raise ValidationError(description)
|
performance_benchmark.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|