diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..022b6f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main, v2] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -e ".[dev]" + - name: Run tests + run: pytest tests/ -v + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: pip install ruff mypy numpy types-PyYAML + - name: Ruff check + run: ruff check v2/ + - name: Type check + run: mypy v2/ --ignore-missing-imports diff --git a/.gitignore b/.gitignore index bf3e0b6..d8c49c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,43 @@ -Hexify/Ilya/ +Hexify/Ilya/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Build artifacts +dist/ +build/ +*.egg-info/ + +# Type checking +.mypy_cache/ + +# Linting +.ruff_cache/ + +# pytest +.pytest_cache/ + +# Coverage +htmlcov/ +.coverage +.coverage.* +coverage.xml + +# Virtual environments +venv/ +.venv/ +ENV/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..320810a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.6 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.7.1 + hooks: + - id: mypy + additional_dependencies: [numpy, types-PyYAML] + args: [--ignore-missing-imports] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c063025 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: install test lint format clean + +install: + pip install -e ".[dev]" + +test: + pytest tests/ -v + +lint: + ruff check v2/ + mypy v2/ --ignore-missing-imports + +format: + ruff format v2/ + ruff check --fix v2/ + +clean: + rm -rf dist/ build/ *.egg-info .pytest_cache .mypy_cache .ruff_cache diff --git a/docs/ALGORITHM.md b/docs/ALGORITHM.md new file mode 100644 index 0000000..d3357e2 --- /dev/null +++ b/docs/ALGORITHM.md @@ -0,0 +1,520 @@ +# Hexify Image Processing Algorithm + +## Table of Contents +1. [Overview](#overview) +2. [Input/Output](#inputoutput) +3. [Hexagonal Grid System](#hexagonal-grid-system) +4. [7-Layer Concentric System](#7-layer-concentric-system) +5. [Color Selection](#color-selection) +6. [Zone Angle Calculation](#zone-angle-calculation) +7. [Mathematical Formulas](#mathematical-formulas) + +--- + +## Overview + +Hexify is an image processing algorithm that transforms input images into a stylized mosaic composed of hexagonal cells. Each hexagonal cell contains a sophisticated 7-layer concentric pattern that represents the average color of the corresponding region in the source image. + +The algorithm achieves a unique aesthetic by: +- Dividing the output into a tessellated hexagonal grid +- Creating 7 concentric hexagonal layers within each cell +- Using alternating black/white and colored layers +- Implementing a 12-zone pie pattern for colored layers +- Intelligently selecting colors from a generated palette + +``` +Input Image Output Image ++-------------+ +---------------------------+ +| | | /\ /\ /\ /\ | +| Original | =========> | / \ / \ / \ / \ | +| Photo | Hexify | /hex \/ hex\/ hex\/ hex\ | +| | | \ 7L /\ 7L /\ 7L /\ 7L / | ++-------------+ | \ / \ / \ / \ / | + +---------------------------+ +``` + +--- + +## Input/Output + +### Input +- **Image**: Any standard image (RGB format) +- **Palette Size**: Number of colors to extract (default: 16) +- **Chunk Size**: Processing batch size (default: 32) + +### Output +- **Scaled Image**: 16x the resolution of the input +- **Format**: RGB image with hexagonal cell patterns + +### Scaling Factor + +``` +Output Resolution = Input Resolution x 16 + +Example: + Input: 1920 x 1080 pixels + Output: 30720 x 17280 pixels +``` + +### Hexagon Dimensions + +| Property | Value | Formula | +|----------|-------|---------| +| Hex Width | 256 px | Fixed | +| Hex Height | 222 px | `256 * (sqrt(3)/2)` | +| Hex Radius | 128 px | `256 / 2` | +| Horizontal Spacing | 192 px | `256 * 0.75` | +| Vertical Spacing | 222 px | Same as height | + +--- + +## Hexagonal Grid System + +### Grid Layout + +The hexagonal grid uses an offset coordinate system where odd columns are shifted vertically by half the hex height: + +``` +Column: 0 1 2 3 4 + +-----+ +-----+ +-----+ +-----+ +-----+ +Row 0 | 0,0 | | | | 0,2 | | | | 0,4 | + +-----+ | | +-----+ | | +-----+ + +-----+ +-----+ +Row 1 | 1,1 | | 1,3 | + +-----+ +-----+ + +-----+ +-----+ +-----+ +Row 1 | 1,0 | | 1,2 | | 1,4 | + +-----+ +-----+ +-----+ +``` + +### Center Calculation + +For each hexagon at grid position (row, col): + +```python +center_x = hex_horizontal_spacing * col +center_y = hex_vertical_spacing * row + (0.5 * hex_vertical_spacing if col % 2 else 0) +``` + +### Hexagon Mask Creation + +Hexagons are oriented with a flat top (orientation = pi/2): + +``` + ____ + / \ + / \ + \ / + \____/ +``` + +The mask is created using `RegularPolygon` with 6 vertices and the specified radius. + +--- + +## 7-Layer Concentric System + +Each hexagonal cell contains 7 concentric hexagonal layers, numbered from inside (1) to outside (7): + +``` +Cross-section view of a hexagonal cell: + + Layer 7 (outermost) - Black/White + +---------------------------+ + | Layer 6 - Colored (12 zones) + | +----------------------+ | + | | Layer 5 - Black/White| | + | | +------------------+ | | + | | | Layer 4 - Colored| | | + | | | +--------------+ | | | + | | | | L3 - B/W | | | | + | | | | +----------+ | | | | + | | | | | L2 Color | | | | | + | | | | | +------+ | | | | | + | | | | | | L1 | | | | | | + | | | | | | B/W | | | | | | + | | | | | +------+ | | | | | + | | | | +----------+ | | | | + | | | +--------------+ | | | + | | +------------------+ | | + | +----------------------+ | + +---------------------------+ +``` + +### Layer Types + +| Layer | Type | Description | +|-------|------|-------------| +| 7 | Odd | Outermost, solid black or white | +| 6 | Even | Colored 12-zone pie pattern | +| 5 | Odd | Solid black or white | +| 4 | Even | Colored 12-zone pie pattern | +| 3 | Odd | Solid black or white | +| 2 | Even | Colored 12-zone pie pattern | +| 1 | Odd | Innermost, solid black or white | + +### Odd Layers (1, 3, 5, 7) - Black/White + +Odd layers are filled with either pure black (0, 0, 0) or pure white (255, 255, 255) based on the average brightness of the cell: + +```python +brightness = mean(avg_rgb) +bw_color = (0, 0, 0) if brightness < 128 else (255, 255, 255) +``` + +**Layer Radii (Fixed Proportions)**: +```python +layer_radius[i] = outer_radius * (i / 7) + +Layer 7: radius * (7/7) = 100% of hex radius +Layer 5: radius * (5/7) = ~71% of hex radius +Layer 3: radius * (3/7) = ~43% of hex radius +Layer 1: radius * (1/7) = ~14% of hex radius +``` + +### Even Layers (2, 4, 6) - Colored Pie Patterns + +Even layers contain a 12-zone pie pattern with two alternating colors. The layer diameter varies based on brightness: + +**Layer 6 Diameter**: +```python +diameter = 256 - abs(brightness - 128) * (256 - 192) / 128 + +When brightness = 128 (mid-gray): diameter = 256 (maximum) +When brightness = 0 or 255: diameter = 192 (minimum) +``` + +**Layers 4 and 2 Diameter**: +```python +diameter = (layer_radii[i + 1] * 2) - abs(brightness - 128) * 64 / 128 +``` + +This creates a visual effect where: +- Mid-brightness cells have larger colored layers +- Very dark or very bright cells have smaller colored layers + +--- + +## Color Selection + +### Palette Generation + +1. **Downscale** large images to max 1 million pixels for efficiency +2. **K-Means Clustering** extracts the dominant colors: + ```python + kmeans = KMeans(n_clusters=num_palette_colors, random_state=42) + palette = kmeans.cluster_centers_ + ``` +3. **Sort** palette by brightness for consistency +4. **Round** and **hash** for caching purposes + +### Color 1 Selection (Primary Color) + +The closest palette color to the average RGB of the layer region: + +```python +color_1 = closest_palette_color(avg_rgb, palette, avoid_rgb) +``` + +Colors used in previous layers are avoided to increase variety. + +### Color 2 Selection (Secondary Color) + +Color 2 is selected to minimize the difference between the weighted average of all colors in the layer and the target average RGB: + +```python +mixed_rgb = (color_1_area * color_1 + color_2_area * color_2 + bw_area * bw_color) / total_area +color_2 = argmin(|avg_rgb - mixed_rgb|) +``` + +This ensures that when you "squint" at the output, the colors blend to approximate the original image. + +--- + +## Zone Angle Calculation + +### 12-Zone Pie Pattern + +Each even layer is divided into 12 alternating zones radiating from the center: + +``` + Zone 1 + _____|_____ + / | \ + Z12 / Z2 | Z2 \ Z2 + / _____|_____ \ + / / | \ \ + Z11| | Center| | Z3 + | |______|_______| | + Z10 \ \ | / / Z4 + \ \_____|_____/ / + Z9 \ Z8 | Z8 / Z5 + \____|____/ + Z7 + Z6 +``` + +### Even vs Odd Zone Angles + +The angular width of zones alternates: +- **Even zones** (2, 4, 6, 8, 10, 12): Use `even_angle` +- **Odd zones** (1, 3, 5, 7, 9, 11): Use `odd_angle` + +```python +# Total must equal 360 degrees +6 * even_angle + 6 * odd_angle = 360 +# Therefore: +even_angle + odd_angle = 60 degrees +``` + +### Calculating Zone Angles + +The angle split is determined by how close Color 1 is to the target: + +```python +# Distance from Color 1 to target +dist_1 = |color_1 - avg_rgb| + +# Distance to nearest adjacent palette color +color_adj = closest_color_to(color_1) in palette +dist_adj = |color_1 - color_adj| + +# Percentage determines angle split +percentage_off = min(dist_1, dist_adj) / max(dist_1, dist_adj) + +even_angle = percentage_off * 60 # Range: 0 to 60 degrees +odd_angle = 60 - even_angle # Range: 60 to 0 degrees +``` + +**Effect**: +- When Color 1 is an exact match: `even_angle = 0`, Color 1 zones are minimal +- When Color 1 is far from target: `even_angle = 60`, Color 1 zones are maximal + +### Zone Vertex Calculation + +For each zone, three key points define the triangular shape: + +``` + p2 + /\ + / \ + / p3 \ + /______\ + p1 center + +Where: +- center: (radius, radius) - the hexagon center +- p1: Starting point on hexagon edge +- p2: Ending point on hexagon edge +- p3: Apex point for odd zones (creates extra triangle fill) +``` + +**Calculating p1 and p2**: +```python +start_angle = angle_offset +end_angle = start_angle + zone_angle + +p1 = (center + hex_radius * cos(start_angle), + center + hex_radius * sin(start_angle)) + +p2 = (center + hex_radius * cos(end_angle), + center + hex_radius * sin(end_angle)) +``` + +**Calculating p3 (Apex for Odd Zones)**: +```python +# Midpoint of p1-p2 +p12 = ((p1.x + p2.x) / 2, (p1.y + p2.y) / 2) + +# Half-distance between p1 and p2 +d = distance(p1, p2) / 2 + +# Length of shorter leg (30-60-90 triangle geometry) +length_of_shorter_leg = d / sqrt(3) + +# Perpendicular offset from midpoint +dx = (p12.x - p1.x) / distance(p1, p12) +dy = (p12.y - p1.y) / distance(p1, p12) +p3 = (p12.x + length_of_shorter_leg * dy, + p12.y - length_of_shorter_leg * dx) +``` + +The initial angle offset ensures zones align properly with the hexagon edges: +```python +angle_offset = 30 - (even_angle / 2) +``` + +--- + +## Mathematical Formulas + +### Hexagon Area Formula + +For a regular hexagon with radius `r` (center to vertex): + +``` +A = (3 * sqrt(3) / 2) * r^2 + +Derivation: +- A hexagon consists of 6 equilateral triangles +- Each triangle has side length r +- Area of equilateral triangle = (sqrt(3) / 4) * r^2 +- Total area = 6 * (sqrt(3) / 4) * r^2 = (3 * sqrt(3) / 2) * r^2 +``` + +**In code**: +```python +hex_area = 3 * math.sqrt(3) * (hex_radius ** 2) / 2 +``` + +### Layer Diameter Calculations + +**Layer 6 (Outermost Colored Layer)**: +``` +diameter = 256 - |brightness - 128| * (256 - 192) / 128 + = 256 - |brightness - 128| * 64 / 128 + = 256 - |brightness - 128| / 2 + +Range: [192, 256] pixels +Maximum when brightness = 128 +Minimum when brightness = 0 or 255 +``` + +**Layers 4 and 2**: +``` +diameter = 2 * layer_radii[i+1] - |brightness - 128| * 64 / 128 + +Where layer_radii[i+1] is the radius of the next outer odd layer +``` + +### Zone Area Calculations + +For a pie slice (circular sector approximation): + +``` +zone_area = 0.5 * r^2 * theta + +Where: +- r = layer radius +- theta = zone angle in radians +``` + +**Total Even Zone Area** (6 zones): +```python +even_area = 6 * (0.5 * hex_radius^2 * radians(even_angle)) +``` + +**Total Odd Zone Area** (6 zones): +```python +odd_area = 6 * (0.5 * hex_radius^2 * radians(odd_angle)) +``` + +### Color Mixing Weighted Average + +The weighted average formula for selecting Color 2: + +``` +mixed_rgb = (A1 * C1 + A2 * C2 + Abw * Cbw) / (A1 + A2 + Abw) + +Where: +- A1 = Total area of Color 1 zones (even zones) +- C1 = Color 1 RGB values +- A2 = Total area of Color 2 zones (odd zones) +- C2 = Color 2 RGB values (candidate) +- Abw = Area of black/white layer above +- Cbw = Black or white RGB (0,0,0 or 255,255,255) +``` + +**Goal**: Find C2 that minimizes: +``` +|avg_rgb - mixed_rgb| +``` + +This Euclidean distance minimization ensures the blended appearance matches the original. + +### Euclidean Distance (Color Matching) + +``` +distance = sqrt((r1-r2)^2 + (g1-g2)^2 + (b1-b2)^2) +``` + +**In code**: +```python +distance = np.linalg.norm(color_1 - color_2) +``` + +--- + +## Implementation Notes + +### Caching System + +Hexagon patterns are cached by their average RGB values to avoid redundant computation: + +```python +cache_key = (int(avg_r), int(avg_g), int(avg_b)) +``` + +This significantly improves performance for images with many similar-colored regions. + +### Parallel Processing + +The algorithm processes hexagons in chunks using `ProcessPoolExecutor`: +- Hexagons are grouped into chunks (default size: 32) +- Each chunk is processed by a separate worker process +- Results are composited onto the output image + +### Point Clipping + +Zone vertices that fall outside the hexagon boundary are clipped to the nearest edge: + +```python +def clip_point_to_hexagon(point, hex_coords): + if point is inside hexagon: + return point + else: + return nearest_point_on_hexagon_edge(point) +``` + +This ensures all zone triangles render correctly within the hexagonal cell boundary. + +--- + +## Quick Reference + +| Constant | Value | Description | +|----------|-------|-------------| +| Scale Factor | 16x | Output resolution multiplier | +| Hex Width | 256 px | Base hexagon width | +| Hex Height | ~222 px | Based on sqrt(3)/2 ratio | +| Layers | 7 | Concentric hexagon layers | +| Zones per Layer | 12 | Pie slices in colored layers | +| Brightness Threshold | 128 | Black vs white determination | + +--- + +## Pseudocode Summary + +``` +function hexify(input_image): + output = create_blank_image(input.size * 16) + palette = kmeans_extract_colors(input_image, n=16) + + for each hexagon_center in grid: + avg_rgb = sample_input_region(hexagon_center / 16) + bw_color = BLACK if mean(avg_rgb) < 128 else WHITE + + # Draw layers from outside to inside + for layer in [7, 6, 5, 4, 3, 2, 1]: + if layer is odd: + draw_solid_hexagon(layer, bw_color) + else: + color_1 = closest_palette_color(avg_rgb) + color_2 = select_complementary_color(avg_rgb, color_1) + angles = calculate_zone_angles(avg_rgb, color_1) + draw_12_zone_pattern(layer, color_1, color_2, angles) + + composite(output, hexagon_pattern) + + return output +``` diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5e7285d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,90 @@ +# Hexify Example Configurations + +This directory contains example configuration files for the Hexify CLI. + +## Available Presets + +### fast.yaml +Optimized for speed with reduced quality. Good for: +- Quick previews +- Batch processing large collections +- Testing different input images + +```bash +hexify input.png --config examples/fast.yaml +``` + +### detailed.yaml +Maximum quality settings for detailed output. Best for: +- Final renders +- High-resolution artwork +- Images where color accuracy matters + +```bash +hexify input.png --config examples/detailed.yaml +``` + +### minimal.yaml +Ultra-simplified style for artistic effects. Creates: +- Bold, graphic patterns +- Poster-like effects with limited colors +- Fast processing with distinctive look + +```bash +hexify input.png --config examples/minimal.yaml +``` + +### bordered.yaml +Adds visible borders between hexagons for a stained-glass effect: +- Mosaic-like appearance +- Clear hexagon boundaries +- Artistic window effect + +```bash +hexify input.png --config examples/bordered.yaml +``` + +## Creating Custom Configurations + +You can create your own configuration by copying one of the examples: + +```bash +cp examples/detailed.yaml my-config.yaml +# Edit my-config.yaml with your preferred settings +hexify input.png --config my-config.yaml +``` + +Or save your current CLI settings to a file: + +```bash +hexify input.png -c 24 --layers 6 --save-config my-config.yaml +``` + +## Configuration Options + +### Geometry Settings +- `hex_width`: Width of each hexagon in output pixels (default: 256) +- `hex_scale_factor`: Output scale relative to input (default: 16) +- `num_layers`: Number of concentric layers per hexagon (1-7) +- `num_zones`: Number of angular zones in even layers (4-24) +- `orientation`: Either `flat_top` or `pointy_top` + +### Color Settings +- `color_space`: Color space for matching (`rgb`, `lab`, `hsv`) +- `quantization_method`: `kmeans` (accurate) or `minibatch_kmeans` (fast) +- `num_palette_colors`: Colors in the palette (2-64, recommended: 8-32) + +### Style Settings +- `border_width`: Hexagon border width in pixels (0 = no border) +- `border_color`: RGB values as `[R, G, B]` + +### Processing Settings +- `chunk_size`: Hexagons per processing batch (affects memory/speed) +- `max_palette_sample_pixels`: Max pixels sampled for palette generation + +## Tips + +1. **For portraits**: Use `detailed.yaml` with 24-32 colors for best results +2. **For landscapes**: `detailed.yaml` with 16-24 colors works well +3. **For icons/logos**: Try `minimal.yaml` for a graphic poster effect +4. **For batch processing**: Use `fast.yaml` to preview, then `detailed.yaml` for finals diff --git a/examples/bordered.yaml b/examples/bordered.yaml new file mode 100644 index 0000000..7c0343c --- /dev/null +++ b/examples/bordered.yaml @@ -0,0 +1,30 @@ +# Hexify Bordered Preset +# Adds visible borders between hexagons +# Creates a stained-glass or mosaic effect + +# Geometry settings +hex_width: 256 +hex_scale_factor: 16 +num_layers: 7 +num_zones: 12 +orientation: flat_top + +# Color settings +color_space: rgb +quantization_method: kmeans +num_palette_colors: 16 + +# K-means settings +kmeans_random_state: 42 +kmeans_n_init: 10 +max_palette_sample_pixels: 1000000 + +# Style settings - the key difference +border_width: 2 # Visible border between hexagons +border_color: [0, 0, 0] # Black borders (stained glass effect) + +# Processing settings +chunk_size: 100 + +# Brightness settings +brightness_threshold: 128 diff --git a/examples/detailed.yaml b/examples/detailed.yaml new file mode 100644 index 0000000..01e94c8 --- /dev/null +++ b/examples/detailed.yaml @@ -0,0 +1,35 @@ +# Hexify Detailed Preset +# Maximum quality settings for detailed output +# Best for final renders and high-resolution artwork + +# Geometry settings +hex_width: 256 +hex_scale_factor: 16 +num_layers: 7 # Maximum layers for detailed patterns +num_zones: 18 # More zones for smoother color transitions +orientation: flat_top + +# Color settings - using standard k-means for best quality +color_space: rgb +quantization_method: kmeans +num_palette_colors: 32 # More colors for better color matching + +# K-means settings - more iterations for stable colors +kmeans_random_state: 42 +kmeans_n_init: 15 # More initializations for better clustering +max_palette_sample_pixels: 1500000 # Sample more pixels + +# Style settings +border_width: 0 +border_color: [0, 0, 0] + +# Processing settings +chunk_size: 50 # Smaller chunks for better memory management + +# Brightness settings +brightness_threshold: 128 + +# Layer diameter settings +layer_6_max_diameter: 256 +layer_6_min_diameter: 192 +inner_layer_diameter_range: 64 diff --git a/examples/fast.yaml b/examples/fast.yaml new file mode 100644 index 0000000..274b6b6 --- /dev/null +++ b/examples/fast.yaml @@ -0,0 +1,30 @@ +# Hexify Fast Preset +# Optimized for speed with reduced quality +# Good for previews and batch processing large collections + +# Geometry settings +hex_width: 256 +hex_scale_factor: 16 +num_layers: 5 # Reduced from 7 for faster rendering +num_zones: 6 # Reduced from 12 for simpler patterns +orientation: flat_top + +# Color settings - using minibatch_kmeans for speed +color_space: rgb +quantization_method: minibatch_kmeans +num_palette_colors: 8 # Fewer colors = faster palette generation + +# K-means settings +kmeans_random_state: 42 +kmeans_n_init: 5 # Fewer initializations for speed +max_palette_sample_pixels: 500000 + +# Style settings +border_width: 0 +border_color: [0, 0, 0] + +# Processing settings +chunk_size: 200 # Larger chunks = less overhead + +# Brightness settings +brightness_threshold: 128 diff --git a/examples/minimal.yaml b/examples/minimal.yaml new file mode 100644 index 0000000..44064db --- /dev/null +++ b/examples/minimal.yaml @@ -0,0 +1,30 @@ +# Hexify Minimal Preset +# Ultra-simplified style for artistic effects +# Creates bold, graphic patterns with minimal detail + +# Geometry settings +hex_width: 256 +hex_scale_factor: 16 +num_layers: 4 # Minimal layers for bold look +num_zones: 6 # Simple angular divisions +orientation: flat_top + +# Color settings - fast and simple +color_space: rgb +quantization_method: minibatch_kmeans +num_palette_colors: 6 # Very limited palette for poster effect + +# K-means settings +kmeans_random_state: 42 +kmeans_n_init: 5 +max_palette_sample_pixels: 250000 + +# Style settings +border_width: 0 +border_color: [0, 0, 0] + +# Processing settings +chunk_size: 200 + +# Brightness settings +brightness_threshold: 128 diff --git a/fire/f09f0b_palette.png b/fire/f09f0b_palette.png new file mode 100644 index 0000000..cdddc62 Binary files /dev/null and b/fire/f09f0b_palette.png differ diff --git a/fire/output.png b/fire/output.png new file mode 100644 index 0000000..5d18f30 Binary files /dev/null and b/fire/output.png differ diff --git a/hexagon_processor.py b/hexagon_processor.py index 29cd4e3..cedf43d 100644 --- a/hexagon_processor.py +++ b/hexagon_processor.py @@ -1,374 +1,783 @@ +"""Hexagon pattern processor for image transformation. + +This module provides the HexagonProcessor class for transforming images +into hexagonal pattern art using color palette extraction and geometric +pattern generation. +""" + +from __future__ import annotations + from multiprocessing import Manager, Lock +from multiprocessing.managers import ValueProxy, DictProxy +from concurrent.futures import ProcessPoolExecutor, as_completed +from typing import TypeAlias, Any import numpy as np import cv2 import matplotlib.pyplot as plt from matplotlib.patches import RegularPolygon from matplotlib.path import Path import math -from concurrent.futures import ProcessPoolExecutor, as_completed import os import hashlib from sklearn.cluster import KMeans +from tqdm import tqdm + +# Type Aliases for improved readability +RGB: TypeAlias = tuple[int, int, int] +Coordinate: TypeAlias = tuple[int, int] +ImageArray: TypeAlias = np.ndarray +HexagonResult: TypeAlias = tuple[int, int, int, int, np.ndarray, np.ndarray] | None + class HexagonProcessor: - def __init__(self, num_palette_colors=16, num_processes=None, hexagons_dir=None, chunk_size=32, save_hexagons=True): - self.num_palette_colors = num_palette_colors - self.num_processes = num_processes or os.cpu_count() - self.hexagons_dir = hexagons_dir - self.hex_centers = None - self.chunk_size = chunk_size - self.save_hexagons = save_hexagons + """Processes images into hexagonal pattern art. + + This class handles the transformation of input images into stylized + hexagonal patterns by extracting color palettes and generating + geometric hexagon patterns based on local image colors. + + Attributes: + num_palette_colors: Number of colors in the extracted palette. + num_processes: Number of parallel processes for processing. + hexagons_dir: Directory to save individual hexagon patterns. + chunk_size: Number of hexagons to process per chunk. + save_hexagons: Whether to save individual hexagon images. + palette: Extracted color palette from the image. + palette_hash: SHA256 hash of the palette for caching. + hex_centers: List of hexagon center coordinates. + """ + + # Validation constants + MIN_IMAGE_SIZE: int = 16 + MIN_PALETTE_COLORS: int = 5 + MAX_PALETTE_COLORS: int = 256 + + def __init__( + self, + num_palette_colors: int = 16, + num_processes: int | None = None, + hexagons_dir: str | None = None, + chunk_size: int = 32, + save_hexagons: bool = True, + ) -> None: + """Initialize the HexagonProcessor. + + Args: + num_palette_colors: Number of colors for the palette (5-256). + num_processes: Number of parallel processes. Defaults to CPU count. + hexagons_dir: Directory path for saving hexagon images. + chunk_size: Number of hexagons processed per chunk. + save_hexagons: Whether to save individual hexagon images. + + Raises: + ValueError: If num_palette_colors is outside valid range (5-256). + ValueError: If num_processes is less than 1. + ValueError: If chunk_size is less than 1. + """ + # Validate palette colors + if num_palette_colors < self.MIN_PALETTE_COLORS: + raise ValueError( + f"num_palette_colors must be at least {self.MIN_PALETTE_COLORS}, " + f"got {num_palette_colors}" + ) + if num_palette_colors > self.MAX_PALETTE_COLORS: + raise ValueError( + f"num_palette_colors must be at most {self.MAX_PALETTE_COLORS}, " + f"got {num_palette_colors}" + ) + + # Validate process count + effective_processes = num_processes or os.cpu_count() or 1 + if num_processes is not None and num_processes < 1: + raise ValueError(f"num_processes must be at least 1, got {num_processes}") + + # Validate chunk size + if chunk_size < 1: + raise ValueError(f"chunk_size must be at least 1, got {chunk_size}") + + self.num_palette_colors: int = num_palette_colors + self.num_processes: int = effective_processes + self.hexagons_dir: str | None = hexagons_dir + self.hex_centers: list[Coordinate] | None = None + self.chunk_size: int = chunk_size + self.save_hexagons: bool = save_hexagons # Create a manager for shared objects manager = Manager() - self.hexagon_cache = manager.dict() - self.cache_hits = manager.Value('i', 0) - self.cache_misses = manager.Value('i', 0) - self.cache_lock = manager.Lock() + self.hexagon_cache: DictProxy[RGB, ImageArray] = manager.dict() + self.cache_hits: ValueProxy[int] = manager.Value("i", 0) + self.cache_misses: ValueProxy[int] = manager.Value("i", 0) + self.cache_lock: Lock = manager.Lock() # Initialize palette and palette_hash - self.palette = None - self.palette_hash = None + self.palette: ImageArray | None = None + self.palette_hash: str | None = None + + # Internal state + self.input_image: ImageArray | None = None + self.output_shape: tuple[int, int, int] | None = None + self.hex_width: int = 0 + self.hex_height: int = 0 + self.hex_radius: int = 0 + + def process_image( + self, input_image: ImageArray, pbar: tqdm | None = None + ) -> ImageArray: + """Process an image through the hexagon filter. + + Transforms the input image into a hexagonal pattern at 16x resolution. + Generates a color palette if not already set, sets up the hexagon grid, + and processes all hexagons. + + Args: + input_image: RGB image array with shape (height, width, 3). + pbar: Optional progress bar for tracking processing. + + Returns: + Processed image at 16x resolution as numpy array. + + Raises: + ValueError: If image is smaller than 16x16 pixels. + ValueError: If image does not have 3 color channels. + """ + # Validate image dimensions + if input_image.shape[0] < self.MIN_IMAGE_SIZE: + raise ValueError( + f"Image height must be at least {self.MIN_IMAGE_SIZE} pixels, " + f"got {input_image.shape[0]}" + ) + if input_image.shape[1] < self.MIN_IMAGE_SIZE: + raise ValueError( + f"Image width must be at least {self.MIN_IMAGE_SIZE} pixels, " + f"got {input_image.shape[1]}" + ) + if len(input_image.shape) != 3 or input_image.shape[2] != 3: + raise ValueError( + f"Image must have 3 color channels (RGB), got shape {input_image.shape}" + ) - def process_image(self, input_image, pbar=None): self.input_image = input_image if self.palette is None: self.generate_palette(input_image) if self.hex_centers is None: self.setup_hexagon_grid(input_image.shape) return self.process_hexagons(pbar) - - def generate_palette(self, image): + + def generate_palette(self, image: ImageArray) -> None: + """Generate a color palette from the input image using K-means clustering. + + Extracts the most representative colors from the image and sorts them + by brightness for consistent results. + + Args: + image: RGB image array with shape (height, width, 3). + """ # Resize the image if it's too large to speed up processing - max_pixels = 1000000 # 1 million pixels + max_pixels: int = 1000000 # 1 million pixels height, width = image.shape[:2] if height * width > max_pixels: - scale = np.sqrt(max_pixels / (height * width)) - new_height = int(height * scale) - new_width = int(width * scale) - image = cv2.resize(image, (new_width, new_height), interpolation=cv2.INTER_AREA) + scale: float = np.sqrt(max_pixels / (height * width)) + new_height: int = int(height * scale) + new_width: int = int(width * scale) + image = cv2.resize( + image, (new_width, new_height), interpolation=cv2.INTER_AREA + ) + + pixels: ImageArray = image.reshape(-1, 3) - pixels = image.reshape(-1, 3) - # Use a fixed random state for reproducibility kmeans = KMeans(n_clusters=self.num_palette_colors, random_state=42, n_init=10) kmeans.fit(pixels) - - palette = kmeans.cluster_centers_ - + + palette: ImageArray = kmeans.cluster_centers_ + # Sort the palette by brightness for consistency - sorted_palette_indices = np.argsort(np.mean(palette, axis=1)) + sorted_palette_indices: ImageArray = np.argsort(np.mean(palette, axis=1)) self.palette = palette[sorted_palette_indices] - + # Round the palette values to integers for consistent hashing self.palette = np.round(self.palette).astype(int) - + # Generate hash from the sorted, rounded palette self.palette_hash = hashlib.sha256(self.palette.tobytes()).hexdigest() - def setup_hexagon_grid(self, input_shape): + def setup_hexagon_grid(self, input_shape: tuple[int, ...]) -> None: + """Set up the hexagon grid based on input image dimensions. + + Calculates hexagon centers for the output image which is 16x the + input resolution. + + Args: + input_shape: Shape tuple of the input image (height, width, channels). + """ self.output_shape = (input_shape[0] * 16, input_shape[1] * 16, 3) self.hex_width = 256 - self.hex_height = round(self.hex_width * (math.sqrt(3)/2)) + self.hex_height = round(self.hex_width * (math.sqrt(3) / 2)) self.hex_radius = self.hex_width // 2 - hex_horizontal_spacing = self.hex_width * 0.75 - hex_vertical_spacing = self.hex_height + hex_horizontal_spacing: float = self.hex_width * 0.75 + hex_vertical_spacing: int = self.hex_height - cols = int(self.output_shape[1] / hex_horizontal_spacing) + 2 - rows = int(self.output_shape[0] / hex_horizontal_spacing) + 2 + cols: int = int(self.output_shape[1] / hex_horizontal_spacing) + 2 + # BUG FIX: Was using hex_horizontal_spacing instead of hex_vertical_spacing for row calculation + # This could result in too few rows for tall images + rows: int = int(self.output_shape[0] / hex_vertical_spacing) + 2 self.hex_centers = [ - (int(hex_horizontal_spacing * col), - int(hex_vertical_spacing * row + (0.5 * hex_vertical_spacing if col % 2 else 0))) + ( + int(hex_horizontal_spacing * col), + int( + hex_vertical_spacing * row + + (0.5 * hex_vertical_spacing if col % 2 else 0) + ), + ) for row in range(rows) for col in range(cols) ] - def process_hexagons(self, pbar=None): - output_image = np.zeros(self.output_shape, dtype=np.uint8) - + def process_hexagons(self, pbar: tqdm | None = None) -> ImageArray: + """Process all hexagons in parallel and compose the output image. + + Uses multiprocessing to generate hexagon patterns for each grid + position and combines them into the final output image. + + Args: + pbar: Optional progress bar for tracking progress. + + Returns: + The composed output image as a numpy array. + """ + output_image: ImageArray = np.zeros(self.output_shape, dtype=np.uint8) + # Group hex_centers into chunks - hex_center_chunks = [self.hex_centers[i:i + self.chunk_size] for i in range(0, len(self.hex_centers), self.chunk_size)] - + hex_center_chunks: list[list[Coordinate]] = [ + self.hex_centers[i : i + self.chunk_size] + for i in range(0, len(self.hex_centers), self.chunk_size) + ] + with ProcessPoolExecutor(max_workers=self.num_processes) as executor: - futures = [executor.submit(self.process_hexagon_chunk, chunk) - for chunk in hex_center_chunks] - + futures = [ + executor.submit(self.process_hexagon_chunk, chunk) + for chunk in hex_center_chunks + ] + for future in as_completed(futures): - results = future.result() + results: list[HexagonResult] = future.result() for result in results: if result: - x_start, y_start, x_end, y_end, hex_pattern_masked, mask_slice = result + x_start, y_start, x_end, y_end, hex_pattern_masked, mask_slice = ( + result + ) hex_slice = output_image[y_start:y_end, x_start:x_end] hex_slice[mask_slice != 0] = hex_pattern_masked[mask_slice != 0] - + if pbar: - pbar.update(self.chunk_size) + # BUG FIX: Update by actual chunk size, not self.chunk_size + # The final chunk may be smaller, causing progress bar overshoot + pbar.update(len(results)) return output_image - def process_hexagon_chunk(self, centers): - local_cache = {} - results = [] - + def process_hexagon_chunk( + self, centers: list[Coordinate] + ) -> list[HexagonResult]: + """Process a chunk of hexagon centers. + + Generates hexagon patterns for a batch of center coordinates. + Note: Caching was removed as inner layers depend on position (center_x, center_y), + making cached patterns incorrect for different positions. + + Args: + centers: List of (x, y) center coordinates for hexagons. + + Returns: + List of hexagon results containing position and pattern data, + or None for invalid positions. + """ + results: list[HexagonResult] = [] + for center_x, center_y in centers: - x_start = max(center_x - self.hex_radius, 0) - y_start = max(center_y - self.hex_radius, 0) - x_end = min(center_x + self.hex_radius, self.output_shape[1]) - y_end = min(center_y + self.hex_radius, self.output_shape[0]) - + x_start: int = max(center_x - self.hex_radius, 0) + y_start: int = max(center_y - self.hex_radius, 0) + x_end: int = min(center_x + self.hex_radius, self.output_shape[1]) + y_end: int = min(center_y + self.hex_radius, self.output_shape[0]) + if x_start < x_end and y_start < y_end: - input_center_x = int(center_x / 16) - input_center_y = int(center_y / 16) - input_hex_radius = self.hex_radius // 16 - - input_mask = self.create_hex_mask(input_center_x, input_center_y, input_hex_radius, self.input_image.shape[:2]) - avg_rgb = self.average_color(self.input_image, input_mask) - avg_rgb_key = tuple(map(int, avg_rgb)) - - if avg_rgb_key in local_cache: - hex_pattern = local_cache[avg_rgb_key] - with self.cache_lock: - self.cache_hits.value += 1 - elif avg_rgb_key in self.hexagon_cache: - hex_pattern = self.hexagon_cache[avg_rgb_key] - local_cache[avg_rgb_key] = hex_pattern - with self.cache_lock: - self.cache_hits.value += 1 - else: - hex_pattern = self.create_hex_pattern(center_x, center_y, self.hex_radius, avg_rgb) - local_cache[avg_rgb_key] = hex_pattern - with self.cache_lock: - self.hexagon_cache[avg_rgb_key] = hex_pattern - self.cache_misses.value += 1 - if self.save_hexagons and self.hexagons_dir: - hex_filename = f"{self.palette_hash[:6]}_hexagon_{avg_rgb_key[0]:03d}_{avg_rgb_key[1]:03d}_{avg_rgb_key[2]:03d}.png" - hex_path = os.path.join(self.hexagons_dir, hex_filename) - plt.imsave(hex_path, hex_pattern) - - full_mask = self.create_hex_mask(center_x, center_y, self.hex_radius, self.output_shape[:2]) - mask = full_mask[y_start:y_end, x_start:x_end] - - pattern_y_start = y_start - (center_y - self.hex_radius) - pattern_x_start = x_start - (center_x - self.hex_radius) - pattern_y_end = pattern_y_start + (y_end - y_start) - pattern_x_end = pattern_x_start + (x_end - x_start) - - hex_pattern_cropped = hex_pattern[pattern_y_start:pattern_y_end, pattern_x_start:pattern_x_end] - hex_pattern_masked = cv2.bitwise_and(hex_pattern_cropped, hex_pattern_cropped, mask=mask) - - results.append((x_start, y_start, x_end, y_end, hex_pattern_masked, mask)) + input_center_x: int = int(center_x / 16) + input_center_y: int = int(center_y / 16) + input_hex_radius: int = self.hex_radius // 16 + + input_mask: ImageArray = self.create_hex_mask( + input_center_x, + input_center_y, + input_hex_radius, + self.input_image.shape[:2], + ) + avg_rgb: ImageArray = self.average_color(self.input_image, input_mask) + + hex_pattern: ImageArray = self.create_hex_pattern( + center_x, center_y, self.hex_radius, avg_rgb + ) + + if self.save_hexagons and self.hexagons_dir: + avg_rgb_key: RGB = tuple(map(int, avg_rgb)) + hex_filename: str = ( + f"{self.palette_hash[:6]}_hexagon_" + f"{avg_rgb_key[0]:03d}_{avg_rgb_key[1]:03d}_{avg_rgb_key[2]:03d}_" + f"{center_x}_{center_y}.png" + ) + hex_path: str = os.path.join(self.hexagons_dir, hex_filename) + plt.imsave(hex_path, hex_pattern) + + full_mask: ImageArray = self.create_hex_mask( + center_x, center_y, self.hex_radius, self.output_shape[:2] + ) + mask: ImageArray = full_mask[y_start:y_end, x_start:x_end] + + pattern_y_start: int = y_start - (center_y - self.hex_radius) + pattern_x_start: int = x_start - (center_x - self.hex_radius) + pattern_y_end: int = pattern_y_start + (y_end - y_start) + pattern_x_end: int = pattern_x_start + (x_end - x_start) + + hex_pattern_cropped: ImageArray = hex_pattern[ + pattern_y_start:pattern_y_end, pattern_x_start:pattern_x_end + ] + hex_pattern_masked: ImageArray = cv2.bitwise_and( + hex_pattern_cropped, hex_pattern_cropped, mask=mask + ) + + results.append( + (x_start, y_start, x_end, y_end, hex_pattern_masked, mask) + ) else: results.append(None) - + return results - def get_cache_hit_rate(self): - total_accesses = self.cache_hits.value + self.cache_misses.value + def get_cache_hit_rate(self) -> float: + """Calculate the cache hit rate for hexagon pattern caching. + + Returns: + Cache hit rate as a float between 0.0 and 1.0. + """ + total_accesses: int = self.cache_hits.value + self.cache_misses.value if total_accesses == 0: - return 0 + return 0.0 return self.cache_hits.value / total_accesses @staticmethod - def create_hex_mask(center_x, center_y, radius, shape): - mask = np.zeros(shape, dtype=np.uint8) - hexagon = RegularPolygon((center_x, center_y), numVertices=6, radius=radius, orientation=np.pi / 2) - coords = hexagon.get_verts() + def create_hex_mask( + center_x: int, center_y: int, radius: int, shape: tuple[int, int] + ) -> ImageArray: + """Create a hexagonal mask at the specified position. + + Args: + center_x: X coordinate of hexagon center. + center_y: Y coordinate of hexagon center. + radius: Radius of the hexagon. + shape: Shape of the output mask (height, width). + + Returns: + Binary mask array with hexagon filled with 255. + """ + mask: ImageArray = np.zeros(shape, dtype=np.uint8) + hexagon = RegularPolygon( + (center_x, center_y), numVertices=6, radius=radius, orientation=np.pi / 2 + ) + coords: ImageArray = hexagon.get_verts() coords = np.clip(coords, [0, 0], [shape[1] - 1, shape[0] - 1]).astype(int) mask = cv2.fillPoly(mask, [coords], 255) return mask @staticmethod - def average_color(image, mask): - masked = cv2.bitwise_and(image, image, mask=mask) - avg_color = cv2.mean(masked, mask=mask)[:3] + def average_color(image: ImageArray, mask: ImageArray) -> ImageArray: + """Calculate the average color within a masked region. + + Args: + image: RGB image array. + mask: Binary mask array. + + Returns: + Average RGB color as integer array. + """ + masked: ImageArray = cv2.bitwise_and(image, image, mask=mask) + avg_color: tuple[float, ...] = cv2.mean(masked, mask=mask)[:3] return np.round(avg_color).astype(int) - def create_hex_pattern(self, center_x, center_y, radius, avg_rgb): - bw_rgb = 0 if np.mean(avg_rgb) < 128 else 255 - pattern = np.zeros((2 * radius, 2 * radius, 3), dtype=np.uint8) + def create_hex_pattern( + self, center_x: int, center_y: int, radius: int, avg_rgb: ImageArray + ) -> ImageArray: + """Create a hexagon pattern with layered color zones. + + Generates a stylized hexagon pattern with alternating layers of + colors selected from the palette based on the average color. + + Args: + center_x: X coordinate of hexagon center in output space. + center_y: Y coordinate of hexagon center in output space. + radius: Radius of the hexagon pattern. + avg_rgb: Average RGB color for the hexagon region. + + Returns: + Hexagon pattern image as numpy array. + """ + bw_rgb: int = 0 if np.mean(avg_rgb) < 128 else 255 + pattern: ImageArray = np.zeros((2 * radius, 2 * radius, 3), dtype=np.uint8) if bw_rgb != 0: pattern = np.full((2 * radius, 2 * radius, 3), bw_rgb, dtype=np.uint8) - - layer_areas = {7: 0, 6: 0, 5: 0, 4: 0, 3: 0, 1: 0} - layer_radii = {7: 0, 6: 0, 5: 0, 4: 0, 3: 0, 1: 0} - - bw_color = (0, 0, 0) if np.mean(avg_rgb) < 128 else (255, 255, 255) - avoid_rgb = [] + + layer_areas: dict[int, float] = {7: 0, 6: 0, 5: 0, 4: 0, 3: 0, 1: 0} + layer_radii: dict[int, int] = {7: 0, 6: 0, 5: 0, 4: 0, 3: 0, 1: 0} + + bw_color: RGB = (0, 0, 0) if np.mean(avg_rgb) < 128 else (255, 255, 255) + avoid_rgb: list[ImageArray] = [] for i in range(7, 0, -1): if i % 2 == 1: - hex_radius = int(radius * (i / 7)) + hex_radius: int = int(radius * (i / 7)) layer_radii[i] = hex_radius - pattern, layer_area = self.fill_odd_layer(pattern, radius, hex_radius, bw_color) + pattern, layer_area = self.fill_odd_layer( + pattern, radius, hex_radius, bw_color + ) layer_areas[i] = layer_area elif i % 2 == 0: - pattern, layer_area = self.fill_even_layer(i, avg_rgb, self.palette, radius, layer_radii, avoid_rgb, layer_areas, pattern, self.input_image, center_x, center_y, bw_color) + pattern, layer_area = self.fill_even_layer( + i, + avg_rgb, + self.palette, + radius, + layer_radii, + avoid_rgb, + layer_areas, + pattern, + self.input_image, + center_x, + center_y, + bw_color, + ) layer_areas[i] = layer_area return pattern @staticmethod - def fill_odd_layer(pattern, radius, hex_radius, bw_color): - inner_hexagon = RegularPolygon((radius, radius), numVertices=6, radius=hex_radius, orientation=np.pi / 2) - inner_coords = inner_hexagon.get_verts().astype(int) + def fill_odd_layer( + pattern: ImageArray, radius: int, hex_radius: int, bw_color: RGB + ) -> tuple[ImageArray, float]: + """Fill an odd-numbered layer with solid color. + + Args: + pattern: The pattern image to draw on. + radius: Base radius for positioning. + hex_radius: Radius of this layer's hexagon. + bw_color: Black or white color tuple. + + Returns: + Tuple of (updated pattern, layer area). + """ + inner_hexagon = RegularPolygon( + (radius, radius), numVertices=6, radius=hex_radius, orientation=np.pi / 2 + ) + inner_coords: ImageArray = inner_hexagon.get_verts().astype(int) pattern = cv2.fillPoly(pattern, [inner_coords], tuple(map(int, bw_color))) - hex_area = 3 * math.sqrt(3) * (hex_radius ** 2) / 2 + hex_area: float = 3 * math.sqrt(3) * (hex_radius**2) / 2 return pattern, hex_area - def fill_even_layer(self, i, avg_rgb, palette_rgb, radius, layer_radii, avoid_rgb, layer_areas, pattern, input_image, center_x, center_y, bw_color): - brightness = np.mean(avg_rgb) + def fill_even_layer( + self, + i: int, + avg_rgb: ImageArray, + palette_rgb: ImageArray, + radius: int, + layer_radii: dict[int, int], + avoid_rgb: list[ImageArray], + layer_areas: dict[int, float], + pattern: ImageArray, + input_image: ImageArray, + center_x: int, + center_y: int, + bw_color: RGB, + ) -> tuple[ImageArray, float]: + """Fill an even-numbered layer with color zones. + + Creates alternating wedge patterns using colors from the palette. + + Args: + i: Layer index (2, 4, or 6). + avg_rgb: Average color for the region. + palette_rgb: Color palette array. + radius: Base radius for positioning. + layer_radii: Dictionary of radii for each layer. + avoid_rgb: List of colors to avoid using. + layer_areas: Dictionary of areas for each layer. + pattern: The pattern image to draw on. + input_image: Original input image. + center_x: X coordinate of hexagon center. + center_y: Y coordinate of hexagon center. + bw_color: Black or white background color. + + Returns: + Tuple of (updated pattern, layer area). + """ + brightness: float = np.mean(avg_rgb) if i == 6: - diameter = 256 - abs(brightness - 128) * (256 - 192) / 128 + diameter: float = 256 - abs(brightness - 128) * (256 - 192) / 128 else: diameter = (layer_radii[i + 1] * 2) - abs(brightness - 128) * (64) / 128 - hex_radius = int(diameter // 2) - inner_hexagon = RegularPolygon((radius, radius), numVertices=6, radius=hex_radius, orientation=np.pi / 2) - inner_coords = inner_hexagon.get_verts().astype(int) - - scaled_center_x = center_x // 16 - scaled_center_y = center_y // 16 - scaled_radius = hex_radius // 16 - - input_mask = self.create_hex_mask(scaled_center_x, scaled_center_y, scaled_radius, input_image.shape[:2]) + hex_radius: int = int(diameter // 2) + inner_hexagon = RegularPolygon( + (radius, radius), numVertices=6, radius=hex_radius, orientation=np.pi / 2 + ) + inner_coords: ImageArray = inner_hexagon.get_verts().astype(int) + + scaled_center_x: int = center_x // 16 + scaled_center_y: int = center_y // 16 + scaled_radius: int = hex_radius // 16 + + input_mask: ImageArray = self.create_hex_mask( + scaled_center_x, scaled_center_y, scaled_radius, input_image.shape[:2] + ) avg_rgb = self.average_color(input_image, input_mask) - color_1 = self.closest_palette_color(avg_rgb, palette_rgb, avoid_rgb) + color_1: ImageArray = self.closest_palette_color(avg_rgb, palette_rgb, avoid_rgb) avoid_rgb.append(color_1) - dist_1 = np.linalg.norm(color_1 - avg_rgb) - - color_adj = min(palette_rgb, key=lambda color: np.linalg.norm(color - color_1) if not np.array_equal(color, color_1) else float('inf')) - dist_adj = np.linalg.norm(color_1 - color_adj) - - percentage_off = min(dist_1, dist_adj) / max(dist_1, dist_adj) if dist_adj != 0 else 0 - even_angle = percentage_off * 60 - odd_angle = 60 - even_angle - - even_area = 6 * (0.5 * hex_radius * hex_radius * math.radians(even_angle)) - odd_area = 6 * (0.5 * hex_radius * hex_radius * math.radians(odd_angle)) - - layer_area = even_area + odd_area - - color_2 = self.select_color_2(avg_rgb, palette_rgb, color_1, even_area, odd_area, bw_color, layer_areas[i + 1] - layer_area) + dist_1: float = np.linalg.norm(color_1 - avg_rgb) + + color_adj: ImageArray = min( + palette_rgb, + key=lambda color: ( + np.linalg.norm(color - color_1) + if not np.array_equal(color, color_1) + else float("inf") + ), + ) + dist_adj: float = np.linalg.norm(color_1 - color_adj) + + percentage_off: float = ( + min(dist_1, dist_adj) / max(dist_1, dist_adj) if dist_adj != 0 else 0 + ) + even_angle: float = percentage_off * 60 + odd_angle: float = 60 - even_angle + + even_area: float = 6 * (0.5 * hex_radius * hex_radius * math.radians(even_angle)) + odd_area: float = 6 * (0.5 * hex_radius * hex_radius * math.radians(odd_angle)) + + layer_area: float = even_area + odd_area + + color_2: ImageArray = self.select_color_2( + avg_rgb, + palette_rgb, + color_1, + even_area, + odd_area, + bw_color, + layer_areas[i + 1] - layer_area, + ) avoid_rgb.append(color_2) - color_1_rgb = tuple(map(int, color_1)) - color_2_rgb = tuple(map(int, color_2)) + color_1_rgb: RGB = tuple(map(int, color_1)) + color_2_rgb: RGB = tuple(map(int, color_2)) - angle_offset = 30 - (even_angle / 2) + angle_offset: float = 30 - (even_angle / 2) for zone in range(12): - angle = even_angle if zone % 2 == 0 else odd_angle - angle_color = color_1_rgb if zone % 2 == 0 else color_2_rgb + angle: float = even_angle if zone % 2 == 0 else odd_angle + angle_color: RGB = color_1_rgb if zone % 2 == 0 else color_2_rgb - start_angle = angle_offset - end_angle = start_angle + angle + start_angle: float = angle_offset + end_angle: float = start_angle + angle angle_offset += angle - p1 = (radius + hex_radius * math.cos(math.radians(start_angle)), radius + hex_radius * math.sin(math.radians(start_angle))) - p2 = (radius + hex_radius * math.cos(math.radians(end_angle)), radius + hex_radius * math.sin(math.radians(end_angle))) + p1: tuple[float, float] = ( + radius + hex_radius * math.cos(math.radians(start_angle)), + radius + hex_radius * math.sin(math.radians(start_angle)), + ) + p2: tuple[float, float] = ( + radius + hex_radius * math.cos(math.radians(end_angle)), + radius + hex_radius * math.sin(math.radians(end_angle)), + ) - p12 = ((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2) + p12: tuple[float, float] = ((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2) - d = ((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2) ** 0.5 / 2 + d: float = ((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2) ** 0.5 / 2 - length_of_shorter_leg = d / math.sqrt(3) + length_of_shorter_leg: float = d / math.sqrt(3) - dx = p12[0] - p1[0] - dy = p12[1] - p1[1] + dx: float = p12[0] - p1[0] + dy: float = p12[1] - p1[1] - length = (dx ** 2 + dy ** 2) ** 0.5 - if length > 1e-6: # Add a small threshold to avoid division by very small numbers + length: float = (dx**2 + dy**2) ** 0.5 + if length > 1e-6: dx /= length dy /= length - p3 = (p12[0] + length_of_shorter_leg * dy, p12[1] - length_of_shorter_leg * dx) + p3: tuple[float, float] = ( + p12[0] + length_of_shorter_leg * dy, + p12[1] - length_of_shorter_leg * dx, + ) else: - # If p1 and p2 are essentially the same point, we can't determine p3 - # In this case, we'll just use p1 for all points p3 = p1 p1 = self.clip_point_to_hexagon(p1, inner_coords) p2 = self.clip_point_to_hexagon(p2, inner_coords) p3 = self.clip_point_to_hexagon(p3, inner_coords) - vertices1 = [(radius, radius), p1, p2] - vertices1 = np.array(vertices1, dtype=np.int32) + vertices1: ImageArray = np.array( + [(radius, radius), p1, p2], dtype=np.int32 + ) cv2.fillPoly(pattern, [vertices1], angle_color) if zone % 2 == 1: - vertices2 = [p1, p2, p3] - vertices2 = np.array(vertices2, dtype=np.int32) + vertices2: ImageArray = np.array([p1, p2, p3], dtype=np.int32) cv2.fillPoly(pattern, [vertices2], angle_color) return pattern, layer_area @staticmethod - def closest_palette_color(avg_rgb, palette_rgb, avoid_rgb=None): - min_distance = float('inf') - closest_color = None + def closest_palette_color( + avg_rgb: ImageArray, + palette_rgb: ImageArray, + avoid_rgb: list[ImageArray] | None = None, + ) -> ImageArray: + """Find the closest palette color to the target color. + + Args: + avg_rgb: Target RGB color to match. + palette_rgb: Array of palette colors. + avoid_rgb: Optional list of colors to avoid. + + Returns: + The closest palette color as numpy array. + """ + min_distance: float = float("inf") + closest_color: ImageArray | None = None + # BUG FIX: Track the closest color ignoring avoid list as fallback + # This prevents returning None when all colors are in avoid_rgb + fallback_color: ImageArray | None = None + fallback_distance: float = float("inf") for color in palette_rgb: - if avoid_rgb is not None and any(np.array_equal(color, avoid_color) for avoid_color in avoid_rgb): + distance: float = np.linalg.norm(avg_rgb - color) + + # Track fallback (closest color ignoring avoid list) + if distance < fallback_distance: + fallback_distance = distance + fallback_color = color + + if avoid_rgb is not None and any( + np.array_equal(color, avoid_color) for avoid_color in avoid_rgb + ): continue - - distance = np.linalg.norm(avg_rgb - color) + if distance < min_distance: min_distance = distance closest_color = color - - return closest_color + + # Return closest non-avoided color, or fallback to closest color if all avoided + return closest_color if closest_color is not None else fallback_color @staticmethod - def select_color_2(avg_rgb, palette_rgb, color_1, color_1_area, color_2_area, bw_color, bw_color_area): - min_distance = float('inf') - best_color_2 = None - + def select_color_2( + avg_rgb: ImageArray, + palette_rgb: ImageArray, + color_1: ImageArray, + color_1_area: float, + color_2_area: float, + bw_color: RGB, + bw_color_area: float, + ) -> ImageArray: + """Select a second color that best complements the first. + + Chooses a color from the palette that, when mixed with color_1 and + the background, best approximates the target average color. + + Args: + avg_rgb: Target average color. + palette_rgb: Array of palette colors. + color_1: First selected color. + color_1_area: Area covered by color_1. + color_2_area: Area to be covered by color_2. + bw_color: Background black/white color. + bw_color_area: Area covered by background. + + Returns: + Best complementary color as numpy array. + """ + min_distance: float = float("inf") + best_color_2: ImageArray | None = None + color_1 = np.array(color_1) - bw_color = np.array(bw_color) - + bw_color_arr: ImageArray = np.array(bw_color) + for color in palette_rgb: if np.array_equal(color, color_1): continue - + color = np.array(color) - - mixed_rgb = (color_1_area * color_1 + color_2_area * color + bw_color_area * bw_color) / (color_1_area + color_2_area + bw_color_area) - - distance = np.linalg.norm(avg_rgb - mixed_rgb) - + + mixed_rgb: ImageArray = ( + color_1_area * color_1 + color_2_area * color + bw_color_area * bw_color_arr + ) / (color_1_area + color_2_area + bw_color_area) + + distance: float = np.linalg.norm(avg_rgb - mixed_rgb) + if distance < min_distance: min_distance = distance best_color_2 = color - + return best_color_2 @staticmethod - def clip_point_to_hexagon(point, hex_coords): + def clip_point_to_hexagon( + point: tuple[float, float], hex_coords: ImageArray + ) -> tuple[float, float]: + """Clip a point to be inside or on the hexagon boundary. + + Args: + point: Point coordinates (x, y). + hex_coords: Array of hexagon vertex coordinates. + + Returns: + Clipped point coordinates. + """ path = Path(hex_coords) if path.contains_point(point): return point - min_dist = float('inf') - nearest_point = point + min_dist: float = float("inf") + nearest_point: tuple[float, float] = point for i in range(len(hex_coords)): - p1 = hex_coords[i] - p2 = hex_coords[(i + 1) % len(hex_coords)] - nearest = HexagonProcessor.nearest_point_on_segment(point, p1, p2) - dist = np.linalg.norm(np.array(nearest) - np.array(point)) + p1: ImageArray = hex_coords[i] + p2: ImageArray = hex_coords[(i + 1) % len(hex_coords)] + nearest: tuple[float, float] = HexagonProcessor.nearest_point_on_segment( + point, p1, p2 + ) + dist: float = np.linalg.norm(np.array(nearest) - np.array(point)) if dist < min_dist: min_dist = dist nearest_point = nearest return nearest_point @staticmethod - def nearest_point_on_segment(point, p1, p2): + def nearest_point_on_segment( + point: tuple[float, float], + p1: ImageArray | tuple[float, float], + p2: ImageArray | tuple[float, float], + ) -> tuple[float, float]: + """Find the nearest point on a line segment to a given point. + + Args: + point: The point to find nearest segment point for. + p1: First endpoint of the segment. + p2: Second endpoint of the segment. + + Returns: + Nearest point on the segment as (x, y) tuple. + """ px, py = point x1, y1 = p1 x2, y2 = p2 dx, dy = x2 - x1, y2 - y1 - if dx == dy == 0: # the segment's start and end points are the same - return p1 + if dx == dy == 0: + return (float(x1), float(y1)) - t = ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy) + t: float = ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy) t = max(0, min(1, t)) - return (x1 + t * dx, y1 + t * dy) \ No newline at end of file + return (x1 + t * dx, y1 + t * dy) diff --git a/hexify.py b/hexify.py index 7c86313..12fb9e7 100644 --- a/hexify.py +++ b/hexify.py @@ -1,3 +1,16 @@ +"""Hexify - Transform images and videos into hexagonal pattern art. + +This module provides command-line functionality for processing images +and videos through the hexagonal pattern filter, creating stylized +artistic outputs with customizable color palettes. + +Example usage: + python hexify.py image.png --colors 16 --processes 4 + python hexify.py video.mp4 --colors 32 --save-frames +""" + +from __future__ import annotations + import cv2 import numpy as np import matplotlib.pyplot as plt @@ -5,66 +18,208 @@ import time import argparse import traceback +from typing import TypeAlias, Any, TYPE_CHECKING + from hexagon_processor import HexagonProcessor -from video_handlers import VideoReader, VideoWriter, downscale_video from tqdm import tqdm -def create_output_directory(input_path): - base_name = os.path.splitext(os.path.basename(input_path))[0] - output_dir = os.path.join(os.path.dirname(input_path), base_name) +# Type Aliases +ImageArray: TypeAlias = np.ndarray + +# Video handlers imported only when needed (module may be missing) +VideoReader: type | None = None +VideoWriter: type | None = None +downscale_video: Any = None + +# Supported file extensions +IMAGE_EXTENSIONS: tuple[str, ...] = (".png", ".jpg", ".jpeg", ".bmp", ".tiff") +VIDEO_EXTENSIONS: tuple[str, ...] = (".mp4", ".avi", ".mov", ".mkv") + + +def create_output_directory(input_path: str) -> tuple[str, str, str]: + """Create output directories for processed files. + + Creates a main output directory named after the input file, + along with subdirectories for frames and hexagons. + + Args: + input_path: Path to the input file. + + Returns: + Tuple of (output_dir, frames_dir, hexagons_dir) paths. + + Raises: + FileNotFoundError: If the input file does not exist. + OSError: If directories cannot be created. + """ + if not os.path.isfile(input_path): + raise FileNotFoundError(f"Input file does not exist: {input_path}") + + base_name: str = os.path.splitext(os.path.basename(input_path))[0] + parent_dir: str = os.path.dirname(input_path) or "." + output_dir: str = os.path.join(parent_dir, base_name) os.makedirs(output_dir, exist_ok=True) - frames_dir = os.path.join(output_dir, 'frames') + + frames_dir: str = os.path.join(output_dir, "frames") os.makedirs(frames_dir, exist_ok=True) - hexagons_dir = os.path.join(output_dir, 'hexagons') + + hexagons_dir: str = os.path.join(output_dir, "hexagons") os.makedirs(hexagons_dir, exist_ok=True) + return output_dir, frames_dir, hexagons_dir -def process_image(input_image_path, num_palette_colors, num_processes, chunk_size=32, save_hexagons=True): + +def validate_palette_colors(num_palette_colors: int) -> int: + """Validate and adjust palette color count. + + Args: + num_palette_colors: Requested number of palette colors. + + Returns: + Validated palette color count (minimum 5, maximum 256). + """ + if num_palette_colors < HexagonProcessor.MIN_PALETTE_COLORS: + print( + f"Warning: Palette size {num_palette_colors} is below minimum. " + f"Setting to {HexagonProcessor.MIN_PALETTE_COLORS}." + ) + return HexagonProcessor.MIN_PALETTE_COLORS + if num_palette_colors > HexagonProcessor.MAX_PALETTE_COLORS: + print( + f"Warning: Palette size {num_palette_colors} exceeds maximum. " + f"Setting to {HexagonProcessor.MAX_PALETTE_COLORS}." + ) + return HexagonProcessor.MAX_PALETTE_COLORS + return num_palette_colors + + +def process_image( + input_image_path: str, + num_palette_colors: int, + num_processes: int | None, + chunk_size: int = 32, + save_hexagons: bool = True, +) -> None: + """Process a single image through the hexagon filter. + + Loads an image, applies hexagonal pattern transformation with the + specified color palette, and saves the output. + + Args: + input_image_path: Path to the input image file. + num_palette_colors: Number of colors for the palette (5-256). + num_processes: Number of parallel processes. None for CPU count. + chunk_size: Number of hexagons to process per chunk. + save_hexagons: Whether to save individual hexagon images. + + Raises: + FileNotFoundError: If the input file does not exist. + ValueError: If the image cannot be read or is invalid. + """ if not os.path.isfile(input_image_path): print(f"Error: The file '{input_image_path}' does not exist.") return output_dir, frames_dir, hexagons_dir = create_output_directory(input_image_path) - - input_image = cv2.imread(input_image_path) + + input_image: ImageArray | None = cv2.imread(input_image_path) if input_image is None: - print(f"Error: Unable to read the image file '{input_image_path}'. Please check if it's a valid image file.") + print( + f"Error: Unable to read the image file '{input_image_path}'. " + "Please check if it's a valid image file." + ) + return + + # Validate image dimensions + height, width = input_image.shape[:2] + if height < HexagonProcessor.MIN_IMAGE_SIZE: + print( + f"Error: Image height ({height}px) is below minimum " + f"({HexagonProcessor.MIN_IMAGE_SIZE}px)." + ) + return + if width < HexagonProcessor.MIN_IMAGE_SIZE: + print( + f"Error: Image width ({width}px) is below minimum " + f"({HexagonProcessor.MIN_IMAGE_SIZE}px)." + ) return input_image = cv2.cvtColor(input_image, cv2.COLOR_BGR2RGB) - processor = HexagonProcessor(num_palette_colors, num_processes, hexagons_dir, chunk_size, save_hexagons) - + processor = HexagonProcessor( + num_palette_colors, num_processes, hexagons_dir, chunk_size, save_hexagons + ) + # Get the total number of hexagons processor.setup_hexagon_grid(input_image.shape) - total_hexagons = len(processor.hex_centers) - + total_hexagons: int = len(processor.hex_centers) + # Create a progress bar for image processing with tqdm(total=total_hexagons, desc="Processing image", unit="hexagon") as pbar: - output_image = processor.process_image(input_image, pbar) + output_image: ImageArray = processor.process_image(input_image, pbar) # Save palette with new naming convention - palette_image = np.zeros((64, 32 * num_palette_colors, 3), dtype=np.uint8) + palette_image: ImageArray = np.zeros( + (64, 32 * num_palette_colors, 3), dtype=np.uint8 + ) for i, color in enumerate(processor.palette): - palette_image[:, i * 32:(i + 1) * 32] = color - palette_filename = f"{processor.palette_hash[:6]}_palette.png" - palette_path = os.path.join(output_dir, palette_filename) + palette_image[:, i * 32 : (i + 1) * 32] = color + palette_filename: str = f"{processor.palette_hash[:6]}_palette.png" + palette_path: str = os.path.join(output_dir, palette_filename) plt.imsave(palette_path, palette_image) print(f"Palette saved to: {palette_path}") # Save output image - output_image_path = os.path.join(output_dir, 'output.png') + output_image_path: str = os.path.join(output_dir, "output.png") plt.imsave(output_image_path, output_image) print(f"Output image saved to: {output_image_path}") # Report cache hit rate and statistics - cache_hit_rate = processor.get_cache_hit_rate() + cache_hit_rate: float = processor.get_cache_hit_rate() print(f"Cache hit rate: {cache_hit_rate:.2%}") print(f"Total cache hits: {processor.cache_hits}") print(f"Total cache misses: {processor.cache_misses}") print(f"Final cache size: {len(processor.hexagon_cache)}") -def process_video(input_video_path, num_palette_colors, num_processes, chunk_size=32, save_hexagons=True, save_frames=True): + +def process_video( + input_video_path: str, + num_palette_colors: int, + num_processes: int | None, + chunk_size: int = 32, + save_hexagons: bool = True, + save_frames: bool = True, +) -> None: + """Process a video through the hexagon filter. + + Loads a video, applies hexagonal pattern transformation to each frame + with a consistent color palette, and saves the output video. + + Args: + input_video_path: Path to the input video file. + num_palette_colors: Number of colors for the palette (5-256). + num_processes: Number of parallel processes. None for CPU count. + chunk_size: Number of hexagons to process per chunk. + save_hexagons: Whether to save individual hexagon images. + save_frames: Whether to save individual processed frames. + + Raises: + FileNotFoundError: If the input file does not exist. + ImportError: If video_handlers module is not available. + """ + global VideoReader, VideoWriter, downscale_video + try: + from video_handlers import ( + VideoReader, + VideoWriter, + downscale_video, + ) + except ImportError: + print("Error: video_handlers module not found. Video processing is not available.") + print("Only image processing is supported with the current installation.") + return + if not os.path.isfile(input_video_path): print(f"Error: The file '{input_video_path}' does not exist.") return @@ -72,68 +227,87 @@ def process_video(input_video_path, num_palette_colors, num_processes, chunk_siz output_dir, frames_dir, hexagons_dir = create_output_directory(input_video_path) # Downscale video if necessary - downscaled_path = os.path.join(output_dir, 'downscaled.mp4') + downscaled_path: str = os.path.join(output_dir, "downscaled.mp4") input_video_path = downscale_video(input_video_path, downscaled_path) reader = VideoReader(input_video_path) - + print(f"Total frames to process: {reader.frame_count}") - + # Generate palette from sample frames - sample_frames = reader.get_frames(num_frames=10) - combined_image = np.concatenate(sample_frames, axis=1) - - processor = HexagonProcessor(num_palette_colors, num_processes, hexagons_dir, chunk_size, save_hexagons) + sample_frames: list[ImageArray] = reader.get_frames(num_frames=10) + combined_image: ImageArray = np.concatenate(sample_frames, axis=1) + + processor = HexagonProcessor( + num_palette_colors, num_processes, hexagons_dir, chunk_size, save_hexagons + ) processor.generate_palette(combined_image) # Save palette with new naming convention - palette_image = np.zeros((64, 32 * num_palette_colors, 3), dtype=np.uint8) + palette_image: ImageArray = np.zeros( + (64, 32 * num_palette_colors, 3), dtype=np.uint8 + ) for i, color in enumerate(processor.palette): - palette_image[:, i * 32:(i + 1) * 32] = color - palette_filename = f"{processor.palette_hash[:6]}_palette.png" - palette_path = os.path.join(output_dir, palette_filename) + palette_image[:, i * 32 : (i + 1) * 32] = color + palette_filename: str = f"{processor.palette_hash[:6]}_palette.png" + palette_path: str = os.path.join(output_dir, palette_filename) plt.imsave(palette_path, palette_image) print(f"Palette saved to: {palette_path}") # Process video - output_video_path = os.path.join(output_dir, 'output.mp4') - writer = VideoWriter(output_video_path, reader.fps, reader.width * 4, reader.height * 4) + output_video_path: str = os.path.join(output_dir, "output.mp4") + writer = VideoWriter( + output_video_path, reader.fps, reader.width * 4, reader.height * 4 + ) - failed_frames = [] + failed_frames: list[int] = [] # Setup hexagon grid to get total hexagons processor.setup_hexagon_grid((reader.height, reader.width)) - total_hexagons = len(processor.hex_centers) + total_hexagons: int = len(processor.hex_centers) try: - with tqdm(total=reader.frame_count, desc="Processing video frames", unit="frame") as frame_pbar: + with tqdm( + total=reader.frame_count, desc="Processing video frames", unit="frame" + ) as frame_pbar: for frame_number in range(1, reader.frame_count + 1): try: - frame = reader.read_frame() + frame: ImageArray | None = reader.read_frame() if frame is None: print(f"Failed to read frame {frame_number}") failed_frames.append(frame_number) continue - - with tqdm(total=total_hexagons, desc=f"Frame {frame_number}", unit="hexagon", leave=False) as hexagon_pbar: - processed_frame = processor.process_image(frame, hexagon_pbar) - + + with tqdm( + total=total_hexagons, + desc=f"Frame {frame_number}", + unit="hexagon", + leave=False, + ) as hexagon_pbar: + processed_frame: ImageArray = processor.process_image( + frame, hexagon_pbar + ) + if save_frames: # Save processed frame with palette hash in filename - frame_filename = f"{processor.palette_hash[:6]}_frame_{frame_number:06d}.png" - frame_path = os.path.join(frames_dir, frame_filename) + frame_filename: str = ( + f"{processor.palette_hash[:6]}_frame_{frame_number:06d}.png" + ) + frame_path: str = os.path.join(frames_dir, frame_filename) plt.imsave(frame_path, processed_frame) - - downscaled_frame = cv2.resize(processed_frame, (reader.width * 4, reader.height * 4)) + + downscaled_frame: ImageArray = cv2.resize( + processed_frame, (reader.width * 4, reader.height * 4) + ) writer.write_frame(downscaled_frame) - + frame_pbar.update(1) - + # Print cache hit rate every 10 frames if frame_number % 10 == 0: - cache_hit_rate = processor.get_cache_hit_rate() + cache_hit_rate: float = processor.get_cache_hit_rate() print(f"Current cache hit rate: {cache_hit_rate:.2%}") - + except Exception as e: print(f"Error processing frame {frame_number}: {str(e)}") print(traceback.format_exc()) @@ -148,46 +322,147 @@ def process_video(input_video_path, num_palette_colors, num_processes, chunk_siz print(f"Final frame count: {reader.frame_count}") if failed_frames: print(f"Failed frames: {failed_frames}") - + # Report final cache hit rate - final_cache_hit_rate = processor.get_cache_hit_rate() + final_cache_hit_rate: float = processor.get_cache_hit_rate() print(f"Final cache hit rate: {final_cache_hit_rate:.2%}") print(f"Total cache hits: {processor.cache_hits.value}") print(f"Total cache misses: {processor.cache_misses.value}") print(f"Final cache size: {len(processor.hexagon_cache)}") -def main(input_path, num_palette_colors=16, num_processes=None, chunk_size=32, save_hexagons=True, save_frames=True): - start_time = time.time() - if input_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.tiff')): - process_image(input_path, num_palette_colors, num_processes, chunk_size, save_hexagons) - elif input_path.lower().endswith(('.mp4', '.avi', '.mov', '.mkv')): - process_video(input_path, num_palette_colors, num_processes, chunk_size, save_hexagons, save_frames) +def main( + input_path: str, + num_palette_colors: int = 16, + num_processes: int | None = None, + chunk_size: int = 32, + save_hexagons: bool = True, + save_frames: bool = True, +) -> None: + """Main entry point for hexify processing. + + Determines the input file type and routes to the appropriate + processing function. + + Args: + input_path: Path to input image or video file. + num_palette_colors: Number of colors for the palette (5-256). + num_processes: Number of parallel processes. None for CPU count. + chunk_size: Number of hexagons to process per chunk. + save_hexagons: Whether to save individual hexagon images. + save_frames: Whether to save individual processed frames (video only). + + Raises: + ValueError: If the file format is not supported. + """ + start_time: float = time.time() + + # Validate input path exists + if not os.path.isfile(input_path): + print(f"Error: The file '{input_path}' does not exist.") + return + + if input_path.lower().endswith(IMAGE_EXTENSIONS): + process_image( + input_path, num_palette_colors, num_processes, chunk_size, save_hexagons + ) + elif input_path.lower().endswith(VIDEO_EXTENSIONS): + process_video( + input_path, + num_palette_colors, + num_processes, + chunk_size, + save_hexagons, + save_frames, + ) else: print(f"Error: Unsupported file format for '{input_path}'") + print(f"Supported image formats: {', '.join(IMAGE_EXTENSIONS)}") + print(f"Supported video formats: {', '.join(VIDEO_EXTENSIONS)}") return - end_time = time.time() - total_time = end_time - start_time + end_time: float = time.time() + total_time: float = end_time - start_time print(f"Total execution time: {total_time:.2f} seconds") + +def parse_arguments() -> argparse.Namespace: + """Parse command-line arguments. + + Returns: + Parsed arguments namespace. + """ + parser = argparse.ArgumentParser( + description="Generate hexagonal pattern from input image or video.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python hexify.py image.png Process with default settings + python hexify.py image.png -c 32 Use 32-color palette + python hexify.py video.mp4 -p 8 Use 8 parallel processes + python hexify.py image.png --save-hexagons Save individual hexagon images + """, + ) + parser.add_argument( + "input_path", help="Path to the input image or video file" + ) + parser.add_argument( + "-c", + "--colors", + type=int, + default=16, + help="Number of colors in the palette (default: 16, range: 5-256)", + ) + parser.add_argument( + "-p", + "--processes", + type=int, + default=None, + help="Number of processes to use (default: number of CPU cores)", + ) + parser.add_argument( + "--chunk-size", + type=int, + default=32, + help="Number of hexagons to process in each chunk (default: 32)", + ) + parser.add_argument( + "--save-hexagons", + action="store_true", + default=False, + help="Save individual hexagon images (default: False)", + ) + parser.add_argument( + "--save-frames", + action="store_true", + default=False, + help="Save individual processed frames from video (default: False)", + ) + + return parser.parse_args() + + if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Generate hexagonal pattern from input image or video.") - parser.add_argument("input_path", help="Path to the input image or video file") - parser.add_argument("-c", "--colors", type=int, default=16, help="Number of colors in the palette (default: 16)") - parser.add_argument("-p", "--processes", type=int, default=None, - help="Number of processes to use (default: number of CPU cores)") - parser.add_argument("--chunk-size", type=int, default=32, - help="Number of hexagons to process in each chunk (default: 32)") - parser.add_argument("--save-hexagons", action="store_true", default=False, - help="Save individual hexagon images (default: False)") - parser.add_argument("--save-frames", action="store_true", default=False, - help="Save individual processed frames from video (default: False)") - - args = parser.parse_args() - - if args.colors < 5: - print("Minimum color palette size must be at least 5. Setting it to 5.") - args.colors = 5 - - main(args.input_path, args.colors, args.processes, args.chunk_size, args.save_hexagons, args.save_frames) \ No newline at end of file + args = parse_arguments() + + # Validate and adjust palette colors + validated_colors: int = validate_palette_colors(args.colors) + + # Validate process count + if args.processes is not None and args.processes < 1: + print("Error: Number of processes must be at least 1.") + exit(1) + + # Validate chunk size + if args.chunk_size < 1: + print("Error: Chunk size must be at least 1.") + exit(1) + + main( + args.input_path, + validated_colors, + args.processes, + args.chunk_size, + args.save_hexagons, + args.save_frames, + ) diff --git a/py.typed b/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ca3541b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "hexify" +version = "2.1.0" +description = "Generate hexagonal pattern art from images" +readme = "README.md" +requires-python = ">=3.9" +license = {text = "MIT"} +dependencies = [ + "numpy>=1.20", + "opencv-python>=4.5", + "matplotlib>=3.5", + "scikit-learn>=1.0", + "tqdm>=4.60", +] + +[project.optional-dependencies] +dev = ["pytest", "pytest-benchmark", "pre-commit", "mypy", "ruff"] +yaml = ["pyyaml>=6.0"] + +[project.scripts] +hexify = "v2.cli:main" + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] +ignore = ["E501"] # Line length handled separately + +[tool.mypy] +python_version = "3.9" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..92b254e --- /dev/null +++ b/ruff.toml @@ -0,0 +1,16 @@ +# Standalone ruff configuration +# Note: These settings are also defined in pyproject.toml +# This file provides a standalone config for running ruff directly + +line-length = 100 +target-version = "py39" + +[lint] +select = ["E", "F", "I", "N", "W", "UP"] +ignore = ["E501"] # Line length handled separately + +[format] +# Use double quotes for strings +quote-style = "double" +# Indent with spaces +indent-style = "space" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3bf2a46 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Hexify test suite.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1fa379f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +"""Pytest configuration and shared fixtures for Hexify tests.""" +import sys +import os +import pytest +import numpy as np +import cv2 + +# Add parent directory to path to import hexify modules +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# Paths +TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +FIXTURES_DIR = os.path.join(TESTS_DIR, 'fixtures') +REFERENCE_DIR = os.path.join(TESTS_DIR, 'reference_outputs') + + +@pytest.fixture +def fixtures_dir(): + """Return the path to the fixtures directory.""" + return FIXTURES_DIR + + +@pytest.fixture +def reference_dir(): + """Return the path to the reference outputs directory.""" + return REFERENCE_DIR + + +@pytest.fixture +def gradient_64x64(): + """Load the 64x64 gradient fixture image.""" + path = os.path.join(FIXTURES_DIR, 'gradient_64x64.png') + img = cv2.imread(path) + if img is None: + pytest.skip(f"Fixture not found: {path}") + return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + +@pytest.fixture +def color_blocks_64x64(): + """Load the 64x64 color blocks fixture image.""" + path = os.path.join(FIXTURES_DIR, 'color_blocks_64x64.png') + img = cv2.imread(path) + if img is None: + pytest.skip(f"Fixture not found: {path}") + return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + +@pytest.fixture +def gradient_32x48(): + """Load the 32x48 gradient fixture image.""" + path = os.path.join(FIXTURES_DIR, 'gradient_32x48.png') + img = cv2.imread(path) + if img is None: + pytest.skip(f"Fixture not found: {path}") + return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + +@pytest.fixture +def fire_64x64(): + """Load the 64x64 fire fixture image.""" + path = os.path.join(FIXTURES_DIR, 'fire_64x64.png') + img = cv2.imread(path) + if img is None: + pytest.skip(f"Fixture not found: {path}") + return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) diff --git a/tests/create_fixtures.py b/tests/create_fixtures.py new file mode 100644 index 0000000..4ddfbce --- /dev/null +++ b/tests/create_fixtures.py @@ -0,0 +1,58 @@ +"""Create test fixture images for regression testing.""" +import numpy as np +import cv2 +import os + +FIXTURES_DIR = os.path.dirname(os.path.abspath(__file__)) + '/fixtures' + +def create_gradient_image(width=64, height=64): + """Create a horizontal gradient from black to white.""" + img = np.zeros((height, width, 3), dtype=np.uint8) + for x in range(width): + value = int(255 * x / (width - 1)) + img[:, x] = [value, value, value] + return img + +def create_color_blocks(width=64, height=64): + """Create a 2x2 grid of color blocks: red, green, blue, yellow.""" + img = np.zeros((height, width, 3), dtype=np.uint8) + h2, w2 = height // 2, width // 2 + img[0:h2, 0:w2] = [255, 0, 0] # Red (RGB) + img[0:h2, w2:] = [0, 255, 0] # Green + img[h2:, 0:w2] = [0, 0, 255] # Blue + img[h2:, w2:] = [255, 255, 0] # Yellow + return img + +def create_checkerboard(width=64, height=64, cell_size=8): + """Create a checkerboard pattern.""" + img = np.zeros((height, width, 3), dtype=np.uint8) + for y in range(height): + for x in range(width): + if ((x // cell_size) + (y // cell_size)) % 2 == 0: + img[y, x] = [255, 255, 255] + return img + +def create_solid_gray(width=64, height=64, value=128): + """Create a solid gray image.""" + img = np.full((height, width, 3), value, dtype=np.uint8) + return img + +def main(): + os.makedirs(FIXTURES_DIR, exist_ok=True) + + fixtures = { + 'gradient_64x64.png': create_gradient_image(64, 64), + 'color_blocks_64x64.png': create_color_blocks(64, 64), + 'checkerboard_64x64.png': create_checkerboard(64, 64), + 'solid_gray_64x64.png': create_solid_gray(64, 64, 128), + 'gradient_32x48.png': create_gradient_image(32, 48), # Non-square, test aspect ratio + } + + for name, img in fixtures.items(): + # Convert RGB to BGR for cv2.imwrite + path = os.path.join(FIXTURES_DIR, name) + cv2.imwrite(path, cv2.cvtColor(img, cv2.COLOR_RGB2BGR)) + print(f"Created: {path}") + +if __name__ == '__main__': + main() diff --git a/tests/fixtures/checkerboard_64x64.png b/tests/fixtures/checkerboard_64x64.png new file mode 100644 index 0000000..1bdfdd1 Binary files /dev/null and b/tests/fixtures/checkerboard_64x64.png differ diff --git a/tests/fixtures/color_blocks_64x64.png b/tests/fixtures/color_blocks_64x64.png new file mode 100644 index 0000000..3cdaa7c Binary files /dev/null and b/tests/fixtures/color_blocks_64x64.png differ diff --git a/tests/fixtures/fire_64x64.png b/tests/fixtures/fire_64x64.png new file mode 100644 index 0000000..19b3938 Binary files /dev/null and b/tests/fixtures/fire_64x64.png differ diff --git a/tests/fixtures/fire_64x64/166be1_palette.png b/tests/fixtures/fire_64x64/166be1_palette.png new file mode 100644 index 0000000..406b761 Binary files /dev/null and b/tests/fixtures/fire_64x64/166be1_palette.png differ diff --git a/tests/fixtures/fire_64x64/output.png b/tests/fixtures/fire_64x64/output.png new file mode 100644 index 0000000..ed8505c Binary files /dev/null and b/tests/fixtures/fire_64x64/output.png differ diff --git a/tests/fixtures/gradient_32x48.png b/tests/fixtures/gradient_32x48.png new file mode 100644 index 0000000..2bbcf56 Binary files /dev/null and b/tests/fixtures/gradient_32x48.png differ diff --git a/tests/fixtures/gradient_64x64.png b/tests/fixtures/gradient_64x64.png new file mode 100644 index 0000000..baaac7f Binary files /dev/null and b/tests/fixtures/gradient_64x64.png differ diff --git a/tests/fixtures/solid_gray_64x64.png b/tests/fixtures/solid_gray_64x64.png new file mode 100644 index 0000000..f181a2d Binary files /dev/null and b/tests/fixtures/solid_gray_64x64.png differ diff --git a/tests/generate_reference_outputs.py b/tests/generate_reference_outputs.py new file mode 100644 index 0000000..d39b9d3 --- /dev/null +++ b/tests/generate_reference_outputs.py @@ -0,0 +1,83 @@ +"""Generate reference outputs from the current v1 code for regression testing.""" +import sys +import os +import shutil + +# Add parent directory to path to import hexify modules +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from hexagon_processor import HexagonProcessor +import cv2 +import numpy as np + +FIXTURES_DIR = os.path.dirname(os.path.abspath(__file__)) + '/fixtures' +REFERENCE_DIR = os.path.dirname(os.path.abspath(__file__)) + '/reference_outputs' + +def generate_reference(input_path, output_name, num_colors=16): + """Generate a reference output for a test fixture.""" + print(f"Processing: {input_path} -> {output_name}") + + input_image = cv2.imread(input_path) + if input_image is None: + print(f" ERROR: Could not read {input_path}") + return False + + input_image = cv2.cvtColor(input_image, cv2.COLOR_BGR2RGB) + + processor = HexagonProcessor( + num_palette_colors=num_colors, + num_processes=1, # Single process for determinism + hexagons_dir=None, + chunk_size=32, + save_hexagons=False + ) + + output_image = processor.process_image(input_image) + + # Save output + output_path = os.path.join(REFERENCE_DIR, output_name) + cv2.imwrite(output_path, cv2.cvtColor(output_image, cv2.COLOR_RGB2BGR)) + print(f" Saved: {output_path}") + + # Also save the palette for reference + palette_path = os.path.join(REFERENCE_DIR, output_name.replace('.png', '_palette.npy')) + np.save(palette_path, processor.palette) + print(f" Palette: {palette_path}") + + return True + +def main(): + os.makedirs(REFERENCE_DIR, exist_ok=True) + + test_cases = [ + ('gradient_64x64.png', 'ref_gradient_64x64_c16.png', 16), + ('color_blocks_64x64.png', 'ref_color_blocks_64x64_c16.png', 16), + # NOTE: checkerboard and solid_gray crash due to bug in original code with low-color images + # ('checkerboard_64x64.png', 'ref_checkerboard_64x64_c5.png', 5), + # ('solid_gray_64x64.png', 'ref_solid_gray_64x64_c5.png', 5), + ('gradient_32x48.png', 'ref_gradient_32x48_c16.png', 16), + ('gradient_64x64.png', 'ref_gradient_64x64_c8.png', 8), # Test different palette size + ] + + # Also test with a real image if fire.png exists + fire_path = os.path.join(os.path.dirname(FIXTURES_DIR), '..', 'fire.png') + if os.path.exists(fire_path): + # Copy fire.png to fixtures and create a small version + import cv2 + fire_img = cv2.imread(fire_path) + if fire_img is not None: + # Create a small version for faster testing + small_fire = cv2.resize(fire_img, (64, 64)) + small_fire_path = os.path.join(FIXTURES_DIR, 'fire_64x64.png') + cv2.imwrite(small_fire_path, small_fire) + test_cases.append(('fire_64x64.png', 'ref_fire_64x64_c16.png', 16)) + + for fixture_name, output_name, num_colors in test_cases: + input_path = os.path.join(FIXTURES_DIR, fixture_name) + if os.path.exists(input_path): + generate_reference(input_path, output_name, num_colors) + else: + print(f"SKIP: {input_path} not found") + +if __name__ == '__main__': + main() diff --git a/tests/reference_outputs/ref_color_blocks_64x64_c16.png b/tests/reference_outputs/ref_color_blocks_64x64_c16.png new file mode 100644 index 0000000..af36465 Binary files /dev/null and b/tests/reference_outputs/ref_color_blocks_64x64_c16.png differ diff --git a/tests/reference_outputs/ref_color_blocks_64x64_c16_palette.npy b/tests/reference_outputs/ref_color_blocks_64x64_c16_palette.npy new file mode 100644 index 0000000..27d0826 Binary files /dev/null and b/tests/reference_outputs/ref_color_blocks_64x64_c16_palette.npy differ diff --git a/tests/reference_outputs/ref_fire_64x64_c16.png b/tests/reference_outputs/ref_fire_64x64_c16.png new file mode 100644 index 0000000..95941d3 Binary files /dev/null and b/tests/reference_outputs/ref_fire_64x64_c16.png differ diff --git a/tests/reference_outputs/ref_fire_64x64_c16_palette.npy b/tests/reference_outputs/ref_fire_64x64_c16_palette.npy new file mode 100644 index 0000000..fbcff57 Binary files /dev/null and b/tests/reference_outputs/ref_fire_64x64_c16_palette.npy differ diff --git a/tests/reference_outputs/ref_gradient_32x48_c16.png b/tests/reference_outputs/ref_gradient_32x48_c16.png new file mode 100644 index 0000000..920828d Binary files /dev/null and b/tests/reference_outputs/ref_gradient_32x48_c16.png differ diff --git a/tests/reference_outputs/ref_gradient_32x48_c16_palette.npy b/tests/reference_outputs/ref_gradient_32x48_c16_palette.npy new file mode 100644 index 0000000..2a08c84 Binary files /dev/null and b/tests/reference_outputs/ref_gradient_32x48_c16_palette.npy differ diff --git a/tests/reference_outputs/ref_gradient_64x64_c16.png b/tests/reference_outputs/ref_gradient_64x64_c16.png new file mode 100644 index 0000000..54a60e4 Binary files /dev/null and b/tests/reference_outputs/ref_gradient_64x64_c16.png differ diff --git a/tests/reference_outputs/ref_gradient_64x64_c16_palette.npy b/tests/reference_outputs/ref_gradient_64x64_c16_palette.npy new file mode 100644 index 0000000..3e5d42c Binary files /dev/null and b/tests/reference_outputs/ref_gradient_64x64_c16_palette.npy differ diff --git a/tests/reference_outputs/ref_gradient_64x64_c8.png b/tests/reference_outputs/ref_gradient_64x64_c8.png new file mode 100644 index 0000000..fc1d4c4 Binary files /dev/null and b/tests/reference_outputs/ref_gradient_64x64_c8.png differ diff --git a/tests/reference_outputs/ref_gradient_64x64_c8_palette.npy b/tests/reference_outputs/ref_gradient_64x64_c8_palette.npy new file mode 100644 index 0000000..cf72bac Binary files /dev/null and b/tests/reference_outputs/ref_gradient_64x64_c8_palette.npy differ diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4a32adb --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,574 @@ +"""Tests for the Hexify CLI.""" + +import argparse +import logging +import os +import sys +import tempfile +from pathlib import Path +from unittest import mock + +import numpy as np +import pytest + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Check if PyYAML is available +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +from v2.cli import ( + PRESETS, + IMAGE_EXTENSIONS, + create_parser, + parse_border_color, + get_default_output_path, + discover_images, + load_settings, + setup_logging, + print_presets, + main, +) +from v2.settings import HexifySettings, QuantizationMethod, HexOrientation + + +class TestArgumentParser: + """Test argument parsing.""" + + def test_create_parser_returns_parser(self): + """create_parser should return an ArgumentParser instance.""" + parser = create_parser() + assert isinstance(parser, argparse.ArgumentParser) + + def test_parser_input_optional_for_info_commands(self): + """Parser should allow no input for info commands.""" + parser = create_parser() + # This should not raise an error + args = parser.parse_args(["--list-presets"]) + assert args.list_presets is True + assert args.input is None + + def test_parser_accepts_input(self): + """Parser should accept an input path.""" + parser = create_parser() + args = parser.parse_args(["input.png"]) + assert args.input == "input.png" + + def test_parser_output_option(self): + """Parser should accept -o/--output option.""" + parser = create_parser() + + args = parser.parse_args(["input.png", "-o", "output.png"]) + assert args.output == "output.png" + + args = parser.parse_args(["input.png", "--output", "out.png"]) + assert args.output == "out.png" + + def test_parser_colors_option(self): + """Parser should accept -c/--colors option.""" + parser = create_parser() + + args = parser.parse_args(["input.png", "-c", "24"]) + assert args.colors == 24 + + args = parser.parse_args(["input.png", "--colors", "32"]) + assert args.colors == 32 + + def test_parser_jobs_option(self): + """Parser should accept -j/--jobs option.""" + parser = create_parser() + + args = parser.parse_args(["input.png", "-j", "4"]) + assert args.jobs == 4 + + args = parser.parse_args(["input.png", "--jobs", "8"]) + assert args.jobs == 8 + + def test_parser_preset_option(self): + """Parser should accept --preset option.""" + parser = create_parser() + + for preset in ["default", "fast", "detailed", "minimal"]: + args = parser.parse_args(["input.png", "--preset", preset]) + assert args.preset == preset + + def test_parser_preset_invalid(self): + """Parser should reject invalid preset names.""" + parser = create_parser() + with pytest.raises(SystemExit): + parser.parse_args(["input.png", "--preset", "invalid"]) + + def test_parser_format_option(self): + """Parser should accept --format option.""" + parser = create_parser() + + args = parser.parse_args(["input.png", "--format", "png"]) + assert args.format == "png" + + args = parser.parse_args(["input.png", "--format", "jpg"]) + assert args.format == "jpg" + + def test_parser_format_invalid(self): + """Parser should reject invalid format.""" + parser = create_parser() + with pytest.raises(SystemExit): + parser.parse_args(["input.png", "--format", "gif"]) + + def test_parser_batch_options(self): + """Parser should accept batch processing options.""" + parser = create_parser() + + args = parser.parse_args(["input/", "--batch"]) + assert args.batch is True + + args = parser.parse_args(["input/", "--recursive"]) + assert args.recursive is True + + args = parser.parse_args(["input/", "-r"]) + assert args.recursive is True + + args = parser.parse_args(["input/", "--pattern", "*.png"]) + assert args.pattern == "*.png" + + def test_parser_style_options(self): + """Parser should accept style options.""" + parser = create_parser() + + args = parser.parse_args(["input.png", "--layers", "5"]) + assert args.layers == 5 + + args = parser.parse_args(["input.png", "--zones", "18"]) + assert args.zones == 18 + + args = parser.parse_args(["input.png", "--border-width", "2"]) + assert args.border_width == 2 + + args = parser.parse_args(["input.png", "--border-color", "255,255,255"]) + assert args.border_color == "255,255,255" + + args = parser.parse_args(["input.png", "--orientation", "pointy_top"]) + assert args.orientation == "pointy_top" + + def test_parser_output_control_options(self): + """Parser should accept output control options.""" + parser = create_parser() + + args = parser.parse_args(["input.png", "-v"]) + assert args.verbose is True + + args = parser.parse_args(["input.png", "--verbose"]) + assert args.verbose is True + + args = parser.parse_args(["input.png", "-q"]) + assert args.quiet is True + + args = parser.parse_args(["input.png", "--quiet"]) + assert args.quiet is True + + args = parser.parse_args(["input.png", "--no-progress"]) + assert args.no_progress is True + + def test_parser_config_option(self): + """Parser should accept --config option.""" + parser = create_parser() + + args = parser.parse_args(["input.png", "--config", "settings.yaml"]) + assert args.config == Path("settings.yaml") + + def test_parser_save_config_option(self): + """Parser should accept --save-config option.""" + parser = create_parser() + + args = parser.parse_args(["input.png", "--save-config", "output.yaml"]) + assert args.save_config == Path("output.yaml") + + def test_parser_list_presets_option(self): + """Parser should accept --list-presets option.""" + parser = create_parser() + + args = parser.parse_args(["input.png", "--list-presets"]) + assert args.list_presets is True + + def test_parser_defaults(self): + """Parser should have correct defaults.""" + parser = create_parser() + args = parser.parse_args(["input.png"]) + + assert args.output is None + assert args.format == "png" + assert args.quality == 95 + assert args.colors is None + assert args.jobs is None + assert args.chunk_size is None + assert args.preset == "default" + assert args.config is None + assert args.batch is False + assert args.recursive is False + assert args.verbose is False + assert args.quiet is False + assert args.no_progress is False + + +class TestBorderColorParsing: + """Test border color parsing.""" + + def test_parse_valid_color(self): + """parse_border_color should parse valid color strings.""" + assert parse_border_color("0,0,0") == (0, 0, 0) + assert parse_border_color("255,255,255") == (255, 255, 255) + assert parse_border_color("128,64,32") == (128, 64, 32) + + def test_parse_color_with_spaces(self): + """parse_border_color should handle spaces.""" + assert parse_border_color("0, 0, 0") == (0, 0, 0) + assert parse_border_color(" 255 , 128 , 64 ") == (255, 128, 64) + + def test_parse_invalid_color_too_few_components(self): + """parse_border_color should reject too few components.""" + with pytest.raises(ValueError): + parse_border_color("255,255") + + def test_parse_invalid_color_too_many_components(self): + """parse_border_color should reject too many components.""" + with pytest.raises(ValueError): + parse_border_color("255,255,255,255") + + def test_parse_invalid_color_out_of_range(self): + """parse_border_color should reject values out of range.""" + with pytest.raises(ValueError): + parse_border_color("256,0,0") + with pytest.raises(ValueError): + parse_border_color("-1,0,0") + + def test_parse_invalid_color_non_numeric(self): + """parse_border_color should reject non-numeric values.""" + with pytest.raises(ValueError): + parse_border_color("red,green,blue") + + +class TestDefaultOutputPath: + """Test default output path generation.""" + + def test_default_output_png(self): + """get_default_output_path should add _hexified suffix for png.""" + result = get_default_output_path(Path("/path/to/image.png"), "png") + assert result == Path("/path/to/image_hexified.png") + + def test_default_output_jpg(self): + """get_default_output_path should use correct format extension.""" + result = get_default_output_path(Path("/path/to/image.png"), "jpg") + assert result == Path("/path/to/image_hexified.jpg") + + def test_default_output_preserves_directory(self): + """get_default_output_path should preserve parent directory.""" + result = get_default_output_path(Path("/deep/nested/path/input.jpg"), "png") + assert result.parent == Path("/deep/nested/path") + + def test_default_output_handles_dots_in_name(self): + """get_default_output_path should handle files with dots in name.""" + result = get_default_output_path(Path("image.v2.final.png"), "png") + assert result == Path("image.v2.final_hexified.png") + + +class TestImageDiscovery: + """Test image file discovery for batch mode.""" + + @pytest.fixture + def temp_dir_with_images(self, tmp_path): + """Create a temporary directory with test images.""" + # Create some test files + (tmp_path / "image1.png").touch() + (tmp_path / "image2.jpg").touch() + (tmp_path / "image3.jpeg").touch() + (tmp_path / "document.txt").touch() + (tmp_path / "data.json").touch() + + # Create subdirectory with images + subdir = tmp_path / "subdir" + subdir.mkdir() + (subdir / "nested.png").touch() + (subdir / "nested.bmp").touch() + + return tmp_path + + def test_discover_single_file(self, temp_dir_with_images): + """discover_images should return single file if valid image.""" + image_path = temp_dir_with_images / "image1.png" + result = discover_images(image_path) + assert result == [image_path] + + def test_discover_single_file_non_image(self, temp_dir_with_images): + """discover_images should return empty for non-image files.""" + text_path = temp_dir_with_images / "document.txt" + result = discover_images(text_path) + assert result == [] + + def test_discover_directory_non_recursive(self, temp_dir_with_images): + """discover_images should find images in directory (non-recursive).""" + result = discover_images(temp_dir_with_images, recursive=False) + assert len(result) == 3 # image1.png, image2.jpg, image3.jpeg + + def test_discover_directory_recursive(self, temp_dir_with_images): + """discover_images should find images in subdirectories when recursive.""" + result = discover_images(temp_dir_with_images, recursive=True) + assert len(result) == 5 # 3 in root + 2 in subdir + + def test_discover_with_pattern(self, temp_dir_with_images): + """discover_images should filter by pattern.""" + result = discover_images(temp_dir_with_images, pattern="*.png") + assert len(result) == 1 + assert result[0].name == "image1.png" + + def test_discover_with_pattern_recursive(self, temp_dir_with_images): + """discover_images should apply pattern recursively.""" + result = discover_images(temp_dir_with_images, recursive=True, pattern="*.png") + assert len(result) == 2 # image1.png and nested.png + + def test_discover_returns_sorted(self, temp_dir_with_images): + """discover_images should return sorted list.""" + result = discover_images(temp_dir_with_images) + assert result == sorted(result) + + +class TestPresetLoading: + """Test preset configuration loading.""" + + def test_all_presets_exist(self): + """All expected presets should be defined.""" + expected = {"default", "fast", "detailed", "minimal"} + assert set(PRESETS.keys()) == expected + + def test_preset_has_required_keys(self): + """Each preset should have the required configuration keys.""" + for name, config in PRESETS.items(): + assert "num_palette_colors" in config, f"Preset {name} missing num_palette_colors" + + def test_load_settings_with_preset(self, tmp_path): + """load_settings should apply preset values.""" + logger = setup_logging(quiet=True) + + args = argparse.Namespace( + preset="fast", + config=None, + colors=None, + layers=None, + zones=None, + chunk_size=None, + border_width=None, + border_color=None, + orientation=None, + ) + + settings = load_settings(args, logger) + assert settings.num_palette_colors == 8 # fast preset value + assert settings.quantization_method == QuantizationMethod.MINIBATCH_KMEANS + + def test_load_settings_cli_overrides_preset(self, tmp_path): + """CLI arguments should override preset values.""" + logger = setup_logging(quiet=True) + + args = argparse.Namespace( + preset="fast", + config=None, + colors=24, # Override the preset's 8 + layers=None, + zones=None, + chunk_size=None, + border_width=None, + border_color=None, + orientation=None, + ) + + settings = load_settings(args, logger) + assert settings.num_palette_colors == 24 # CLI value wins + + @pytest.mark.skipif(not HAS_YAML, reason="PyYAML not installed") + def test_load_settings_from_yaml(self, tmp_path): + """load_settings should load from YAML file.""" + # Create a test YAML config + config_path = tmp_path / "test_config.yaml" + config_path.write_text(""" +num_palette_colors: 20 +num_layers: 6 +border_width: 3 +""") + + logger = setup_logging(quiet=True) + + args = argparse.Namespace( + preset="default", + config=config_path, + colors=None, + layers=None, + zones=None, + chunk_size=None, + border_width=None, + border_color=None, + orientation=None, + ) + + settings = load_settings(args, logger) + assert settings.num_palette_colors == 20 + assert settings.num_layers == 6 + assert settings.border_width == 3 + + def test_load_settings_from_json(self, tmp_path): + """load_settings should load from JSON file.""" + import json + + config_path = tmp_path / "test_config.json" + with open(config_path, "w") as f: + json.dump({"num_palette_colors": 18, "num_zones": 15}, f) + + logger = setup_logging(quiet=True) + + args = argparse.Namespace( + preset="default", + config=config_path, + colors=None, + layers=None, + zones=None, + chunk_size=None, + border_width=None, + border_color=None, + orientation=None, + ) + + settings = load_settings(args, logger) + assert settings.num_palette_colors == 18 + assert settings.num_zones == 15 + + @pytest.mark.skipif(not HAS_YAML, reason="PyYAML not installed") + def test_load_settings_cli_overrides_config(self, tmp_path): + """CLI arguments should override config file values.""" + config_path = tmp_path / "test_config.yaml" + config_path.write_text("num_palette_colors: 20") + + logger = setup_logging(quiet=True) + + args = argparse.Namespace( + preset="default", + config=config_path, + colors=30, # Override config file's 20 + layers=None, + zones=None, + chunk_size=None, + border_width=None, + border_color=None, + orientation=None, + ) + + settings = load_settings(args, logger) + assert settings.num_palette_colors == 30 # CLI wins + + def test_load_settings_missing_config_file(self, tmp_path): + """load_settings should raise error for missing config file.""" + logger = setup_logging(quiet=True) + + args = argparse.Namespace( + preset="default", + config=Path("/nonexistent/config.yaml"), + colors=None, + layers=None, + zones=None, + chunk_size=None, + border_width=None, + border_color=None, + orientation=None, + ) + + with pytest.raises(FileNotFoundError): + load_settings(args, logger) + + +class TestLogging: + """Test logging setup.""" + + def test_setup_logging_default(self): + """setup_logging should create INFO level logger by default.""" + logger = setup_logging() + assert logger.level == logging.INFO + + def test_setup_logging_verbose(self): + """setup_logging with verbose=True should create DEBUG level logger.""" + logger = setup_logging(verbose=True) + assert logger.level == logging.DEBUG + + def test_setup_logging_quiet(self): + """setup_logging with quiet=True should create ERROR level logger.""" + logger = setup_logging(quiet=True) + assert logger.level == logging.ERROR + + +class TestMainFunction: + """Test the main CLI entry point.""" + + def test_main_list_presets(self, capsys): + """main --list-presets should print presets and exit.""" + result = main(["input.png", "--list-presets"]) + assert result == 0 + + captured = capsys.readouterr() + assert "default" in captured.out + assert "fast" in captured.out + assert "detailed" in captured.out + assert "minimal" in captured.out + + def test_main_input_not_found(self): + """main should return error for nonexistent input.""" + result = main(["/nonexistent/image.png"]) + assert result == 1 + + @pytest.mark.skipif(not HAS_YAML, reason="PyYAML not installed") + def test_main_save_config(self, tmp_path): + """main --save-config should save settings and exit.""" + config_path = tmp_path / "saved_config.yaml" + + # Need a valid "input" even though we're just saving config + # Use a real fixture path + result = main([ + "dummy.png", # Won't be processed + "-c", "24", + "--layers", "6", + "--save-config", str(config_path), + ]) + + assert result == 0 + assert config_path.exists() + + # Verify saved settings + settings = HexifySettings.from_yaml(str(config_path)) + assert settings.num_palette_colors == 24 + assert settings.num_layers == 6 + + +class TestImageExtensions: + """Test supported image extensions.""" + + def test_common_extensions_supported(self): + """Common image extensions should be supported.""" + expected = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp"} + assert expected.issubset(IMAGE_EXTENSIONS) + + def test_extensions_are_lowercase(self): + """All extensions should be lowercase.""" + for ext in IMAGE_EXTENSIONS: + assert ext == ext.lower() + assert ext.startswith(".") + + +class TestPrintPresets: + """Test preset printing.""" + + def test_print_presets_output(self, capsys): + """print_presets should output preset information.""" + print_presets() + + captured = capsys.readouterr() + assert "Available Presets" in captured.out + for preset_name in PRESETS: + assert preset_name in captured.out diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..1f65bab --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,125 @@ +""" +Tests for custom exceptions and input validation. +""" + +import pytest +import numpy as np + +from v2 import HexagonProcessor +from v2.exceptions import ( + HexifyError, + InvalidImageError, + PaletteError, + PaletteNotGeneratedError, + InsufficientColorsError, + GridError, + GridNotSetupError, +) + + +class TestExceptionHierarchy: + """Test that exceptions have the correct hierarchy.""" + + def test_invalid_image_error_is_hexify_error(self): + assert issubclass(InvalidImageError, HexifyError) + + def test_palette_error_is_hexify_error(self): + assert issubclass(PaletteError, HexifyError) + + def test_palette_not_generated_is_palette_error(self): + assert issubclass(PaletteNotGeneratedError, PaletteError) + + def test_insufficient_colors_is_palette_error(self): + assert issubclass(InsufficientColorsError, PaletteError) + + def test_grid_error_is_hexify_error(self): + assert issubclass(GridError, HexifyError) + + def test_grid_not_setup_is_grid_error(self): + assert issubclass(GridNotSetupError, GridError) + + +class TestInvalidImageErrorAttributes: + """Test InvalidImageError stores shape information.""" + + def test_stores_shape(self): + err = InvalidImageError("test message", shape=(100, 100, 3)) + assert err.shape == (100, 100, 3) + + def test_shape_can_be_none(self): + err = InvalidImageError("test message", shape=None) + assert err.shape is None + + +class TestInsufficientColorsErrorAttributes: + """Test InsufficientColorsError stores count information.""" + + def test_stores_required_and_available(self): + err = InsufficientColorsError(required=16, available=4) + assert err.required == 16 + assert err.available == 4 + assert "16" in str(err) + assert "4" in str(err) + + +class TestInputValidation: + """Test that process_image validates input correctly.""" + + def test_rejects_non_array(self): + processor = HexagonProcessor(num_palette_colors=4) + with pytest.raises(InvalidImageError) as excinfo: + processor.process_image("not an array") + assert "numpy array" in str(excinfo.value) + + def test_rejects_2d_array(self): + processor = HexagonProcessor(num_palette_colors=4) + image = np.zeros((100, 100), dtype=np.uint8) + with pytest.raises(InvalidImageError) as excinfo: + processor.process_image(image) + assert "3D array" in str(excinfo.value) + + def test_rejects_4d_array(self): + processor = HexagonProcessor(num_palette_colors=4) + image = np.zeros((1, 100, 100, 3), dtype=np.uint8) + with pytest.raises(InvalidImageError) as excinfo: + processor.process_image(image) + assert "3D array" in str(excinfo.value) + + def test_rejects_4_channels(self): + processor = HexagonProcessor(num_palette_colors=4) + image = np.zeros((100, 100, 4), dtype=np.uint8) + with pytest.raises(InvalidImageError) as excinfo: + processor.process_image(image) + assert "3 channels" in str(excinfo.value) + + def test_rejects_1_channel(self): + processor = HexagonProcessor(num_palette_colors=4) + image = np.zeros((100, 100, 1), dtype=np.uint8) + with pytest.raises(InvalidImageError) as excinfo: + processor.process_image(image) + assert "3 channels" in str(excinfo.value) + + def test_accepts_valid_rgb_image(self): + processor = HexagonProcessor(num_palette_colors=4) + image = np.random.randint(0, 256, (32, 32, 3), dtype=np.uint8) + # Should not raise + result = processor.process_image(image) + assert result is not None + assert result.ndim == 3 + + +class TestCatchingByBaseClass: + """Test that exceptions can be caught by base class.""" + + def test_catch_invalid_image_as_hexify_error(self): + processor = HexagonProcessor(num_palette_colors=4) + with pytest.raises(HexifyError): + processor.process_image("not an array") + + def test_catch_palette_not_generated_as_palette_error(self): + with pytest.raises(PaletteError): + raise PaletteNotGeneratedError() + + def test_catch_grid_not_setup_as_grid_error(self): + with pytest.raises(GridError): + raise GridNotSetupError() diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 0000000..4fdd186 --- /dev/null +++ b/tests/test_geometry.py @@ -0,0 +1,550 @@ +"""Unit tests for hexagon geometry calculations in Hexify. + +Tests cover: +- Hexagon mask creation +- Grid center calculations +- Coordinate transformations +- Point clipping to hexagon boundaries +""" +import os +import sys +import pytest +import numpy as np +import math +from matplotlib.patches import RegularPolygon +from matplotlib.path import Path + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from hexagon_processor import HexagonProcessor + + +# ============================================================================= +# Hexagon Mask Creation Tests +# ============================================================================= + +class TestHexagonMaskCreation: + """Tests for the create_hex_mask static method.""" + + def test_mask_shape_matches_input_shape(self): + """Mask should have the same shape as the specified shape parameter.""" + shapes = [(100, 100), (64, 128), (200, 150)] + + for shape in shapes: + mask = HexagonProcessor.create_hex_mask( + center_x=50, center_y=50, radius=20, shape=shape + ) + assert mask.shape == shape, \ + f"Expected mask shape {shape}, got {mask.shape}" + + def test_mask_dtype_is_uint8(self): + """Mask should be uint8 type for use with OpenCV.""" + mask = HexagonProcessor.create_hex_mask( + center_x=50, center_y=50, radius=20, shape=(100, 100) + ) + assert mask.dtype == np.uint8 + + def test_mask_values_are_binary(self): + """Mask should only contain 0 and 255 values.""" + mask = HexagonProcessor.create_hex_mask( + center_x=50, center_y=50, radius=20, shape=(100, 100) + ) + unique_values = np.unique(mask) + assert all(v in [0, 255] for v in unique_values), \ + f"Mask contains unexpected values: {unique_values}" + + def test_mask_center_is_filled(self): + """The center of the hexagon should be filled (255).""" + mask = HexagonProcessor.create_hex_mask( + center_x=50, center_y=50, radius=20, shape=(100, 100) + ) + # The center point should be inside the hexagon + assert mask[50, 50] == 255, "Center should be filled" + + def test_mask_corners_are_empty(self): + """Corners of the bounding box should be outside the hexagon.""" + center_x, center_y, radius = 50, 50, 20 + mask = HexagonProcessor.create_hex_mask( + center_x=center_x, center_y=center_y, radius=radius, shape=(100, 100) + ) + # Check that points far from center are outside + # Note: hexagon is oriented with pi/2, so corners of bounding box are outside + assert mask[0, 0] == 0, "Top-left corner should be empty" + assert mask[0, 99] == 0, "Top-right corner should be empty" + assert mask[99, 0] == 0, "Bottom-left corner should be empty" + assert mask[99, 99] == 0, "Bottom-right corner should be empty" + + def test_mask_has_approximately_correct_area(self): + """Hexagon mask area should be approximately 3*sqrt(3)/2 * r^2.""" + radius = 30 + expected_area = 3 * math.sqrt(3) / 2 * (radius ** 2) + + mask = HexagonProcessor.create_hex_mask( + center_x=50, center_y=50, radius=radius, shape=(100, 100) + ) + actual_area = np.sum(mask == 255) + + # Allow 10% tolerance for discretization effects + tolerance = 0.10 + assert abs(actual_area - expected_area) / expected_area < tolerance, \ + f"Expected area ~{expected_area:.1f}, got {actual_area}" + + def test_mask_clipping_at_boundaries(self): + """Mask should be clipped when hexagon extends beyond image boundaries.""" + # Center near edge + mask = HexagonProcessor.create_hex_mask( + center_x=5, center_y=5, radius=20, shape=(50, 50) + ) + + # Should still have valid shape and not raise errors + assert mask.shape == (50, 50) + # Should have some filled pixels + assert np.sum(mask == 255) > 0 + + def test_mask_fully_outside_image(self): + """Mask with center far outside image should have minimal or no fill.""" + mask = HexagonProcessor.create_hex_mask( + center_x=-100, center_y=-100, radius=20, shape=(50, 50) + ) + + # Most or all pixels should be empty + assert mask.shape == (50, 50) + + def test_different_radii(self): + """Larger radius should produce larger mask area.""" + areas = [] + for radius in [10, 20, 30, 40]: + mask = HexagonProcessor.create_hex_mask( + center_x=100, center_y=100, radius=radius, shape=(200, 200) + ) + areas.append(np.sum(mask == 255)) + + # Each radius should produce a larger area + for i in range(len(areas) - 1): + assert areas[i] < areas[i + 1], \ + f"Radius {[10, 20, 30, 40][i + 1]} should have larger area than {[10, 20, 30, 40][i]}" + + +# ============================================================================= +# Grid Center Calculations Tests +# ============================================================================= + +class TestGridCenterCalculations: + """Tests for hexagon grid center calculations in setup_hexagon_grid.""" + + def test_grid_setup_creates_centers(self): + """setup_hexagon_grid should create a list of center coordinates.""" + processor = HexagonProcessor(num_processes=1) + processor.setup_hexagon_grid((64, 64, 3)) + + assert processor.hex_centers is not None + assert len(processor.hex_centers) > 0 + + def test_centers_are_tuples(self): + """Each center should be a tuple of (x, y) coordinates.""" + processor = HexagonProcessor(num_processes=1) + processor.setup_hexagon_grid((64, 64, 3)) + + for center in processor.hex_centers: + assert isinstance(center, tuple) + assert len(center) == 2 + assert isinstance(center[0], int) + assert isinstance(center[1], int) + + def test_output_shape_is_16x_input(self): + """Output shape should be 16x the input dimensions.""" + processor = HexagonProcessor(num_processes=1) + + for input_shape in [(32, 64, 3), (64, 64, 3), (100, 50, 3)]: + processor.setup_hexagon_grid(input_shape) + + assert processor.output_shape[0] == input_shape[0] * 16 + assert processor.output_shape[1] == input_shape[1] * 16 + assert processor.output_shape[2] == 3 + + def test_hex_dimensions_are_correct(self): + """Hexagon width and height should follow geometric relationships.""" + processor = HexagonProcessor(num_processes=1) + processor.setup_hexagon_grid((64, 64, 3)) + + assert processor.hex_width == 256 + assert processor.hex_radius == 128 + # Height should be width * sqrt(3)/2 + expected_height = round(256 * (math.sqrt(3) / 2)) + assert processor.hex_height == expected_height + + def test_centers_cover_output_area(self): + """Centers should cover the entire output area with some overlap.""" + processor = HexagonProcessor(num_processes=1) + processor.setup_hexagon_grid((64, 64, 3)) + + x_coords = [c[0] for c in processor.hex_centers] + y_coords = [c[1] for c in processor.hex_centers] + + # Centers should start near origin and extend past the output dimensions + # (to ensure edge coverage) + assert min(x_coords) <= processor.hex_radius + assert min(y_coords) <= processor.hex_radius + assert max(x_coords) >= processor.output_shape[1] - processor.hex_radius + assert max(y_coords) >= processor.output_shape[0] - processor.hex_radius + + def test_staggered_grid_pattern(self): + """Odd columns should have vertically offset centers (staggered grid).""" + processor = HexagonProcessor(num_processes=1) + processor.setup_hexagon_grid((64, 64, 3)) + + # Group centers by column (x coordinate) + columns = {} + for x, y in processor.hex_centers: + if x not in columns: + columns[x] = [] + columns[x].append(y) + + # Sort columns by x coordinate + sorted_x = sorted(columns.keys()) + + # For at least some adjacent columns, check the staggering pattern + if len(sorted_x) >= 2: + # Calculate column indices based on hex spacing + hex_horizontal_spacing = processor.hex_width * 0.75 + + for i, x in enumerate(sorted_x): + col_index = int(round(x / hex_horizontal_spacing)) + # Odd columns should have different y offsets than even columns + # This is verified by checking that not all columns have same y values + # (more comprehensive check would require detailed analysis) + + +# ============================================================================= +# Coordinate Transformation Tests +# ============================================================================= + +class TestCoordinateTransformations: + """Tests for coordinate scaling and transformation.""" + + def test_input_to_output_scaling(self): + """Verify 16x scaling from input to output coordinates.""" + processor = HexagonProcessor(num_processes=1) + processor.setup_hexagon_grid((64, 64, 3)) + + # For each hex center, the corresponding input coordinate should be 1/16 + for center_x, center_y in processor.hex_centers: + input_x = center_x // 16 + input_y = center_y // 16 + + # Input coordinates should be within input image bounds (with some margin) + assert input_x >= -10 # Some margin for edge hexagons + assert input_y >= -10 + + def test_radius_scaling(self): + """Verify radius scaling from output to input coordinates.""" + processor = HexagonProcessor(num_processes=1) + processor.setup_hexagon_grid((64, 64, 3)) + + output_radius = processor.hex_radius + input_radius = output_radius // 16 + + assert output_radius == 128 + assert input_radius == 8 + + +# ============================================================================= +# Point Clipping Tests +# ============================================================================= + +class TestPointClipping: + """Tests for clip_point_to_hexagon and nearest_point_on_segment methods.""" + + def test_point_inside_hexagon_unchanged(self): + """Points inside hexagon should not be modified.""" + # Create hexagon vertices + radius = 50 + center = (100, 100) + hexagon = RegularPolygon(center, numVertices=6, radius=radius, orientation=np.pi / 2) + coords = hexagon.get_verts().astype(int) + + # Test center point + result = HexagonProcessor.clip_point_to_hexagon(center, coords) + assert result == center + + # Test point slightly off center but still inside + inside_point = (100, 110) + result = HexagonProcessor.clip_point_to_hexagon(inside_point, coords) + assert result == inside_point + + def test_point_outside_hexagon_clipped(self): + """Points outside hexagon should be clipped to boundary.""" + radius = 50 + center = (100, 100) + hexagon = RegularPolygon(center, numVertices=6, radius=radius, orientation=np.pi / 2) + coords = hexagon.get_verts().astype(int) + + # Point far outside + outside_point = (200, 200) + result = HexagonProcessor.clip_point_to_hexagon(outside_point, coords) + + # Result should be different from original + assert result != outside_point + + # Result should be on or near the hexagon boundary + path = Path(coords) + # The clipped point should be on the boundary (or very close to an edge) + + def test_nearest_point_on_segment_midpoint(self): + """Test nearest point when projection falls on segment interior.""" + p1 = (0, 0) + p2 = (10, 0) + point = (5, 5) + + nearest = HexagonProcessor.nearest_point_on_segment(point, p1, p2) + + # Nearest point should be at (5, 0) - perpendicular projection + assert abs(nearest[0] - 5) < 0.001 + assert abs(nearest[1] - 0) < 0.001 + + def test_nearest_point_on_segment_at_p1(self): + """Test nearest point when closest is p1.""" + p1 = (0, 0) + p2 = (10, 0) + point = (-5, 0) + + nearest = HexagonProcessor.nearest_point_on_segment(point, p1, p2) + + # Nearest point should be p1 + assert abs(nearest[0] - 0) < 0.001 + assert abs(nearest[1] - 0) < 0.001 + + def test_nearest_point_on_segment_at_p2(self): + """Test nearest point when closest is p2.""" + p1 = (0, 0) + p2 = (10, 0) + point = (15, 0) + + nearest = HexagonProcessor.nearest_point_on_segment(point, p1, p2) + + # Nearest point should be p2 + assert abs(nearest[0] - 10) < 0.001 + assert abs(nearest[1] - 0) < 0.001 + + def test_nearest_point_same_endpoints(self): + """Test handling of degenerate segment (same start and end).""" + p1 = (5, 5) + p2 = (5, 5) + point = (10, 10) + + nearest = HexagonProcessor.nearest_point_on_segment(point, p1, p2) + + # Should return p1 + assert nearest == p1 + + def test_nearest_point_diagonal_segment(self): + """Test nearest point on a diagonal segment.""" + p1 = (0, 0) + p2 = (10, 10) + point = (0, 10) + + nearest = HexagonProcessor.nearest_point_on_segment(point, p1, p2) + + # Nearest point should be at (5, 5) + assert abs(nearest[0] - 5) < 0.001 + assert abs(nearest[1] - 5) < 0.001 + + +# ============================================================================= +# Average Color Tests +# ============================================================================= + +class TestAverageColor: + """Tests for the average_color static method.""" + + def test_uniform_color_returns_same(self): + """Uniform color image should return that color as average.""" + color = [100, 150, 200] + image = np.full((50, 50, 3), color, dtype=np.uint8) + mask = np.ones((50, 50), dtype=np.uint8) * 255 + + avg = HexagonProcessor.average_color(image, mask) + + np.testing.assert_array_equal(avg, color) + + def test_partial_mask(self): + """Average should only consider masked pixels.""" + image = np.zeros((50, 50, 3), dtype=np.uint8) + image[0:25, :] = [255, 0, 0] # Top half red + image[25:50, :] = [0, 0, 255] # Bottom half blue + + # Mask only top half + mask = np.zeros((50, 50), dtype=np.uint8) + mask[0:25, :] = 255 + + avg = HexagonProcessor.average_color(image, mask) + + # Should be red (top half) + assert avg[0] == 255 + assert avg[1] == 0 + assert avg[2] == 0 + + def test_average_rounds_to_int(self): + """Average color values should be rounded integers.""" + image = np.zeros((50, 50, 3), dtype=np.uint8) + image[0:25, :] = [100, 100, 100] + image[25:50, :] = [101, 101, 101] + + mask = np.ones((50, 50), dtype=np.uint8) * 255 + + avg = HexagonProcessor.average_color(image, mask) + + # Should be rounded + assert avg.dtype == np.int64 or avg.dtype == np.int32 + assert all(100 <= v <= 101 for v in avg) + + +# ============================================================================= +# Closest Palette Color Tests +# ============================================================================= + +class TestClosestPaletteColor: + """Tests for the closest_palette_color static method.""" + + def test_exact_match_returns_same(self): + """Exact color match should be returned.""" + palette = np.array([ + [0, 0, 0], + [255, 0, 0], + [0, 255, 0], + [0, 0, 255], + [255, 255, 255] + ]) + + for color in palette: + result = HexagonProcessor.closest_palette_color(color, palette) + np.testing.assert_array_equal(result, color) + + def test_closest_color_selection(self): + """Should return nearest palette color by Euclidean distance.""" + palette = np.array([ + [0, 0, 0], # Black + [255, 255, 255] # White + ]) + + # Dark gray should be closer to black + dark_gray = np.array([50, 50, 50]) + result = HexagonProcessor.closest_palette_color(dark_gray, palette) + np.testing.assert_array_equal(result, [0, 0, 0]) + + # Light gray should be closer to white + light_gray = np.array([200, 200, 200]) + result = HexagonProcessor.closest_palette_color(light_gray, palette) + np.testing.assert_array_equal(result, [255, 255, 255]) + + def test_avoid_colors(self): + """Should skip colors in the avoid list.""" + palette = np.array([ + [0, 0, 0], + [128, 128, 128], + [255, 255, 255] + ]) + + # Black should be avoided, so gray is next closest to dark gray + dark_gray = np.array([50, 50, 50]) + avoid = [np.array([0, 0, 0])] + + result = HexagonProcessor.closest_palette_color(dark_gray, palette, avoid) + np.testing.assert_array_equal(result, [128, 128, 128]) + + +# ============================================================================= +# Hexagon Pattern Layer Tests +# ============================================================================= + +class TestHexagonPatternLayers: + """Tests for the fill_odd_layer static method.""" + + def test_odd_layer_returns_pattern_and_area(self): + """fill_odd_layer should return updated pattern and area.""" + pattern = np.zeros((256, 256, 3), dtype=np.uint8) + radius = 128 + hex_radius = 100 + bw_color = (255, 255, 255) + + result_pattern, area = HexagonProcessor.fill_odd_layer( + pattern, radius, hex_radius, bw_color + ) + + assert isinstance(result_pattern, np.ndarray) + assert result_pattern.shape == pattern.shape + assert area > 0 + + def test_odd_layer_area_formula(self): + """Area should follow hexagon area formula.""" + pattern = np.zeros((256, 256, 3), dtype=np.uint8) + radius = 128 + hex_radius = 50 + bw_color = (255, 255, 255) + + _, area = HexagonProcessor.fill_odd_layer( + pattern, radius, hex_radius, bw_color + ) + + expected_area = 3 * math.sqrt(3) * (hex_radius ** 2) / 2 + assert abs(area - expected_area) < 0.01 + + +# ============================================================================= +# Integration Test for Geometry +# ============================================================================= + +class TestGeometryIntegration: + """Integration tests combining multiple geometry functions.""" + + def test_mask_created_at_grid_centers(self): + """Masks should be successfully created at each grid center.""" + processor = HexagonProcessor(num_processes=1) + input_shape = (32, 32, 3) + processor.setup_hexagon_grid(input_shape) + + # Test that masks can be created at each center + for center_x, center_y in processor.hex_centers[:10]: # Test first 10 + mask = HexagonProcessor.create_hex_mask( + center_x, center_y, + processor.hex_radius, + processor.output_shape[:2] + ) + assert mask.shape == processor.output_shape[:2] + + def test_input_mask_scaling_consistency(self): + """Input mask scaling should be consistent with output mask.""" + processor = HexagonProcessor(num_processes=1) + input_shape = (64, 64, 3) + processor.setup_hexagon_grid(input_shape) + + # For a center in the middle of the image + center_x = processor.output_shape[1] // 2 + center_y = processor.output_shape[0] // 2 + + # Create output-scale mask + output_mask = HexagonProcessor.create_hex_mask( + center_x, center_y, + processor.hex_radius, + processor.output_shape[:2] + ) + + # Create input-scale mask + input_center_x = center_x // 16 + input_center_y = center_y // 16 + input_radius = processor.hex_radius // 16 + + input_mask = HexagonProcessor.create_hex_mask( + input_center_x, input_center_y, + input_radius, + input_shape[:2] + ) + + # Both masks should have non-zero pixels + assert np.sum(output_mask == 255) > 0 + assert np.sum(input_mask == 255) > 0 + + # Output mask should have ~256x the area (16^2) + area_ratio = np.sum(output_mask == 255) / np.sum(input_mask == 255) + assert 200 < area_ratio < 300 # Approximate due to discretization diff --git a/tests/test_regression.py b/tests/test_regression.py new file mode 100644 index 0000000..a3c7a6b --- /dev/null +++ b/tests/test_regression.py @@ -0,0 +1,521 @@ +"""Regression tests for Hexify image processing. + +These tests compare output from the current code against pre-generated +reference outputs to ensure that refactoring does not change behavior. +""" +import os +import sys +import pytest +import numpy as np +import cv2 +from typing import Tuple, Dict + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from hexagon_processor import HexagonProcessor + +# Paths +TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +FIXTURES_DIR = os.path.join(TESTS_DIR, 'fixtures') +REFERENCE_DIR = os.path.join(TESTS_DIR, 'reference_outputs') + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def compare_images(img1: np.ndarray, img2: np.ndarray) -> Dict[str, float]: + """ + Compare two images and return similarity metrics. + + Args: + img1: First image as numpy array (H, W, C) or (H, W) + img2: Second image as numpy array (H, W, C) or (H, W) + + Returns: + Dictionary containing: + - 'identical': bool, True if images are pixel-perfect identical + - 'shape_match': bool, True if shapes are identical + - 'max_diff': float, maximum absolute difference between any pixels + - 'mean_diff': float, mean absolute difference across all pixels + - 'rmse': float, root mean square error + - 'psnr': float, peak signal-to-noise ratio (higher is better, inf if identical) + - 'percent_different': float, percentage of pixels that differ + """ + result = { + 'identical': False, + 'shape_match': img1.shape == img2.shape, + 'max_diff': float('inf'), + 'mean_diff': float('inf'), + 'rmse': float('inf'), + 'psnr': 0.0, + 'percent_different': 100.0 + } + + if not result['shape_match']: + return result + + # Convert to float for calculations + img1_float = img1.astype(np.float64) + img2_float = img2.astype(np.float64) + + # Calculate difference + diff = np.abs(img1_float - img2_float) + + result['max_diff'] = float(np.max(diff)) + result['mean_diff'] = float(np.mean(diff)) + result['identical'] = result['max_diff'] == 0.0 + + # RMSE + mse = np.mean((img1_float - img2_float) ** 2) + result['rmse'] = float(np.sqrt(mse)) + + # PSNR (Peak Signal-to-Noise Ratio) + if mse == 0: + result['psnr'] = float('inf') + else: + max_pixel = 255.0 + result['psnr'] = float(20 * np.log10(max_pixel / np.sqrt(mse))) + + # Percentage of different pixels + if len(img1.shape) == 3: + # For color images, a pixel is different if any channel differs + pixel_diff = np.any(diff > 0, axis=2) + else: + pixel_diff = diff > 0 + result['percent_different'] = float(100.0 * np.sum(pixel_diff) / pixel_diff.size) + + return result + + +def assert_images_equal( + img1: np.ndarray, + img2: np.ndarray, + tolerance: int = 0, + msg: str = "" +) -> None: + """ + Assert that two images are equal within a tolerance. + + Args: + img1: First image as numpy array + img2: Second image as numpy array + tolerance: Maximum allowed difference per pixel (0 for exact match) + msg: Optional message to include in assertion error + + Raises: + AssertionError: If images differ beyond tolerance + """ + metrics = compare_images(img1, img2) + + if not metrics['shape_match']: + raise AssertionError( + f"{msg}Image shapes do not match: {img1.shape} vs {img2.shape}" + ) + + if tolerance == 0: + if not metrics['identical']: + raise AssertionError( + f"{msg}Images are not identical. " + f"Max diff: {metrics['max_diff']}, " + f"Mean diff: {metrics['mean_diff']:.4f}, " + f"Pixels different: {metrics['percent_different']:.2f}%" + ) + else: + if metrics['max_diff'] > tolerance: + raise AssertionError( + f"{msg}Images differ beyond tolerance ({tolerance}). " + f"Max diff: {metrics['max_diff']}, " + f"Mean diff: {metrics['mean_diff']:.4f}, " + f"Pixels different: {metrics['percent_different']:.2f}%" + ) + + +def load_reference_output(name: str) -> Tuple[np.ndarray, np.ndarray]: + """ + Load a reference output image and its palette. + + Args: + name: Base name of the reference output (e.g., 'ref_gradient_64x64_c16') + + Returns: + Tuple of (image, palette) as numpy arrays + """ + img_path = os.path.join(REFERENCE_DIR, f"{name}.png") + palette_path = os.path.join(REFERENCE_DIR, f"{name}_palette.npy") + + img = cv2.imread(img_path) + if img is None: + pytest.skip(f"Reference image not found: {img_path}") + img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + palette = None + if os.path.exists(palette_path): + palette = np.load(palette_path) + + return img, palette + + +def load_fixture(name: str) -> np.ndarray: + """ + Load a fixture image. + + Args: + name: Name of the fixture file (e.g., 'gradient_64x64.png') + + Returns: + Image as numpy array in RGB format + """ + path = os.path.join(FIXTURES_DIR, name) + img = cv2.imread(path) + if img is None: + pytest.skip(f"Fixture not found: {path}") + return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + +def process_image(input_image: np.ndarray, num_colors: int = 16) -> np.ndarray: + """ + Process an image using HexagonProcessor with deterministic settings. + + Args: + input_image: Input image as numpy array in RGB format + num_colors: Number of palette colors + + Returns: + Processed output image as numpy array + """ + processor = HexagonProcessor( + num_palette_colors=num_colors, + num_processes=1, # Single process for determinism + hexagons_dir=None, + chunk_size=32, + save_hexagons=False + ) + return processor.process_image(input_image) + + +# ============================================================================= +# Pixel-Perfect Regression Tests +# ============================================================================= + +class TestPixelPerfectRegression: + """Test suite for exact pixel-by-pixel comparison with reference outputs.""" + + def test_gradient_64x64_c16_exact(self): + """Test gradient 64x64 with 16 colors produces exact reference output.""" + input_img = load_fixture('gradient_64x64.png') + ref_img, ref_palette = load_reference_output('ref_gradient_64x64_c16') + + output = process_image(input_img, num_colors=16) + + assert_images_equal( + output, ref_img, tolerance=0, + msg="Gradient 64x64 c16 regression: " + ) + + def test_color_blocks_64x64_c16_exact(self): + """Test color blocks 64x64 with 16 colors produces exact reference output.""" + input_img = load_fixture('color_blocks_64x64.png') + ref_img, ref_palette = load_reference_output('ref_color_blocks_64x64_c16') + + output = process_image(input_img, num_colors=16) + + assert_images_equal( + output, ref_img, tolerance=0, + msg="Color blocks 64x64 c16 regression: " + ) + + def test_gradient_32x48_c16_exact(self): + """Test non-square gradient 32x48 with 16 colors produces exact reference output.""" + input_img = load_fixture('gradient_32x48.png') + ref_img, ref_palette = load_reference_output('ref_gradient_32x48_c16') + + output = process_image(input_img, num_colors=16) + + assert_images_equal( + output, ref_img, tolerance=0, + msg="Gradient 32x48 c16 regression: " + ) + + def test_gradient_64x64_c8_exact(self): + """Test gradient 64x64 with 8 colors produces exact reference output.""" + input_img = load_fixture('gradient_64x64.png') + ref_img, ref_palette = load_reference_output('ref_gradient_64x64_c8') + + output = process_image(input_img, num_colors=8) + + assert_images_equal( + output, ref_img, tolerance=0, + msg="Gradient 64x64 c8 regression: " + ) + + def test_fire_64x64_c16_exact(self): + """Test fire image 64x64 with 16 colors produces exact reference output.""" + fire_path = os.path.join(FIXTURES_DIR, 'fire_64x64.png') + if not os.path.exists(fire_path): + pytest.skip("Fire fixture not available") + + input_img = load_fixture('fire_64x64.png') + ref_img, ref_palette = load_reference_output('ref_fire_64x64_c16') + + output = process_image(input_img, num_colors=16) + + assert_images_equal( + output, ref_img, tolerance=0, + msg="Fire 64x64 c16 regression: " + ) + + +# ============================================================================= +# Tolerance-Based Regression Tests +# ============================================================================= + +class TestToleranceRegression: + """Test suite with tolerance for floating-point variations.""" + + @pytest.mark.parametrize("tolerance", [1, 2, 5]) + def test_gradient_64x64_c16_with_tolerance(self, tolerance): + """Test gradient with various tolerances for floating-point variations.""" + input_img = load_fixture('gradient_64x64.png') + ref_img, _ = load_reference_output('ref_gradient_64x64_c16') + + output = process_image(input_img, num_colors=16) + + assert_images_equal( + output, ref_img, tolerance=tolerance, + msg=f"Gradient 64x64 c16 with tolerance {tolerance}: " + ) + + @pytest.mark.parametrize("tolerance", [1, 2, 5]) + def test_color_blocks_64x64_c16_with_tolerance(self, tolerance): + """Test color blocks with various tolerances for floating-point variations.""" + input_img = load_fixture('color_blocks_64x64.png') + ref_img, _ = load_reference_output('ref_color_blocks_64x64_c16') + + output = process_image(input_img, num_colors=16) + + assert_images_equal( + output, ref_img, tolerance=tolerance, + msg=f"Color blocks 64x64 c16 with tolerance {tolerance}: " + ) + + @pytest.mark.parametrize("tolerance", [1, 2, 5]) + def test_gradient_32x48_c16_with_tolerance(self, tolerance): + """Test non-square gradient with various tolerances.""" + input_img = load_fixture('gradient_32x48.png') + ref_img, _ = load_reference_output('ref_gradient_32x48_c16') + + output = process_image(input_img, num_colors=16) + + assert_images_equal( + output, ref_img, tolerance=tolerance, + msg=f"Gradient 32x48 c16 with tolerance {tolerance}: " + ) + + @pytest.mark.parametrize("tolerance", [1, 2, 5]) + def test_gradient_64x64_c8_with_tolerance(self, tolerance): + """Test 8-color gradient with various tolerances.""" + input_img = load_fixture('gradient_64x64.png') + ref_img, _ = load_reference_output('ref_gradient_64x64_c8') + + output = process_image(input_img, num_colors=8) + + assert_images_equal( + output, ref_img, tolerance=tolerance, + msg=f"Gradient 64x64 c8 with tolerance {tolerance}: " + ) + + @pytest.mark.parametrize("tolerance", [1, 2, 5]) + def test_fire_64x64_c16_with_tolerance(self, tolerance): + """Test fire image with various tolerances.""" + fire_path = os.path.join(FIXTURES_DIR, 'fire_64x64.png') + if not os.path.exists(fire_path): + pytest.skip("Fire fixture not available") + + input_img = load_fixture('fire_64x64.png') + ref_img, _ = load_reference_output('ref_fire_64x64_c16') + + output = process_image(input_img, num_colors=16) + + assert_images_equal( + output, ref_img, tolerance=tolerance, + msg=f"Fire 64x64 c16 with tolerance {tolerance}: " + ) + + +# ============================================================================= +# Image Comparison Metrics Tests +# ============================================================================= + +class TestComparisonMetrics: + """Tests for the compare_images helper function itself.""" + + def test_identical_images(self): + """Verify metrics for identical images.""" + img = np.random.randint(0, 256, (100, 100, 3), dtype=np.uint8) + metrics = compare_images(img, img.copy()) + + assert metrics['identical'] is True + assert metrics['shape_match'] is True + assert metrics['max_diff'] == 0.0 + assert metrics['mean_diff'] == 0.0 + assert metrics['rmse'] == 0.0 + assert metrics['psnr'] == float('inf') + assert metrics['percent_different'] == 0.0 + + def test_different_shapes(self): + """Verify handling of different shaped images.""" + img1 = np.zeros((100, 100, 3), dtype=np.uint8) + img2 = np.zeros((100, 50, 3), dtype=np.uint8) + metrics = compare_images(img1, img2) + + assert metrics['identical'] is False + assert metrics['shape_match'] is False + + def test_known_difference(self): + """Verify metrics for images with known difference.""" + img1 = np.zeros((10, 10, 3), dtype=np.uint8) + img2 = np.zeros((10, 10, 3), dtype=np.uint8) + img2[0, 0] = [10, 10, 10] # Single pixel difference + + metrics = compare_images(img1, img2) + + assert metrics['identical'] is False + assert metrics['shape_match'] is True + assert metrics['max_diff'] == 10.0 + assert metrics['percent_different'] == 1.0 # 1 of 100 pixels + + def test_grayscale_images(self): + """Verify metrics work for grayscale images.""" + img1 = np.zeros((50, 50), dtype=np.uint8) + img2 = np.ones((50, 50), dtype=np.uint8) * 128 + + metrics = compare_images(img1, img2) + + assert metrics['identical'] is False + assert metrics['shape_match'] is True + assert metrics['max_diff'] == 128.0 + assert metrics['mean_diff'] == 128.0 + assert metrics['percent_different'] == 100.0 + + +class TestAssertImagesEqual: + """Tests for the assert_images_equal helper function.""" + + def test_identical_passes(self): + """Identical images should pass assertion.""" + img = np.random.randint(0, 256, (50, 50, 3), dtype=np.uint8) + assert_images_equal(img, img.copy()) # Should not raise + + def test_different_fails(self): + """Different images should fail assertion with tolerance=0.""" + img1 = np.zeros((50, 50, 3), dtype=np.uint8) + img2 = np.ones((50, 50, 3), dtype=np.uint8) + + with pytest.raises(AssertionError): + assert_images_equal(img1, img2, tolerance=0) + + def test_within_tolerance_passes(self): + """Images within tolerance should pass.""" + img1 = np.zeros((50, 50, 3), dtype=np.uint8) + img2 = np.ones((50, 50, 3), dtype=np.uint8) * 5 + + assert_images_equal(img1, img2, tolerance=5) # Should not raise + + def test_beyond_tolerance_fails(self): + """Images beyond tolerance should fail.""" + img1 = np.zeros((50, 50, 3), dtype=np.uint8) + img2 = np.ones((50, 50, 3), dtype=np.uint8) * 10 + + with pytest.raises(AssertionError): + assert_images_equal(img1, img2, tolerance=5) + + def test_shape_mismatch_fails(self): + """Different shaped images should fail.""" + img1 = np.zeros((50, 50, 3), dtype=np.uint8) + img2 = np.zeros((50, 100, 3), dtype=np.uint8) + + with pytest.raises(AssertionError) as excinfo: + assert_images_equal(img1, img2) + assert "shapes do not match" in str(excinfo.value) + + +# ============================================================================= +# Palette Consistency Tests +# ============================================================================= + +class TestPaletteConsistency: + """Tests to verify palette generation is deterministic.""" + + def test_palette_reproducibility(self): + """Verify that the same input produces the same palette.""" + input_img = load_fixture('gradient_64x64.png') + _, ref_palette = load_reference_output('ref_gradient_64x64_c16') + + if ref_palette is None: + pytest.skip("Reference palette not available") + + processor = HexagonProcessor( + num_palette_colors=16, + num_processes=1, + hexagons_dir=None, + save_hexagons=False + ) + processor.generate_palette(input_img) + + np.testing.assert_array_equal( + processor.palette, ref_palette, + err_msg="Palette should be identical to reference" + ) + + def test_palette_color_count(self): + """Verify palette has the correct number of colors.""" + input_img = load_fixture('gradient_64x64.png') + + for num_colors in [8, 16, 32]: + processor = HexagonProcessor( + num_palette_colors=num_colors, + num_processes=1, + hexagons_dir=None, + save_hexagons=False + ) + processor.generate_palette(input_img) + + assert processor.palette.shape == (num_colors, 3), \ + f"Expected palette shape ({num_colors}, 3), got {processor.palette.shape}" + + +# ============================================================================= +# Output Dimension Tests +# ============================================================================= + +class TestOutputDimensions: + """Tests to verify output dimensions are correct.""" + + @pytest.mark.parametrize("input_shape,expected_multiplier", [ + ((64, 64), 16), + ((32, 48), 16), + ((100, 100), 16), + ]) + def test_output_size_multiplier(self, input_shape, expected_multiplier): + """Verify output is 16x the input dimensions.""" + input_img = np.random.randint(0, 256, (*input_shape, 3), dtype=np.uint8) + + processor = HexagonProcessor( + num_palette_colors=16, + num_processes=1, + hexagons_dir=None, + save_hexagons=False + ) + output = processor.process_image(input_img) + + expected_height = input_shape[0] * expected_multiplier + expected_width = input_shape[1] * expected_multiplier + + assert output.shape[0] == expected_height, \ + f"Expected height {expected_height}, got {output.shape[0]}" + assert output.shape[1] == expected_width, \ + f"Expected width {expected_width}, got {output.shape[1]}" + assert output.shape[2] == 3, \ + f"Expected 3 channels, got {output.shape[2]}" diff --git a/tests/test_styles.py b/tests/test_styles.py new file mode 100644 index 0000000..a65401f --- /dev/null +++ b/tests/test_styles.py @@ -0,0 +1,529 @@ +""" +Tests for style augmentation features. + +This module tests the style rendering utilities including: +- Border rendering (solid, double, glow) +- Gradient application (radial, linear) +- Noise texture application +- RGBA output with transparency +""" + +import numpy as np +import pytest +import cv2 + +from v2.styles import ( + BorderStyle, + FillStyle, + add_hexagon_border, + apply_radial_gradient, + apply_linear_gradient, + add_noise_texture, + apply_alpha_channel, + apply_vignette, + get_style_preset, + apply_style_preset, + STYLE_EFFECTS, +) + + +# ============================================================================= +# Fixtures +# ============================================================================= + +@pytest.fixture +def sample_pattern(): + """Create a sample RGB pattern for testing.""" + pattern = np.full((100, 100, 3), 128, dtype=np.uint8) + return pattern + + +@pytest.fixture +def sample_rgba_pattern(): + """Create a sample RGBA pattern for testing.""" + pattern = np.full((100, 100, 4), 128, dtype=np.uint8) + pattern[:, :, 3] = 255 # Fully opaque + return pattern + + +@pytest.fixture +def hexagon_coords(): + """Create hexagon vertex coordinates for a centered hexagon.""" + import math + center = (50, 50) + radius = 40 + coords = [] + for i in range(6): + angle = math.radians(60 * i + 30) # Flat-top orientation + x = center[0] + radius * math.cos(angle) + y = center[1] + radius * math.sin(angle) + coords.append([x, y]) + return np.array(coords, dtype=np.int32) + + +@pytest.fixture +def hexagon_mask(): + """Create a hexagon mask for testing.""" + mask = np.zeros((100, 100), dtype=np.uint8) + # Create a simple circular approximation of hexagon for testing + cv2.circle(mask, (50, 50), 40, 255, -1) + return mask + + +# ============================================================================= +# Border Rendering Tests +# ============================================================================= + +class TestBorderRendering: + """Tests for hexagon border rendering.""" + + def test_no_border_returns_unchanged(self, sample_pattern, hexagon_coords): + """Border with NONE style should return unchanged pattern.""" + result = add_hexagon_border( + sample_pattern, + hexagon_coords, + border_width=2, + border_style=BorderStyle.NONE + ) + np.testing.assert_array_equal(result, sample_pattern) + + def test_zero_width_returns_unchanged(self, sample_pattern, hexagon_coords): + """Border with zero width should return unchanged pattern.""" + result = add_hexagon_border( + sample_pattern, + hexagon_coords, + border_width=0, + border_style=BorderStyle.SOLID + ) + np.testing.assert_array_equal(result, sample_pattern) + + def test_solid_border_modifies_pattern(self, sample_pattern, hexagon_coords): + """Solid border should modify the pattern along edges.""" + border_color = (255, 0, 0) # Red + result = add_hexagon_border( + sample_pattern, + hexagon_coords, + border_width=2, + border_color=border_color, + border_style=BorderStyle.SOLID + ) + # Pattern should be modified (not identical to original) + assert not np.array_equal(result, sample_pattern) + # Check that red color appears in result + assert np.any(result[:, :, 0] == 255) + + def test_double_border_creates_two_lines(self, sample_pattern, hexagon_coords): + """Double border should create inner and outer lines.""" + result = add_hexagon_border( + sample_pattern, + hexagon_coords, + border_width=4, + border_color=(0, 0, 0), + border_style=BorderStyle.DOUBLE + ) + # Pattern should be modified + assert not np.array_equal(result, sample_pattern) + + def test_glow_border_creates_fading_effect(self, sample_pattern, hexagon_coords): + """Glow border should create multiple fading layers.""" + result = add_hexagon_border( + sample_pattern, + hexagon_coords, + border_width=3, + border_color=(255, 255, 255), + border_style=BorderStyle.GLOW + ) + # Pattern should be modified + assert not np.array_equal(result, sample_pattern) + + def test_border_preserves_shape(self, sample_pattern, hexagon_coords): + """Border should preserve pattern dimensions.""" + result = add_hexagon_border( + sample_pattern, + hexagon_coords, + border_width=2, + border_style=BorderStyle.SOLID + ) + assert result.shape == sample_pattern.shape + + +# ============================================================================= +# Gradient Application Tests +# ============================================================================= + +class TestGradientApplication: + """Tests for gradient fill application.""" + + def test_radial_gradient_basic(self, sample_pattern): + """Radial gradient should create color variation from center.""" + result = apply_radial_gradient( + sample_pattern, + center=(50, 50), + radius=50, + inner_color=(255, 0, 0), + outer_color=(0, 0, 255) + ) + # Center should be closer to inner_color (red) + center_pixel = result[50, 50] + assert center_pixel[0] > center_pixel[2] # More red than blue + + # Edge should be closer to outer_color (blue) + edge_pixel = result[0, 50] + assert edge_pixel[2] > edge_pixel[0] # More blue than red + + def test_radial_gradient_with_mask(self, sample_pattern, hexagon_mask): + """Radial gradient with mask should only affect masked area.""" + original = sample_pattern.copy() + result = apply_radial_gradient( + sample_pattern, + center=(50, 50), + radius=50, + inner_color=(255, 0, 0), + outer_color=(0, 0, 255), + mask=hexagon_mask + ) + # Outside mask should be unchanged (approximately - some edge effects) + # Check a corner that's definitely outside the circle mask + assert result[0, 0, 0] == original[0, 0, 0] + + def test_linear_gradient_basic(self, sample_pattern): + """Linear gradient should create color transition along direction.""" + result = apply_linear_gradient( + sample_pattern, + start_point=(0, 0), + end_point=(99, 99), + start_color=(255, 0, 0), + end_color=(0, 0, 255) + ) + # Top-left should be closer to start_color (red) + start_pixel = result[0, 0] + assert start_pixel[0] > start_pixel[2] + + # Bottom-right should be closer to end_color (blue) + end_pixel = result[99, 99] + assert end_pixel[2] > end_pixel[0] + + def test_linear_gradient_with_mask(self, sample_pattern, hexagon_mask): + """Linear gradient with mask should only affect masked area.""" + original = sample_pattern.copy() + result = apply_linear_gradient( + sample_pattern, + start_point=(0, 0), + end_point=(99, 99), + start_color=(255, 0, 0), + end_color=(0, 0, 255), + mask=hexagon_mask + ) + # Corner outside mask should be unchanged + assert result[0, 0, 0] == original[0, 0, 0] + + def test_gradient_preserves_shape(self, sample_pattern): + """Gradient should preserve pattern dimensions.""" + result = apply_radial_gradient( + sample_pattern, + center=(50, 50), + radius=50, + inner_color=(255, 0, 0), + outer_color=(0, 0, 255) + ) + assert result.shape == sample_pattern.shape + + +# ============================================================================= +# Noise Texture Tests +# ============================================================================= + +class TestNoiseTexture: + """Tests for noise texture application.""" + + def test_zero_intensity_returns_unchanged(self, sample_pattern): + """Zero noise intensity should return unchanged pattern.""" + result = add_noise_texture(sample_pattern, intensity=0.0) + np.testing.assert_array_equal(result, sample_pattern) + + def test_noise_modifies_pattern(self, sample_pattern): + """Noise should modify the pattern values.""" + np.random.seed(42) # For reproducibility + result = add_noise_texture(sample_pattern, intensity=0.1) + assert not np.array_equal(result, sample_pattern) + + def test_noise_within_valid_range(self, sample_pattern): + """Noise should keep values within valid [0, 255] range.""" + np.random.seed(42) + result = add_noise_texture(sample_pattern, intensity=0.5) + assert np.all(result >= 0) + assert np.all(result <= 255) + + def test_noise_intensity_affects_variation(self, sample_pattern): + """Higher intensity should create more variation.""" + np.random.seed(42) + result_low = add_noise_texture(sample_pattern.copy(), intensity=0.01) + np.random.seed(42) + result_high = add_noise_texture(sample_pattern.copy(), intensity=0.2) + + # Calculate standard deviation of differences + diff_low = np.std(result_low.astype(float) - sample_pattern.astype(float)) + diff_high = np.std(result_high.astype(float) - sample_pattern.astype(float)) + + assert diff_high > diff_low + + def test_noise_with_mask(self, sample_pattern, hexagon_mask): + """Noise with mask should only affect masked area.""" + np.random.seed(42) + original = sample_pattern.copy() + result = add_noise_texture(sample_pattern, intensity=0.1, mask=hexagon_mask) + + # Corner outside mask should be unchanged + assert result[0, 0, 0] == original[0, 0, 0] + + def test_noise_preserves_shape(self, sample_pattern): + """Noise should preserve pattern dimensions.""" + result = add_noise_texture(sample_pattern, intensity=0.1) + assert result.shape == sample_pattern.shape + + +# ============================================================================= +# RGBA/Alpha Channel Tests +# ============================================================================= + +class TestAlphaChannel: + """Tests for RGBA output with transparency.""" + + def test_rgba_output_has_four_channels(self, sample_pattern, hexagon_mask): + """RGBA output should have 4 channels.""" + result = apply_alpha_channel(sample_pattern, hexagon_mask) + assert result.shape[2] == 4 + + def test_alpha_inside_mask_is_opaque(self, sample_pattern, hexagon_mask): + """Pixels inside mask should be fully opaque (alpha=255).""" + result = apply_alpha_channel(sample_pattern, hexagon_mask) + # Check a pixel inside the mask (center) + assert result[50, 50, 3] == 255 + + def test_alpha_outside_mask_is_transparent(self, sample_pattern, hexagon_mask): + """Pixels outside mask should be transparent (alpha=0 by default).""" + result = apply_alpha_channel(sample_pattern, hexagon_mask, background_alpha=0) + # Check a corner outside the mask + assert result[0, 0, 3] == 0 + + def test_custom_background_alpha(self, sample_pattern, hexagon_mask): + """Custom background alpha should be applied outside mask.""" + result = apply_alpha_channel(sample_pattern, hexagon_mask, background_alpha=128) + # Corner should have custom alpha + assert result[0, 0, 3] == 128 + + def test_rgb_values_preserved(self, sample_pattern, hexagon_mask): + """RGB values should be preserved in RGBA output.""" + result = apply_alpha_channel(sample_pattern, hexagon_mask) + # RGB channels should match original + np.testing.assert_array_equal(result[:, :, :3], sample_pattern) + + +# ============================================================================= +# Vignette Effect Tests +# ============================================================================= + +class TestVignetteEffect: + """Tests for vignette effect application.""" + + def test_zero_strength_returns_unchanged(self, sample_pattern): + """Zero vignette strength should return unchanged pattern.""" + result = apply_vignette(sample_pattern, center=(50, 50), radius=50, strength=0) + np.testing.assert_array_equal(result, sample_pattern) + + def test_vignette_darkens_edges(self, sample_pattern): + """Vignette should darken pixels toward edges.""" + result = apply_vignette(sample_pattern, center=(50, 50), radius=50, strength=0.5) + + # Center should be brighter than edge + center_brightness = np.mean(result[50, 50]) + edge_brightness = np.mean(result[0, 0]) + assert center_brightness >= edge_brightness + + +# ============================================================================= +# Style Preset Tests +# ============================================================================= + +class TestStylePresets: + """Tests for style effect presets.""" + + def test_classic_preset_is_empty(self): + """Classic preset should have no effects.""" + preset = get_style_preset("classic") + assert preset == {} + + def test_outlined_preset_has_border(self): + """Outlined preset should have border settings.""" + preset = get_style_preset("outlined") + assert "border_width" in preset + assert preset["border_width"] > 0 + + def test_textured_preset_has_noise(self): + """Textured preset should have noise settings.""" + preset = get_style_preset("textured") + assert "noise_intensity" in preset + assert preset["noise_intensity"] > 0 + + def test_unknown_preset_raises_error(self): + """Unknown preset name should raise ValueError.""" + with pytest.raises(ValueError) as exc_info: + get_style_preset("unknown_preset") + assert "unknown_preset" in str(exc_info.value) + + def test_all_presets_are_valid(self): + """All defined presets should be retrievable.""" + for preset_name in STYLE_EFFECTS.keys(): + preset = get_style_preset(preset_name) + assert isinstance(preset, dict) + + def test_apply_style_preset_classic(self, sample_pattern, hexagon_coords, hexagon_mask): + """Applying classic preset should not modify pattern.""" + result = apply_style_preset( + sample_pattern, + hexagon_coords, + center=(50, 50), + radius=50, + preset_name="classic" + ) + np.testing.assert_array_equal(result, sample_pattern) + + def test_apply_style_preset_outlined(self, sample_pattern, hexagon_coords, hexagon_mask): + """Applying outlined preset should add borders.""" + result = apply_style_preset( + sample_pattern, + hexagon_coords, + center=(50, 50), + radius=50, + preset_name="outlined" + ) + assert not np.array_equal(result, sample_pattern) + + +# ============================================================================= +# Integration Tests +# ============================================================================= + +class TestStyleIntegration: + """Integration tests for combining multiple style effects.""" + + def test_combined_noise_and_border(self, sample_pattern, hexagon_coords): + """Applying both noise and border should work together.""" + np.random.seed(42) + # First apply noise + result = add_noise_texture(sample_pattern, intensity=0.05) + # Then apply border + result = add_hexagon_border( + result, + hexagon_coords, + border_width=2, + border_style=BorderStyle.SOLID + ) + # Should be different from original + assert not np.array_equal(result, sample_pattern) + # Should have valid dimensions + assert result.shape == sample_pattern.shape + + def test_gradient_then_border(self, sample_pattern, hexagon_coords): + """Applying gradient then border should work correctly.""" + # Apply gradient + result = apply_radial_gradient( + sample_pattern, + center=(50, 50), + radius=50, + inner_color=(255, 128, 128), + outer_color=(128, 128, 255) + ) + # Apply border + result = add_hexagon_border( + result, + hexagon_coords, + border_width=2, + border_style=BorderStyle.SOLID + ) + assert result.shape == sample_pattern.shape + + def test_all_effects_combined(self, sample_pattern, hexagon_coords, hexagon_mask): + """All effects should work when combined.""" + np.random.seed(42) + + # Start with gradient + result = apply_radial_gradient( + sample_pattern, + center=(50, 50), + radius=50, + inner_color=(200, 200, 200), + outer_color=(100, 100, 100) + ) + + # Add noise + result = add_noise_texture(result, intensity=0.03, mask=hexagon_mask) + + # Add vignette + result = apply_vignette(result, center=(50, 50), radius=50, strength=0.2) + + # Add border + result = add_hexagon_border( + result, + hexagon_coords, + border_width=2, + border_style=BorderStyle.SOLID + ) + + # Verify output + assert result.shape == sample_pattern.shape + assert result.dtype == np.uint8 + + +# ============================================================================= +# Edge Cases and Error Handling +# ============================================================================= + +class TestEdgeCases: + """Tests for edge cases and boundary conditions.""" + + def test_empty_pattern(self): + """Small pattern should be handled correctly.""" + small_pattern = np.zeros((10, 10, 3), dtype=np.uint8) + result = add_noise_texture(small_pattern, intensity=0.1) + assert result.shape == small_pattern.shape + + def test_single_pixel_pattern(self): + """Single pixel pattern should not crash.""" + tiny_pattern = np.full((1, 1, 3), 128, dtype=np.uint8) + result = apply_radial_gradient( + tiny_pattern, + center=(0, 0), + radius=1, + inner_color=(255, 0, 0), + outer_color=(0, 0, 255) + ) + assert result.shape == tiny_pattern.shape + + def test_zero_radius_gradient(self, sample_pattern): + """Zero radius gradient should not crash.""" + result = apply_radial_gradient( + sample_pattern, + center=(50, 50), + radius=0, + inner_color=(255, 0, 0), + outer_color=(0, 0, 255) + ) + assert result.shape == sample_pattern.shape + + def test_rgba_pattern_with_border(self, sample_rgba_pattern, hexagon_coords): + """RGBA patterns should work with border.""" + result = add_hexagon_border( + sample_rgba_pattern, + hexagon_coords, + border_width=2, + border_style=BorderStyle.SOLID + ) + assert result.shape == sample_rgba_pattern.shape + + def test_high_intensity_noise_clamped(self, sample_pattern): + """Noise with intensity > 1 should be clamped.""" + np.random.seed(42) + result = add_noise_texture(sample_pattern, intensity=2.0) + assert np.all(result >= 0) + assert np.all(result <= 255) diff --git a/v2/__init__.py b/v2/__init__.py new file mode 100644 index 0000000..db5ccfd --- /dev/null +++ b/v2/__init__.py @@ -0,0 +1,116 @@ +""" +Hexify v2 - Clean Architecture Refactor + +This package provides a cleanly architected version of the Hexify hexagonal +pattern generator. The code has been restructured for clarity while maintaining +identical output to the original implementation. + +Modules: + config: All configuration constants and magic numbers + color: Color palette generation and selection + geometry: Hexagon grid and mask operations + layers: Multi-layer hexagon pattern rendering + processor: Main orchestration class + exceptions: Custom exception classes + settings: Configurable settings dataclass + styles: Style augmentation utilities (borders, gradients, noise, transparency) + cli: Command-line interface + +Usage: + # Python API: + from v2 import HexagonProcessor + + processor = HexagonProcessor(num_palette_colors=16) + output = processor.process_image(input_image) + + # With style effects: + processor = HexagonProcessor( + num_palette_colors=16, + style_params={"border_width": 2, "border_style": BorderStyle.SOLID} + ) + + # With RGBA output (transparent background): + processor = HexagonProcessor(output_format="RGBA") + output = processor.process_image(input_image) # 4-channel output + + # Command line: + python -m v2 input.png -o output.png + python -m v2 input.png --preset fast + +Exceptions: + HexifyError: Base exception for all Hexify errors + InvalidImageError: Raised when input image format is invalid + PaletteError: Raised when palette operations fail +""" + +from .color import ColorPalette +from .exceptions import ( + GridError, + GridNotSetupError, + HexifyError, + InsufficientColorsError, + InvalidImageError, + PaletteError, + PaletteNotGeneratedError, +) +from .geometry import HexagonGrid, HexagonMask +from .layers import LayerRenderer +from .processor import HexagonProcessor +from .settings import HexifySettings, ColorSpace, HexOrientation, QuantizationMethod +from .presets import PRESETS, get_preset, list_presets, create_custom_preset +from .config import ConfigResolver +from .styles import ( + BorderStyle, + FillStyle, + STYLE_EFFECTS, + add_hexagon_border, + add_noise_texture, + apply_alpha_channel, + apply_radial_gradient, + apply_linear_gradient, + apply_vignette, + apply_style_preset, + get_style_preset, +) + +__all__ = [ + # Main classes + 'HexagonProcessor', + 'ColorPalette', + 'HexagonGrid', + 'HexagonMask', + 'LayerRenderer', + # Settings and Configuration + 'HexifySettings', + 'ColorSpace', + 'HexOrientation', + 'QuantizationMethod', + 'ConfigResolver', + # Presets + 'PRESETS', + 'get_preset', + 'list_presets', + 'create_custom_preset', + # Style augmentation + 'BorderStyle', + 'FillStyle', + 'STYLE_EFFECTS', + 'add_hexagon_border', + 'add_noise_texture', + 'apply_alpha_channel', + 'apply_radial_gradient', + 'apply_linear_gradient', + 'apply_vignette', + 'apply_style_preset', + 'get_style_preset', + # Exceptions + 'HexifyError', + 'InvalidImageError', + 'PaletteError', + 'PaletteNotGeneratedError', + 'InsufficientColorsError', + 'GridError', + 'GridNotSetupError', +] + +__version__ = '2.2.0' diff --git a/v2/__main__.py b/v2/__main__.py new file mode 100644 index 0000000..cae983e --- /dev/null +++ b/v2/__main__.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +""" +Allow running Hexify v2 as a module. + +Usage: + python -m v2 input.png -o output.png + python -m v2 --help +""" + +from .cli import main + +if __name__ == "__main__": + import sys + sys.exit(main()) diff --git a/v2/cli.py b/v2/cli.py new file mode 100644 index 0000000..07cc96d --- /dev/null +++ b/v2/cli.py @@ -0,0 +1,757 @@ +#!/usr/bin/env python3 +""" +Hexify CLI - Generate hexagonal pattern art from images. + +Usage: + hexify input.png -o output.png + hexify input.png -c 16 --preset fast + hexify input.png --config settings.yaml + hexify batch inputs/ -o outputs/ + python -m v2.cli input.png -o output.png +""" + +import argparse +import logging +import os +import sys +import time +from pathlib import Path +from typing import List, Optional, Tuple, Dict, Any + +import cv2 +import numpy as np + +from .settings import HexifySettings, ColorSpace, HexOrientation, QuantizationMethod +from .processor import HexagonProcessor +from .exceptions import HexifyError, InvalidImageError + +# Version +__version__ = "2.1.0" + +# Supported image extensions +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp"} + +# Preset configurations +PRESETS: Dict[str, Dict[str, Any]] = { + "default": { + "num_palette_colors": 16, + "num_layers": 7, + "num_zones": 12, + "chunk_size": 100, + }, + "fast": { + "num_palette_colors": 8, + "num_layers": 5, + "num_zones": 6, + "chunk_size": 200, + "quantization_method": "minibatch_kmeans", + }, + "detailed": { + "num_palette_colors": 32, + "num_layers": 7, + "num_zones": 18, + "chunk_size": 50, + "kmeans_n_init": 15, + }, + "minimal": { + "num_palette_colors": 6, + "num_layers": 4, + "num_zones": 6, + "chunk_size": 200, + "quantization_method": "minibatch_kmeans", + }, +} + + +class ColoredFormatter(logging.Formatter): + """Logging formatter with color support.""" + + COLORS = { + logging.DEBUG: "\033[36m", # Cyan + logging.INFO: "\033[32m", # Green + logging.WARNING: "\033[33m", # Yellow + logging.ERROR: "\033[31m", # Red + logging.CRITICAL: "\033[1;31m", # Bold Red + } + RESET = "\033[0m" + + def format(self, record): + color = self.COLORS.get(record.levelno, self.RESET) + record.levelname = f"{color}{record.levelname}{self.RESET}" + return super().format(record) + + +def setup_logging(verbose: bool = False, quiet: bool = False) -> logging.Logger: + """ + Configure logging based on verbosity settings. + + Args: + verbose: Enable debug-level logging + quiet: Suppress all output except errors + + Returns: + Configured logger + """ + logger = logging.getLogger("hexify") + + if quiet: + level = logging.ERROR + elif verbose: + level = logging.DEBUG + else: + level = logging.INFO + + logger.setLevel(level) + + # Remove existing handlers + logger.handlers.clear() + + # Create console handler with colored output + handler = logging.StreamHandler() + handler.setLevel(level) + + # Use colors if terminal supports it + if sys.stdout.isatty(): + formatter = ColoredFormatter("%(levelname)s: %(message)s") + else: + formatter = logging.Formatter("%(levelname)s: %(message)s") + + handler.setFormatter(formatter) + logger.addHandler(handler) + + return logger + + +def create_parser() -> argparse.ArgumentParser: + """ + Create and configure the argument parser. + + Returns: + Configured ArgumentParser instance + """ + parser = argparse.ArgumentParser( + prog="hexify", + description="Generate hexagonal pattern art from images", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + hexify input.png Process with defaults + hexify input.png -o output.png Specify output path + hexify input.png -c 16 --preset fast Use fast preset with 16 colors + hexify input.png --config my.yaml Load settings from config file + hexify inputs/ --batch -o outputs/ Batch process a directory + hexify "*.jpg" --batch Process all JPGs in current dir + """, + ) + + # Input argument (required unless using info commands like --list-presets) + parser.add_argument( + "input", + nargs="?", + default=None, + help="Input image path, directory (for batch mode), or glob pattern", + ) + + # Output options + output_group = parser.add_argument_group("Output Options") + output_group.add_argument( + "-o", "--output", + help="Output path (default: input_hexified.png)", + ) + output_group.add_argument( + "--format", + choices=["png", "jpg"], + default="png", + help="Output image format (default: png)", + ) + output_group.add_argument( + "--quality", + type=int, + default=95, + metavar="N", + help="JPEG quality 1-100 (default: 95)", + ) + + # Processing options + proc_group = parser.add_argument_group("Processing Options") + proc_group.add_argument( + "-c", "--colors", + type=int, + default=None, + metavar="N", + help="Number of palette colors (default: 16)", + ) + proc_group.add_argument( + "-j", "--jobs", + type=int, + default=None, + metavar="N", + help="Number of parallel workers (default: CPU count)", + ) + proc_group.add_argument( + "--chunk-size", + type=int, + default=None, + metavar="N", + help="Hexagons per processing chunk (default: 100)", + ) + + # Preset and config + config_group = parser.add_argument_group("Configuration") + config_group.add_argument( + "--preset", + choices=["default", "fast", "detailed", "minimal"], + default="default", + help="Use a predefined preset (default: default)", + ) + config_group.add_argument( + "--config", + type=Path, + metavar="FILE", + help="Load settings from YAML or JSON file", + ) + config_group.add_argument( + "--save-config", + type=Path, + metavar="FILE", + help="Save current settings to YAML file and exit", + ) + + # Style options + style_group = parser.add_argument_group("Style Options") + style_group.add_argument( + "--layers", + type=int, + default=None, + metavar="N", + help="Number of concentric layers (default: 7)", + ) + style_group.add_argument( + "--zones", + type=int, + default=None, + metavar="N", + help="Number of angular zones (default: 12)", + ) + style_group.add_argument( + "--border-width", + type=int, + default=None, + metavar="N", + help="Hexagon border width in pixels (default: 0)", + ) + style_group.add_argument( + "--border-color", + type=str, + default=None, + metavar="R,G,B", + help="Border color as R,G,B (e.g., 255,255,255)", + ) + style_group.add_argument( + "--orientation", + choices=["flat_top", "pointy_top"], + default=None, + help="Hexagon orientation (default: flat_top)", + ) + + # Batch processing + batch_group = parser.add_argument_group("Batch Processing") + batch_group.add_argument( + "--batch", + action="store_true", + help="Process all images in input directory", + ) + batch_group.add_argument( + "--recursive", "-r", + action="store_true", + help="Include subdirectories in batch mode", + ) + batch_group.add_argument( + "--pattern", + type=str, + default=None, + metavar="GLOB", + help="File pattern for batch mode (e.g., '*.png')", + ) + + # Output control + output_ctrl_group = parser.add_argument_group("Output Control") + output_ctrl_group.add_argument( + "-v", "--verbose", + action="store_true", + help="Enable verbose debug output", + ) + output_ctrl_group.add_argument( + "-q", "--quiet", + action="store_true", + help="Suppress all output except errors", + ) + output_ctrl_group.add_argument( + "--no-progress", + action="store_true", + help="Disable progress bar", + ) + output_ctrl_group.add_argument( + "--save-hexagons", + action="store_true", + help="Save individual hexagon images", + ) + + # Info commands + info_group = parser.add_argument_group("Information") + info_group.add_argument( + "--list-presets", + action="store_true", + help="List available presets and exit", + ) + info_group.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + ) + + return parser + + +def parse_border_color(color_str: str) -> Tuple[int, int, int]: + """ + Parse a border color string into RGB tuple. + + Args: + color_str: Color as "R,G,B" string + + Returns: + Tuple of (R, G, B) integers + + Raises: + ValueError: If color string is invalid + """ + try: + parts = [int(x.strip()) for x in color_str.split(",")] + if len(parts) != 3: + raise ValueError("Must have exactly 3 components") + for p in parts: + if not 0 <= p <= 255: + raise ValueError(f"Color value {p} out of range 0-255") + return tuple(parts) + except Exception as e: + raise ValueError(f"Invalid border color '{color_str}': {e}") + + +def print_presets() -> None: + """Print available presets and their configurations.""" + print("\nAvailable Presets:") + print("-" * 60) + for name, config in PRESETS.items(): + print(f"\n {name}:") + for key, value in config.items(): + print(f" {key}: {value}") + print() + + +def load_settings(args: argparse.Namespace, logger: logging.Logger) -> HexifySettings: + """ + Load settings from config file and/or command line arguments. + + Priority (highest to lowest): + 1. Command line arguments + 2. Config file + 3. Preset + 4. Defaults + + Args: + args: Parsed command line arguments + logger: Logger instance + + Returns: + Configured HexifySettings instance + """ + settings_dict = {} + + # Start with preset settings + if args.preset and args.preset in PRESETS: + settings_dict.update(PRESETS[args.preset]) + logger.debug(f"Applied preset: {args.preset}") + + # Load config file if specified + if args.config: + if not args.config.exists(): + raise FileNotFoundError(f"Config file not found: {args.config}") + + config_path = str(args.config) + if config_path.endswith(".yaml") or config_path.endswith(".yml"): + file_settings = HexifySettings.from_yaml(config_path) + elif config_path.endswith(".json"): + file_settings = HexifySettings.from_json(config_path) + else: + raise ValueError(f"Unknown config format: {args.config.suffix}") + + settings_dict.update(file_settings.to_dict()) + logger.debug(f"Loaded config from: {args.config}") + + # Override with command line arguments + if args.colors is not None: + settings_dict["num_palette_colors"] = args.colors + if args.layers is not None: + settings_dict["num_layers"] = args.layers + if args.zones is not None: + settings_dict["num_zones"] = args.zones + if args.chunk_size is not None: + settings_dict["chunk_size"] = args.chunk_size + if args.border_width is not None: + settings_dict["border_width"] = args.border_width + if args.border_color is not None: + settings_dict["border_color"] = parse_border_color(args.border_color) + if args.orientation is not None: + settings_dict["orientation"] = args.orientation + + # Convert string enum values + if "quantization_method" in settings_dict and isinstance(settings_dict["quantization_method"], str): + settings_dict["quantization_method"] = QuantizationMethod(settings_dict["quantization_method"]) + if "orientation" in settings_dict and isinstance(settings_dict["orientation"], str): + settings_dict["orientation"] = HexOrientation(settings_dict["orientation"]) + if "color_space" in settings_dict and isinstance(settings_dict["color_space"], str): + settings_dict["color_space"] = ColorSpace(settings_dict["color_space"]) + + return HexifySettings.from_dict(settings_dict) if settings_dict else HexifySettings() + + +def get_default_output_path(input_path: Path, output_format: str) -> Path: + """ + Generate default output path from input path. + + Args: + input_path: Input file path + output_format: Output format (png or jpg) + + Returns: + Default output path (input_hexified.format) + """ + stem = input_path.stem + return input_path.parent / f"{stem}_hexified.{output_format}" + + +def discover_images( + input_path: Path, + recursive: bool = False, + pattern: Optional[str] = None +) -> List[Path]: + """ + Discover image files for batch processing. + + Args: + input_path: Input path (file, directory, or glob pattern) + recursive: Whether to search subdirectories + pattern: Optional glob pattern to filter files + + Returns: + List of image file paths + """ + images = [] + + if input_path.is_file(): + # Single file + if input_path.suffix.lower() in IMAGE_EXTENSIONS: + images.append(input_path) + elif input_path.is_dir(): + # Directory - scan for images + if recursive: + glob_pattern = "**/*" if pattern is None else f"**/{pattern}" + else: + glob_pattern = "*" if pattern is None else pattern + + for path in input_path.glob(glob_pattern): + if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS: + images.append(path) + else: + # Treat as glob pattern + parent = input_path.parent if input_path.parent.exists() else Path(".") + glob_str = input_path.name + + for path in parent.glob(glob_str): + if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS: + images.append(path) + + return sorted(images) + + +def process_single( + input_path: Path, + output_path: Path, + settings: HexifySettings, + args: argparse.Namespace, + logger: logging.Logger, +) -> Tuple[bool, float, Optional[float]]: + """ + Process a single image file. + + Args: + input_path: Input image path + output_path: Output image path + settings: Processing settings + args: Command line arguments + logger: Logger instance + + Returns: + Tuple of (success, processing_time, cache_hit_rate) + """ + start_time = time.time() + + try: + # Load image + logger.info(f"Loading: {input_path}") + image = cv2.imread(str(input_path)) + if image is None: + raise InvalidImageError(f"Failed to load image: {input_path}") + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + + logger.debug(f"Image size: {image.shape[1]}x{image.shape[0]}") + + # Create processor + processor = HexagonProcessor( + num_palette_colors=settings.num_palette_colors, + num_processes=args.jobs, + chunk_size=settings.chunk_size, + save_hexagons=args.save_hexagons, + ) + + # Setup progress bar if available and not disabled + pbar = None + if not args.no_progress and not args.quiet: + try: + from tqdm import tqdm + # Estimate hexagon count based on grid setup + processor.setup_hexagon_grid(image.shape) + total_hexagons = len(processor.hex_centers) + pbar = tqdm(total=total_hexagons, desc="Processing", unit="hex") + except ImportError: + logger.debug("tqdm not available, progress bar disabled") + + # Process image + logger.info("Processing...") + output = processor.process_image(image, pbar=pbar) + + if pbar: + pbar.close() + + # Ensure output directory exists + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Save output + logger.info(f"Saving: {output_path}") + output_bgr = cv2.cvtColor(output, cv2.COLOR_RGB2BGR) + + if args.format == "jpg": + cv2.imwrite(str(output_path), output_bgr, [cv2.IMWRITE_JPEG_QUALITY, args.quality]) + else: + cv2.imwrite(str(output_path), output_bgr) + + elapsed = time.time() - start_time + cache_hit_rate = processor.get_cache_hit_rate() + + logger.info(f"Complete in {elapsed:.2f}s (cache hit rate: {cache_hit_rate:.1%})") + + return True, elapsed, cache_hit_rate + + except Exception as e: + elapsed = time.time() - start_time + logger.error(f"Failed: {e}") + return False, elapsed, None + + +def process_batch(args: argparse.Namespace, logger: logging.Logger) -> int: + """ + Process multiple images in batch mode. + + Args: + args: Command line arguments + logger: Logger instance + + Returns: + Exit code (0 for success) + """ + input_path = Path(args.input) + + # Discover images + images = discover_images(input_path, args.recursive, args.pattern) + + if not images: + logger.error(f"No images found in: {input_path}") + return 1 + + logger.info(f"Found {len(images)} images to process") + + # Load settings + settings = load_settings(args, logger) + + # Determine output directory + if args.output: + output_dir = Path(args.output) + else: + if input_path.is_dir(): + output_dir = input_path / "hexified" + else: + output_dir = input_path.parent / "hexified" + + output_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"Output directory: {output_dir}") + + # Process each image + start_time = time.time() + results = { + "success": 0, + "failed": 0, + "total_time": 0.0, + "cache_hits": 0.0, + "cache_samples": 0, + } + + # Overall progress bar + pbar_overall = None + if not args.no_progress and not args.quiet: + try: + from tqdm import tqdm + pbar_overall = tqdm(total=len(images), desc="Batch", unit="img") + except ImportError: + pass + + for img_path in images: + # Generate output path + rel_path = img_path.relative_to(input_path.parent) if input_path.is_dir() else img_path.name + output_path = output_dir / f"{rel_path.stem}_hexified.{args.format}" + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Suppress individual progress bars in batch mode + batch_args = argparse.Namespace(**vars(args)) + batch_args.no_progress = True + + success, elapsed, cache_rate = process_single( + img_path, output_path, settings, batch_args, logger + ) + + if success: + results["success"] += 1 + results["total_time"] += elapsed + if cache_rate is not None: + results["cache_hits"] += cache_rate + results["cache_samples"] += 1 + else: + results["failed"] += 1 + + if pbar_overall: + pbar_overall.update(1) + + if pbar_overall: + pbar_overall.close() + + # Print summary + total_time = time.time() - start_time + avg_cache_rate = ( + results["cache_hits"] / results["cache_samples"] + if results["cache_samples"] > 0 + else 0 + ) + + print() + print("=" * 50) + print("Batch Processing Summary") + print("=" * 50) + print(f" Images processed: {results['success']}") + print(f" Images failed: {results['failed']}") + print(f" Total time: {total_time:.2f}s") + if results["success"] > 0: + print(f" Average time: {results['total_time'] / results['success']:.2f}s per image") + print(f" Avg cache rate: {avg_cache_rate:.1%}") + print("=" * 50) + + return 0 if results["failed"] == 0 else 1 + + +def main(argv: Optional[List[str]] = None) -> int: + """ + Main entry point for the CLI. + + Args: + argv: Command line arguments (default: sys.argv[1:]) + + Returns: + Exit code (0 for success) + """ + parser = create_parser() + args = parser.parse_args(argv) + + # Setup logging + logger = setup_logging(args.verbose, args.quiet) + + # Handle info commands first (these don't require input) + if args.list_presets: + print_presets() + return 0 + + # Handle save-config command (doesn't require input to exist) + if args.save_config: + try: + settings = load_settings(args, logger) + save_path = str(args.save_config) + if save_path.endswith(".json"): + settings.to_json(save_path) + else: + settings.to_yaml(save_path) + print(f"Settings saved to: {args.save_config}") + return 0 + except Exception as e: + logger.error(f"Failed to save config: {e}") + return 1 + + # Validate input is provided + if args.input is None: + logger.error("Input path is required") + return 1 + + input_path = Path(args.input) + + # Check if input exists (allow glob patterns) + if not input_path.exists() and not any(input_path.parent.glob(input_path.name)): + logger.error(f"Input not found: {args.input}") + return 1 + + # Determine if batch mode + is_batch = args.batch or input_path.is_dir() or "*" in str(input_path) + + try: + if is_batch: + return process_batch(args, logger) + else: + # Single file mode + settings = load_settings(args, logger) + + # Determine output path + if args.output: + output_path = Path(args.output) + else: + output_path = get_default_output_path(input_path, args.format) + + success, _, _ = process_single( + input_path, output_path, settings, args, logger + ) + return 0 if success else 1 + + except FileNotFoundError as e: + logger.error(str(e)) + return 1 + except HexifyError as e: + logger.error(f"Processing error: {e}") + return 1 + except KeyboardInterrupt: + logger.info("\nInterrupted by user") + return 130 + except Exception as e: + logger.error(f"Unexpected error: {e}") + if args.verbose: + import traceback + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/v2/color.py b/v2/color.py new file mode 100644 index 0000000..d2292dc --- /dev/null +++ b/v2/color.py @@ -0,0 +1,331 @@ +""" +Color palette generation and selection for Hexify. + +This module handles all color-related operations: +- Generating a color palette from an input image using K-means clustering +- Finding the closest palette color to a target color +- Selecting optimal color pairs for area-weighted color mixing + +The color selection algorithm aims to approximate any target color using only +colors from a limited palette by strategically mixing two palette colors in +varying proportions across angular zones. + +The module supports settings-based configuration through HexifySettings. +For backward compatibility, module-level constants are used when no +settings are provided. +""" + +from __future__ import annotations + +import hashlib +import logging +from typing import List, Optional, Tuple, TYPE_CHECKING + +import cv2 +import numpy as np + +from .config import ( + BRIGHTNESS_THRESHOLD, + ConfigResolver, + KMEANS_N_INIT, + KMEANS_RANDOM_STATE, + MAX_PALETTE_SAMPLE_PIXELS, +) + +if TYPE_CHECKING: + from .settings import HexifySettings + +# Lazy imports for heavy sklearn modules +_KMeans = None +_MiniBatchKMeans = None + + +def _get_kmeans(): + """Lazy load KMeans from sklearn.""" + global _KMeans + if _KMeans is None: + from sklearn.cluster import KMeans + _KMeans = KMeans + return _KMeans + + +def _get_minibatch_kmeans(): + """Lazy load MiniBatchKMeans from sklearn.""" + global _MiniBatchKMeans + if _MiniBatchKMeans is None: + from sklearn.cluster import MiniBatchKMeans + _MiniBatchKMeans = MiniBatchKMeans + return _MiniBatchKMeans + +# Module-level logger +logger = logging.getLogger(__name__) + + +class ColorPalette: + """ + Manages color palette generation and color selection from the palette. + + The palette is generated using K-means clustering on the input image pixels, + which finds the most representative colors. The palette is then sorted by + brightness for consistency across runs. + + Supports settings-based configuration through HexifySettings. + For backward compatibility, parameters take precedence over settings. + + Attributes: + num_colors: Number of colors to extract from the image + fast_mode: If True, uses MiniBatchKMeans for faster (but potentially + less accurate) palette generation. Default is False. + """ + + def __init__( + self, + num_colors: int, + fast_mode: bool = False, + settings: Optional[HexifySettings] = None + ): + """ + Initialize the color palette. + + Args: + num_colors: Number of colors to extract from the image + fast_mode: If True, use MiniBatchKMeans for faster palette generation. + Default is False (use standard KMeans for better quality). + settings: Optional HexifySettings for configuration. + When provided, uses settings for kmeans parameters. + """ + self._config = ConfigResolver(settings) + self.num_colors = num_colors + self.fast_mode = fast_mode + self.colors = None + self.palette_hash = None + + def generate_from_image(self, image: np.ndarray) -> None: + """ + Generate a color palette from an input image using K-means clustering. + + The algorithm: + 1. Downscales large images to speed up clustering + 2. Runs K-means to find cluster centers (representative colors) + 3. Sorts colors by brightness for reproducible ordering + 4. Rounds to integers and generates a hash for caching + + When fast_mode is enabled, uses MiniBatchKMeans which processes data + in mini-batches for significantly faster training at the cost of + slightly reduced clustering quality. + + Args: + image: Input image as numpy array (H, W, 3) in RGB format + """ + # Get configuration values + max_sample_pixels = self._config.max_palette_sample_pixels + random_state = self._config.kmeans_random_state + n_init = self._config.kmeans_n_init + + # Downscale large images to speed up K-means clustering. + # Using INTER_AREA for high quality downsampling that preserves color accuracy. + height, width = image.shape[:2] + if height * width > max_sample_pixels: + scale = np.sqrt(max_sample_pixels / (height * width)) + new_height = int(height * scale) + new_width = int(width * scale) + image = cv2.resize(image, (new_width, new_height), interpolation=cv2.INTER_AREA) + + # Reshape to list of pixels for clustering + pixels = image.reshape(-1, 3) + + # Use fixed random state for reproducible palette generation + if self.fast_mode: + # MiniBatchKMeans is faster but may produce slightly different results + MiniBatchKMeans = _get_minibatch_kmeans() + kmeans = MiniBatchKMeans( + n_clusters=self.num_colors, + random_state=random_state, + n_init=n_init, + batch_size=1024, # Process 1024 samples at a time + max_iter=100, + ) + else: + # Standard KMeans for best quality + KMeans = _get_kmeans() + kmeans = KMeans( + n_clusters=self.num_colors, + random_state=random_state, + n_init=n_init + ) + kmeans.fit(pixels) + + palette = kmeans.cluster_centers_ + + # Sort by brightness (mean of RGB) for consistent ordering. + # This ensures the same palette regardless of K-means initialization order. + sorted_indices = np.argsort(np.mean(palette, axis=1)) + self.colors = palette[sorted_indices] + + # Round to integers for consistent hashing and to match typical color values + self.colors = np.round(self.colors).astype(int) + + # Generate hash for cache key generation (identifies this specific palette) + self.palette_hash = hashlib.sha256(self.colors.tobytes()).hexdigest() + + def closest_color( + self, target_rgb: np.ndarray, avoid_colors: Optional[List[np.ndarray]] = None + ) -> np.ndarray: + """ + Find the closest palette color to a target color. + + Uses Euclidean distance in RGB space to find the nearest match. + Optionally avoids certain colors (useful when selecting multiple distinct colors). + + Args: + target_rgb: Target color as numpy array [R, G, B] + avoid_colors: List of colors to exclude from consideration + + Returns: + The closest palette color as numpy array [R, G, B] + + Note: + If all colors are in avoid_colors, returns the closest color anyway + as a fallback to prevent None return values that would crash callers. + """ + if avoid_colors is None: + avoid_colors = [] + + min_distance = float('inf') + closest_color = None + + # Track fallback (closest ignoring avoid list) in case all colors are avoided. + # BUG FIX: This prevents returning None when avoid_colors contains all palette colors, + # which would cause crashes in color selection logic. + fallback_color = None + fallback_distance = float('inf') + + for color in self.colors: + distance = np.linalg.norm(target_rgb - color) + + # Always track the absolute closest as fallback + if distance < fallback_distance: + fallback_distance = distance + fallback_color = color + + # Skip avoided colors for primary selection + if any(np.array_equal(color, avoided) for avoided in avoid_colors): + continue + + if distance < min_distance: + min_distance = distance + closest_color = color + + # Return closest non-avoided color, or fallback if all were avoided + return closest_color if closest_color is not None else fallback_color + + def select_mixing_color( + self, + target_rgb: np.ndarray, + primary_color: np.ndarray, + primary_area: float, + secondary_area: float, + background_color: tuple, + background_area: float + ) -> np.ndarray: + """ + Select the best secondary color for area-weighted color mixing. + + This is the key color matching algorithm. Given a target color and constraints: + - A primary color already chosen + - Areas for primary, secondary, and background colors + - The background color (black or white) + + Find the secondary palette color that, when mixed with the primary and background + in their respective area proportions, best approximates the target color. + + The mixed color formula: + mixed = (primary_area * primary + secondary_area * secondary + bg_area * bg) / total_area + + Args: + target_rgb: The color we're trying to approximate + primary_color: The first color already selected + primary_area: Pixel area covered by primary color + secondary_area: Pixel area covered by secondary color + background_color: Background color tuple (R, G, B) + background_area: Pixel area covered by background + + Returns: + The optimal secondary color from the palette + """ + min_distance = float('inf') + best_secondary = None + + primary_color = np.array(primary_color) + background_color = np.array(background_color) + total_area = primary_area + secondary_area + background_area + + for color in self.colors: + # Skip the primary color - we need a different secondary + if np.array_equal(color, primary_color): + continue + + color = np.array(color) + + # Calculate what the area-weighted mixed color would be + mixed_rgb = ( + primary_area * primary_color + + secondary_area * color + + background_area * background_color + ) / total_area + + # Find the color that produces the closest mix to target + distance = np.linalg.norm(target_rgb - mixed_rgb) + + if distance < min_distance: + min_distance = distance + best_secondary = color + + return best_secondary + + +def get_background_color( + avg_rgb: np.ndarray, + threshold: Optional[int] = None +) -> Tuple[int, int, int]: + """ + Determine background color (black or white) based on average brightness. + + Lighter colors get black background for contrast. + Darker colors get white background for contrast. + + Args: + avg_rgb: Average color of the region + threshold: Brightness threshold (default: BRIGHTNESS_THRESHOLD) + + Returns: + Background color as tuple (R, G, B) - either (0, 0, 0) or (255, 255, 255) + """ + if threshold is None: + threshold = BRIGHTNESS_THRESHOLD + + brightness = np.mean(avg_rgb) + if brightness < threshold: + return (255, 255, 255) # Dark colors get white background + else: + return (0, 0, 0) # Light colors get black background + + +def get_background_value( + avg_rgb: np.ndarray, + threshold: Optional[int] = None +) -> int: + """ + Get background value as single integer (0 or 255). + + Args: + avg_rgb: Average color of the region + threshold: Brightness threshold (default: BRIGHTNESS_THRESHOLD) + + Returns: + 0 for black background, 255 for white background + """ + if threshold is None: + threshold = BRIGHTNESS_THRESHOLD + + return 0 if np.mean(avg_rgb) >= threshold else 255 diff --git a/v2/config.py b/v2/config.py new file mode 100644 index 0000000..a661607 --- /dev/null +++ b/v2/config.py @@ -0,0 +1,328 @@ +""" +Configuration constants for Hexify. + +This module centralizes all magic numbers and configuration values used throughout +the hexagon pattern generation process. By extracting these values, we make the +algorithm easier to understand and tune. + +The hexagon pattern algorithm works by: +1. Creating a grid of hexagons over the output image (16x scale of input) +2. For each hexagon, sampling the input image to get average color +3. Rendering 7 concentric layers alternating between solid color and patterned zones +4. Even layers use color mixing with 12 angular zones to approximate the target color + +This module supports both: +1. Direct constant access (backward compatible): `from .config import HEX_WIDTH` +2. Settings-based configuration via HexifySettings class + +When using settings-based configuration, the ConfigResolver class provides +computed properties that can be overridden by HexifySettings instances. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from .settings import HexifySettings + +# ============================================================================= +# Image Scaling +# ============================================================================= + +# The output image is scaled up by this factor from the input. +# This allows for detailed hexagon patterns at high resolution. +HEX_SCALE_FACTOR = 16 + +# ============================================================================= +# Hexagon Dimensions +# ============================================================================= + +# Width of each hexagon in output pixels. +# Height is calculated as width * sqrt(3)/2 to maintain regular hexagon proportions. +HEX_WIDTH = 256 + +# Calculated hexagon height maintaining regular hexagon proportions. +# For a regular flat-topped hexagon, height = width * sqrt(3)/2 +HEX_HEIGHT = round(HEX_WIDTH * (math.sqrt(3) / 2)) + +# Radius is half the width, used for centering calculations +HEX_RADIUS = HEX_WIDTH // 2 + +# Horizontal spacing between hexagon centers. +# Hexagons overlap by 25% horizontally (3/4 of width between centers). +HEX_HORIZONTAL_SPACING = HEX_WIDTH * 0.75 + +# Vertical spacing between hexagon centers (full height) +HEX_VERTICAL_SPACING = HEX_HEIGHT + +# ============================================================================= +# Layer Configuration +# ============================================================================= + +# Total number of concentric layers in each hexagon. +# Layers alternate between odd (solid color) and even (patterned). +# Layer 7 is outermost, layer 1 is innermost. +NUM_LAYERS = 7 + +# Number of angular zones in even layers. +# 12 zones = 30 degrees each, alternating between two colors for color mixing. +NUM_ZONES = 12 + +# Base angle per zone before adjustment (360 / 12 = 30 degrees) +BASE_ZONE_ANGLE = 360 / NUM_ZONES # 30 degrees + +# ============================================================================= +# Palette Generation +# ============================================================================= + +# Maximum pixels to sample for palette generation. +# Larger images are downscaled to this limit for faster K-means clustering. +MAX_PALETTE_SAMPLE_PIXELS = 1_000_000 + +# Random seed for K-means clustering to ensure reproducible results. +KMEANS_RANDOM_STATE = 42 + +# Number of K-means initializations for stable clustering. +KMEANS_N_INIT = 10 + +# ============================================================================= +# Processing Defaults +# ============================================================================= + +# Default number of colors in the generated palette +DEFAULT_NUM_PALETTE_COLORS = 16 + +# Minimum required palette size (algorithm needs enough colors to work with) +MIN_PALETTE_COLORS = 5 + +# Default chunk size for parallel processing +DEFAULT_CHUNK_SIZE = 32 + +# ============================================================================= +# Brightness Thresholds +# ============================================================================= + +# Threshold for determining black vs white background. +# Colors with average RGB below this use black background, above use white. +BRIGHTNESS_THRESHOLD = 128 + +# ============================================================================= +# Layer Diameter Calculations +# ============================================================================= + +# These constants control how layer diameter varies with brightness. +# The goal is to make the pattern more compact for extreme brightness values +# (very dark or very light) to allow more of the background to show through. + +# Maximum diameter for layer 6 (when brightness is at 128, mid-gray) +LAYER_6_MAX_DIAMETER = 256 + +# Minimum diameter for layer 6 (at extreme brightness 0 or 255) +LAYER_6_MIN_DIAMETER = 192 + +# Diameter reduction range for inner even layers based on brightness +INNER_LAYER_DIAMETER_RANGE = 64 + +# ============================================================================= +# Geometric Constants +# ============================================================================= + +# Hexagon orientation in radians (pi/2 = flat top orientation) +HEX_ORIENTATION = math.pi / 2 + +# Number of vertices in a hexagon +HEX_NUM_VERTICES = 6 + +# Small threshold for floating point comparisons +FLOAT_EPSILON = 1e-6 + + +# ============================================================================= +# Configuration Resolver +# ============================================================================= + +class ConfigResolver: + """ + Resolves configuration values with optional settings override. + + This class provides a unified interface for accessing configuration values. + When initialized with settings, it uses those values; otherwise, it falls + back to the module-level constants for backward compatibility. + + Usage: + # Without settings (uses defaults) + config = ConfigResolver() + width = config.hex_width # Returns HEX_WIDTH constant + + # With settings + settings = HexifySettings(hex_width=128) + config = ConfigResolver(settings) + width = config.hex_width # Returns 128 + """ + + def __init__(self, settings: Optional[HexifySettings] = None): + """ + Initialize the configuration resolver. + + Args: + settings: Optional HexifySettings to use. If None, uses defaults. + """ + self._settings = settings + + @property + def settings(self) -> Optional[HexifySettings]: + """Get the settings object, if any.""" + return self._settings + + # Image Scaling + @property + def hex_scale_factor(self) -> int: + """Output image scale factor from input.""" + if self._settings is not None: + return self._settings.hex_scale_factor + return HEX_SCALE_FACTOR + + # Hexagon Dimensions + @property + def hex_width(self) -> int: + """Width of each hexagon in output pixels.""" + if self._settings is not None: + return self._settings.hex_width + return HEX_WIDTH + + @property + def hex_height(self) -> int: + """Height of each hexagon maintaining regular proportions.""" + if self._settings is not None: + return self._settings.hex_height + return HEX_HEIGHT + + @property + def hex_radius(self) -> int: + """Hexagon radius (half of width).""" + if self._settings is not None: + return self._settings.hex_radius + return HEX_RADIUS + + @property + def hex_horizontal_spacing(self) -> float: + """Horizontal spacing between hexagon centers.""" + if self._settings is not None: + return self._settings.hex_horizontal_spacing + return HEX_HORIZONTAL_SPACING + + @property + def hex_vertical_spacing(self) -> int: + """Vertical spacing between hexagon centers.""" + if self._settings is not None: + return self._settings.hex_vertical_spacing + return HEX_VERTICAL_SPACING + + # Layer Configuration + @property + def num_layers(self) -> int: + """Total number of concentric layers in each hexagon.""" + if self._settings is not None: + return self._settings.num_layers + return NUM_LAYERS + + @property + def num_zones(self) -> int: + """Number of angular zones in even layers.""" + if self._settings is not None: + return self._settings.num_zones + return NUM_ZONES + + @property + def base_zone_angle(self) -> float: + """Base angle per zone in degrees.""" + if self._settings is not None: + return self._settings.base_zone_angle + return BASE_ZONE_ANGLE + + # Palette Generation + @property + def max_palette_sample_pixels(self) -> int: + """Maximum pixels to sample for palette generation.""" + if self._settings is not None: + return self._settings.max_palette_sample_pixels + return MAX_PALETTE_SAMPLE_PIXELS + + @property + def kmeans_random_state(self) -> int: + """Random seed for K-means clustering.""" + if self._settings is not None: + return self._settings.kmeans_random_state + return KMEANS_RANDOM_STATE + + @property + def kmeans_n_init(self) -> int: + """Number of K-means initializations.""" + if self._settings is not None: + return self._settings.kmeans_n_init + return KMEANS_N_INIT + + # Processing Defaults + @property + def default_num_palette_colors(self) -> int: + """Default number of colors in the palette.""" + if self._settings is not None: + return self._settings.num_palette_colors + return DEFAULT_NUM_PALETTE_COLORS + + @property + def default_chunk_size(self) -> int: + """Default chunk size for processing.""" + if self._settings is not None: + return self._settings.chunk_size + return DEFAULT_CHUNK_SIZE + + # Brightness Thresholds + @property + def brightness_threshold(self) -> int: + """Threshold for black/white background selection.""" + if self._settings is not None: + return self._settings.brightness_threshold + return BRIGHTNESS_THRESHOLD + + # Layer Diameter Calculations + @property + def layer_6_max_diameter(self) -> int: + """Maximum diameter for layer 6.""" + if self._settings is not None: + return self._settings.layer_6_max_diameter + return LAYER_6_MAX_DIAMETER + + @property + def layer_6_min_diameter(self) -> int: + """Minimum diameter for layer 6.""" + if self._settings is not None: + return self._settings.layer_6_min_diameter + return LAYER_6_MIN_DIAMETER + + @property + def inner_layer_diameter_range(self) -> int: + """Diameter reduction range for inner even layers.""" + if self._settings is not None: + return self._settings.inner_layer_diameter_range + return INNER_LAYER_DIAMETER_RANGE + + # Geometric Constants + @property + def hex_orientation(self) -> float: + """Hexagon orientation in radians.""" + if self._settings is not None: + return self._settings.hex_orientation_radians + return HEX_ORIENTATION + + @property + def hex_num_vertices(self) -> int: + """Number of vertices in a hexagon.""" + return HEX_NUM_VERTICES + + @property + def float_epsilon(self) -> float: + """Small threshold for floating point comparisons.""" + return FLOAT_EPSILON diff --git a/v2/exceptions.py b/v2/exceptions.py new file mode 100644 index 0000000..fc65fea --- /dev/null +++ b/v2/exceptions.py @@ -0,0 +1,66 @@ +""" +Custom exceptions for Hexify v2. + +This module defines domain-specific exceptions that provide clear error messages +and allow callers to handle specific error conditions appropriately. +""" + + +class HexifyError(Exception): + """Base exception for all Hexify errors.""" + pass + + +class InvalidImageError(HexifyError): + """Raised when an input image has invalid format or dimensions.""" + + def __init__(self, message: str, shape: tuple = None): + """ + Initialize the exception. + + Args: + message: Description of the validation failure + shape: The actual shape of the invalid image (optional) + """ + self.shape = shape + super().__init__(message) + + +class PaletteError(HexifyError): + """Raised when palette generation or color selection fails.""" + pass + + +class PaletteNotGeneratedError(PaletteError): + """Raised when attempting to use a palette before generation.""" + + def __init__(self): + super().__init__("Palette must be generated before use. Call generate_from_image() first.") + + +class InsufficientColorsError(PaletteError): + """Raised when the palette has fewer colors than required.""" + + def __init__(self, required: int, available: int): + """ + Initialize the exception. + + Args: + required: Number of colors required + available: Number of colors available + """ + self.required = required + self.available = available + super().__init__(f"Palette has {available} colors but {required} are required.") + + +class GridError(HexifyError): + """Raised when hexagon grid setup or operations fail.""" + pass + + +class GridNotSetupError(GridError): + """Raised when attempting to use grid before setup.""" + + def __init__(self): + super().__init__("Grid must be set up before use. Call setup() first.") diff --git a/v2/geometry.py b/v2/geometry.py new file mode 100644 index 0000000..502a3ce --- /dev/null +++ b/v2/geometry.py @@ -0,0 +1,320 @@ +""" +Geometric utilities for hexagon grid and mask operations. + +This module handles all geometric calculations: +- Setting up the hexagon grid layout +- Creating hexagonal masks for sampling and rendering +- Point-in-polygon tests and clipping operations +- Coordinate transformations between input and output spaces + +The module supports settings-based configuration through HexifySettings. +For backward compatibility, module-level constants are used when no +settings are provided. +""" + +from __future__ import annotations + +import logging +from typing import List, Optional, Tuple, TYPE_CHECKING + +import cv2 +import numpy as np +from matplotlib.patches import RegularPolygon +from matplotlib.path import Path + +from .config import ( + ConfigResolver, + HEX_HEIGHT, + HEX_HORIZONTAL_SPACING, + HEX_NUM_VERTICES, + HEX_ORIENTATION, + HEX_RADIUS, + HEX_SCALE_FACTOR, + HEX_VERTICAL_SPACING, + HEX_WIDTH, +) + +if TYPE_CHECKING: + from .settings import HexifySettings + +# Module-level logger +logger = logging.getLogger(__name__) + + +class HexagonGrid: + """ + Manages the hexagon grid layout for the output image. + + The grid uses an offset coordinate system where: + - Hexagons are arranged in columns + - Odd columns are offset vertically by half a hexagon height + - This creates the characteristic honeycomb tessellation + + The output image is HEX_SCALE_FACTOR times larger than the input, + allowing detailed hexagon patterns to be rendered. + + Supports settings-based configuration through HexifySettings. + For backward compatibility, module-level constants are used when + no settings are provided. + """ + + def __init__(self, settings: Optional[HexifySettings] = None): + """ + Initialize the hexagon grid. + + Args: + settings: Optional HexifySettings for configuration. + If None, uses module-level constants. + """ + self._config = ConfigResolver(settings) + self.hex_centers = None + self.output_shape = None + self.hex_width = self._config.hex_width + self.hex_height = self._config.hex_height + self.hex_radius = self._config.hex_radius + + def setup(self, input_shape: tuple) -> None: + """ + Set up the hexagon grid based on input image dimensions. + + Calculates the output image size and generates all hexagon center + coordinates. Uses the offset coordinate system for honeycomb layout. + + Args: + input_shape: Shape of input image (height, width, channels) + """ + # Get configuration values + scale_factor = self._config.hex_scale_factor + h_spacing = self._config.hex_horizontal_spacing + v_spacing = self._config.hex_vertical_spacing + + # Output is scaled up by scale_factor + self.output_shape = ( + input_shape[0] * scale_factor, + input_shape[1] * scale_factor, + 3 + ) + + # Calculate grid dimensions with +2 for edge coverage + cols = int(self.output_shape[1] / h_spacing) + 2 + # BUG FIX: Was using HEX_HORIZONTAL_SPACING instead of HEX_VERTICAL_SPACING + # This caused too few rows for tall images + rows = int(self.output_shape[0] / v_spacing) + 2 + + # Generate hexagon centers using offset coordinate system + # Odd columns are shifted down by half the vertical spacing + self.hex_centers = [ + ( + int(h_spacing * col), + int(v_spacing * row + (0.5 * v_spacing if col % 2 else 0)) + ) + for row in range(rows) + for col in range(cols) + ] + + def get_hex_bounds(self, center_x: int, center_y: int) -> tuple: + """ + Get the bounding box for a hexagon, clipped to image bounds. + + Args: + center_x: X coordinate of hexagon center + center_y: Y coordinate of hexagon center + + Returns: + Tuple of (x_start, y_start, x_end, y_end) clipped to output image bounds + """ + x_start = max(center_x - self.hex_radius, 0) + y_start = max(center_y - self.hex_radius, 0) + x_end = min(center_x + self.hex_radius, self.output_shape[1]) + y_end = min(center_y + self.hex_radius, self.output_shape[0]) + return x_start, y_start, x_end, y_end + + def output_to_input_coords(self, center_x: int, center_y: int) -> tuple: + """ + Convert output image coordinates to input image coordinates. + + Args: + center_x: X coordinate in output space + center_y: Y coordinate in output space + + Returns: + Tuple of (input_x, input_y) coordinates + """ + scale_factor = self._config.hex_scale_factor + return ( + int(center_x / scale_factor), + int(center_y / scale_factor) + ) + + def get_input_hex_radius(self) -> int: + """Get the hexagon radius in input image coordinates.""" + return self.hex_radius // self._config.hex_scale_factor + + +class HexagonMask: + """ + Utilities for creating and manipulating hexagonal masks. + + Masks are used to: + - Sample average colors from hexagonal regions of the input image + - Composite rendered hexagons onto the output image + + All methods support an optional orientation parameter for settings-based + configuration. When not provided, uses the default flat-top orientation. + """ + + @staticmethod + def create( + center_x: int, + center_y: int, + radius: int, + shape: tuple, + orientation: Optional[float] = None + ) -> np.ndarray: + """ + Create a hexagonal mask at the specified location. + + Uses matplotlib's RegularPolygon to generate accurate hexagon vertices, + then fills with OpenCV. The hexagon is oriented with a flat top by default. + + Args: + center_x: X coordinate of hexagon center + center_y: Y coordinate of hexagon center + radius: Radius of the hexagon (center to vertex) + shape: Shape of the mask array (height, width) + orientation: Hexagon orientation in radians (default: flat-top) + + Returns: + Binary mask as numpy array with 255 inside hexagon, 0 outside + """ + if orientation is None: + orientation = HEX_ORIENTATION + + mask = np.zeros(shape, dtype=np.uint8) + + # Create hexagon with specified orientation + hexagon = RegularPolygon( + (center_x, center_y), + numVertices=HEX_NUM_VERTICES, + radius=radius, + orientation=orientation + ) + + # Get vertices and clip to image bounds + coords = hexagon.get_verts() + coords = np.clip(coords, [0, 0], [shape[1] - 1, shape[0] - 1]).astype(int) + + # Fill the hexagon + mask = cv2.fillPoly(mask, [coords], 255) + return mask + + @staticmethod + def get_hexagon_vertices( + center_x: int, + center_y: int, + radius: int, + orientation: Optional[float] = None + ) -> np.ndarray: + """ + Get the vertices of a hexagon. + + Args: + center_x: X coordinate of hexagon center + center_y: Y coordinate of hexagon center + radius: Radius of the hexagon + orientation: Hexagon orientation in radians (default: flat-top) + + Returns: + Numpy array of vertex coordinates, shape (6, 2) + """ + if orientation is None: + orientation = HEX_ORIENTATION + + hexagon = RegularPolygon( + (center_x, center_y), + numVertices=HEX_NUM_VERTICES, + radius=radius, + orientation=orientation + ) + return hexagon.get_verts().astype(int) + + +def average_color(image: np.ndarray, mask: np.ndarray) -> np.ndarray: + """ + Calculate the average color of an image region defined by a mask. + + Args: + image: Input image as numpy array (H, W, 3) + mask: Binary mask defining the region (H, W) + + Returns: + Average color as numpy array [R, G, B] rounded to integers + """ + masked = cv2.bitwise_and(image, image, mask=mask) + avg_color = cv2.mean(masked, mask=mask)[:3] + return np.round(avg_color).astype(int) + + +def clip_point_to_hexagon(point: tuple, hex_coords: np.ndarray) -> tuple: + """ + Clip a point to lie on or inside a hexagon boundary. + + If the point is inside the hexagon, returns it unchanged. + If outside, returns the nearest point on the hexagon edge. + + Args: + point: (x, y) coordinates of the point + hex_coords: Array of hexagon vertex coordinates + + Returns: + Clipped point coordinates as tuple + """ + path = Path(hex_coords) + if path.contains_point(point): + return point + + # Find the nearest point on any edge + min_dist = float('inf') + nearest_point = point + + for i in range(len(hex_coords)): + p1 = hex_coords[i] + p2 = hex_coords[(i + 1) % len(hex_coords)] + nearest = nearest_point_on_segment(point, p1, p2) + dist = np.linalg.norm(np.array(nearest) - np.array(point)) + if dist < min_dist: + min_dist = dist + nearest_point = nearest + + return nearest_point + + +def nearest_point_on_segment(point: tuple, p1: tuple, p2: tuple) -> tuple: + """ + Find the nearest point on a line segment to a given point. + + Uses vector projection to find the closest point on segment p1-p2. + + Args: + point: The query point (x, y) + p1: First endpoint of segment + p2: Second endpoint of segment + + Returns: + Nearest point on segment as tuple + """ + px, py = point + x1, y1 = p1 + x2, y2 = p2 + + dx, dy = x2 - x1, y2 - y1 + + # Handle degenerate case where segment is a point + if dx == dy == 0: + return p1 + + # Project point onto line, then clamp to segment + t = ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy) + t = max(0, min(1, t)) # Clamp to [0, 1] + + return (x1 + t * dx, y1 + t * dy) diff --git a/v2/hexify.py b/v2/hexify.py new file mode 100644 index 0000000..2743ff6 --- /dev/null +++ b/v2/hexify.py @@ -0,0 +1,334 @@ +""" +Hexify v2 CLI - Clean Architecture Entry Point + +This module provides the command-line interface for the refactored Hexify. +It maintains the same interface as the original hexify.py while using +the cleanly architected v2 implementation. + +Usage: + python -m v2.hexify input_image.png + python -m v2.hexify input_video.mp4 --colors 24 --processes 8 +""" + +import cv2 +import numpy as np +import matplotlib.pyplot as plt +import os +import time +import argparse +import traceback +from tqdm import tqdm + +from .processor import HexagonProcessor +from .config import MIN_PALETTE_COLORS + + +# Video handlers imported only when needed (module may be missing) +VideoReader = None +VideoWriter = None +downscale_video = None + + +def create_output_directory(input_path: str) -> tuple: + """ + Create output directories for processed files. + + Creates a directory structure: + / + frames/ - Individual processed frames (for video) + hexagons/ - Individual hexagon patterns + + Args: + input_path: Path to the input file + + Returns: + Tuple of (output_dir, frames_dir, hexagons_dir) + """ + base_name = os.path.splitext(os.path.basename(input_path))[0] + output_dir = os.path.join(os.path.dirname(input_path), base_name) + os.makedirs(output_dir, exist_ok=True) + + frames_dir = os.path.join(output_dir, 'frames') + os.makedirs(frames_dir, exist_ok=True) + + hexagons_dir = os.path.join(output_dir, 'hexagons') + os.makedirs(hexagons_dir, exist_ok=True) + + return output_dir, frames_dir, hexagons_dir + + +def process_image( + input_image_path: str, + num_palette_colors: int, + num_processes: int, + chunk_size: int = 32, + save_hexagons: bool = True +) -> None: + """ + Process a single image file. + + Args: + input_image_path: Path to input image + num_palette_colors: Number of colors in palette + num_processes: Number of parallel workers + chunk_size: Hexagons per processing chunk + save_hexagons: Whether to save individual hexagon images + """ + if not os.path.isfile(input_image_path): + print(f"Error: The file '{input_image_path}' does not exist.") + return + + output_dir, frames_dir, hexagons_dir = create_output_directory(input_image_path) + + # Load and convert image + input_image = cv2.imread(input_image_path) + if input_image is None: + print(f"Error: Unable to read the image file '{input_image_path}'. Please check if it's a valid image file.") + return + + input_image = cv2.cvtColor(input_image, cv2.COLOR_BGR2RGB) + + # Create processor + processor = HexagonProcessor( + num_palette_colors=num_palette_colors, + num_processes=num_processes, + hexagons_dir=hexagons_dir, + chunk_size=chunk_size, + save_hexagons=save_hexagons + ) + + # Get total hexagons for progress bar + processor.setup_hexagon_grid(input_image.shape) + total_hexagons = len(processor.hex_centers) + + # Process with progress bar + with tqdm(total=total_hexagons, desc="Processing image", unit="hexagon") as pbar: + output_image = processor.process_image(input_image, pbar) + + # Save palette visualization + palette_image = np.zeros((64, 32 * num_palette_colors, 3), dtype=np.uint8) + for i, color in enumerate(processor.palette.colors): + palette_image[:, i * 32:(i + 1) * 32] = color + + palette_filename = f"{processor.palette_hash[:6]}_palette.png" + palette_path = os.path.join(output_dir, palette_filename) + plt.imsave(palette_path, palette_image) + print(f"Palette saved to: {palette_path}") + + # Save output image + output_image_path = os.path.join(output_dir, 'output.png') + plt.imsave(output_image_path, output_image) + print(f"Output image saved to: {output_image_path}") + + # Report statistics + cache_hit_rate = processor.get_cache_hit_rate() + print(f"Cache hit rate: {cache_hit_rate:.2%}") + print(f"Total cache hits: {processor.cache_hits.value}") + print(f"Total cache misses: {processor.cache_misses.value}") + print(f"Final cache size: {len(processor.hexagon_cache)}") + + +def process_video( + input_video_path: str, + num_palette_colors: int, + num_processes: int, + chunk_size: int = 32, + save_hexagons: bool = True, + save_frames: bool = True +) -> None: + """ + Process a video file. + + Args: + input_video_path: Path to input video + num_palette_colors: Number of colors in palette + num_processes: Number of parallel workers + chunk_size: Hexagons per processing chunk + save_hexagons: Whether to save individual hexagon images + save_frames: Whether to save individual processed frames + """ + global VideoReader, VideoWriter, downscale_video + + try: + from video_handlers import VideoReader, VideoWriter, downscale_video + except ImportError: + print("Error: video_handlers module not found. Video processing is not available.") + print("Only image processing is supported with the current installation.") + return + + if not os.path.isfile(input_video_path): + print(f"Error: The file '{input_video_path}' does not exist.") + return + + output_dir, frames_dir, hexagons_dir = create_output_directory(input_video_path) + + # Downscale video if necessary + downscaled_path = os.path.join(output_dir, 'downscaled.mp4') + input_video_path = downscale_video(input_video_path, downscaled_path) + + reader = VideoReader(input_video_path) + print(f"Total frames to process: {reader.frame_count}") + + # Generate palette from sample frames + sample_frames = reader.get_frames(num_frames=10) + combined_image = np.concatenate(sample_frames, axis=1) + + processor = HexagonProcessor( + num_palette_colors=num_palette_colors, + num_processes=num_processes, + hexagons_dir=hexagons_dir, + chunk_size=chunk_size, + save_hexagons=save_hexagons + ) + processor.generate_palette(combined_image) + + # Save palette visualization + palette_image = np.zeros((64, 32 * num_palette_colors, 3), dtype=np.uint8) + for i, color in enumerate(processor.palette.colors): + palette_image[:, i * 32:(i + 1) * 32] = color + + palette_filename = f"{processor.palette_hash[:6]}_palette.png" + palette_path = os.path.join(output_dir, palette_filename) + plt.imsave(palette_path, palette_image) + print(f"Palette saved to: {palette_path}") + + # Process video + output_video_path = os.path.join(output_dir, 'output.mp4') + writer = VideoWriter(output_video_path, reader.fps, reader.width * 4, reader.height * 4) + + failed_frames = [] + + # Setup hexagon grid to get total hexagons + processor.setup_hexagon_grid((reader.height, reader.width)) + total_hexagons = len(processor.hex_centers) + + try: + with tqdm(total=reader.frame_count, desc="Processing video frames", unit="frame") as frame_pbar: + for frame_number in range(1, reader.frame_count + 1): + try: + frame = reader.read_frame() + if frame is None: + print(f"Failed to read frame {frame_number}") + failed_frames.append(frame_number) + continue + + with tqdm(total=total_hexagons, desc=f"Frame {frame_number}", unit="hexagon", leave=False) as hexagon_pbar: + processed_frame = processor.process_image(frame, hexagon_pbar) + + if save_frames: + frame_filename = f"{processor.palette_hash[:6]}_frame_{frame_number:06d}.png" + frame_path = os.path.join(frames_dir, frame_filename) + plt.imsave(frame_path, processed_frame) + + downscaled_frame = cv2.resize(processed_frame, (reader.width * 4, reader.height * 4)) + writer.write_frame(downscaled_frame) + + frame_pbar.update(1) + + # Report cache hit rate periodically + if frame_number % 10 == 0: + cache_hit_rate = processor.get_cache_hit_rate() + print(f"Current cache hit rate: {cache_hit_rate:.2%}") + + except Exception as e: + print(f"Error processing frame {frame_number}: {str(e)}") + print(traceback.format_exc()) + failed_frames.append(frame_number) + + except Exception as e: + print(f"An error occurred during video processing: {str(e)}") + print(traceback.format_exc()) + + finally: + reader.close() + writer.close() + print(f"Processed video saved to: {output_video_path}") + print(f"Final frame count: {reader.frame_count}") + if failed_frames: + print(f"Failed frames: {failed_frames}") + + # Report final statistics + final_cache_hit_rate = processor.get_cache_hit_rate() + print(f"Final cache hit rate: {final_cache_hit_rate:.2%}") + print(f"Total cache hits: {processor.cache_hits.value}") + print(f"Total cache misses: {processor.cache_misses.value}") + print(f"Final cache size: {len(processor.hexagon_cache)}") + + +def main( + input_path: str, + num_palette_colors: int = 16, + num_processes: int = None, + chunk_size: int = 32, + save_hexagons: bool = True, + save_frames: bool = True +) -> None: + """ + Main entry point for processing images or videos. + + Args: + input_path: Path to input file + num_palette_colors: Number of colors in palette + num_processes: Number of parallel workers + chunk_size: Hexagons per processing chunk + save_hexagons: Whether to save individual hexagon images + save_frames: Whether to save individual video frames + """ + start_time = time.time() + + if input_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.tiff')): + process_image(input_path, num_palette_colors, num_processes, chunk_size, save_hexagons) + elif input_path.lower().endswith(('.mp4', '.avi', '.mov', '.mkv')): + process_video(input_path, num_palette_colors, num_processes, chunk_size, save_hexagons, save_frames) + else: + print(f"Error: Unsupported file format for '{input_path}'") + return + + end_time = time.time() + total_time = end_time - start_time + print(f"Total execution time: {total_time:.2f} seconds") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Generate hexagonal pattern from input image or video (v2 clean architecture)." + ) + parser.add_argument("input_path", help="Path to the input image or video file") + parser.add_argument( + "-c", "--colors", + type=int, + default=16, + help="Number of colors in the palette (default: 16)" + ) + parser.add_argument( + "-p", "--processes", + type=int, + default=None, + help="Number of processes to use (default: number of CPU cores)" + ) + parser.add_argument( + "--chunk-size", + type=int, + default=32, + help="Number of hexagons to process in each chunk (default: 32)" + ) + parser.add_argument( + "--save-hexagons", + action="store_true", + default=False, + help="Save individual hexagon images (default: False)" + ) + parser.add_argument( + "--save-frames", + action="store_true", + default=False, + help="Save individual processed frames from video (default: False)" + ) + + args = parser.parse_args() + + if args.colors < MIN_PALETTE_COLORS: + print(f"Minimum color palette size must be at least {MIN_PALETTE_COLORS}. Setting it to {MIN_PALETTE_COLORS}.") + args.colors = MIN_PALETTE_COLORS + + main(args.input_path, args.colors, args.processes, args.chunk_size, args.save_hexagons, args.save_frames) diff --git a/v2/layers.py b/v2/layers.py new file mode 100644 index 0000000..04b6e2b --- /dev/null +++ b/v2/layers.py @@ -0,0 +1,541 @@ +""" +Layer rendering for hexagon patterns. + +This module handles the rendering of individual hexagon patterns with their +concentric layers. Each hexagon has 7 layers alternating between: + +- Odd layers (7, 5, 3, 1): Solid background color (black or white based on brightness) +- Even layers (6, 4, 2): Patterned zones with two colors for color mixing + +The layer system creates a distinctive visual style where: +- The outermost layer (7) provides contrast with the background +- Even layers use angular zones to mix two palette colors +- Inner odd layers provide separation between even layers +- Layer 1 (center) is the innermost solid region + +The color mixing in even layers works by dividing the layer into 12 angular zones, +alternating between two colors. The ratio of zone sizes determines the visual +color blend, approximating any target color using only palette colors. + +Style augmentation features (all optional, backward compatible): +- Border styles: solid, double, glow effects around hexagon edges +- Fill styles: solid, radial gradient, linear gradient +- Noise texture: subtle grain for artistic effects + +The module supports settings-based configuration through HexifySettings. +For backward compatibility, module-level constants are used when no +settings are provided. +""" + +from __future__ import annotations + +import logging +import math +from typing import Dict, List, Optional, Tuple, TYPE_CHECKING + +import cv2 +import numpy as np +from matplotlib.patches import RegularPolygon + +from .color import ColorPalette, get_background_color +from .config import ( + BRIGHTNESS_THRESHOLD, + ConfigResolver, + FLOAT_EPSILON, + HEX_NUM_VERTICES, + HEX_ORIENTATION, + HEX_SCALE_FACTOR, + INNER_LAYER_DIAMETER_RANGE, + LAYER_6_MAX_DIAMETER, + LAYER_6_MIN_DIAMETER, + NUM_LAYERS, + NUM_ZONES, +) +from .geometry import HexagonMask, average_color, clip_point_to_hexagon +from .styles import ( + BorderStyle, + FillStyle, + add_hexagon_border, + add_noise_texture, + apply_radial_gradient, + apply_linear_gradient, +) + +if TYPE_CHECKING: + from .settings import HexifySettings + +# Module-level logger +logger = logging.getLogger(__name__) + + +class LayerRenderer: + """ + Renders the multi-layer hexagon pattern. + + Each hexagon is rendered as a series of concentric layers from outside in. + This class handles the complex geometry of the angular zones in even layers + and coordinates color selection for visual color mixing. + + Style augmentation features (all optional, backward compatible): + - border_width, border_color, border_style: Add borders around hexagons + - fill_style: Control how hexagons are filled (solid, gradient) + - noise_intensity: Add subtle noise texture for artistic effect + """ + + def __init__( + self, + palette: ColorPalette, + input_image: np.ndarray, + settings: Optional[HexifySettings] = None, + border_width: int = 0, + border_color: Tuple[int, int, int] = (0, 0, 0), + border_style: BorderStyle = BorderStyle.NONE, + fill_style: FillStyle = FillStyle.SOLID, + noise_intensity: float = 0.0, + gradient_inner_color: Optional[Tuple[int, int, int]] = None, + gradient_outer_color: Optional[Tuple[int, int, int]] = None, + ): + """ + Initialize the layer renderer. + + Args: + palette: The color palette to use for rendering + input_image: The input image for sampling colors + settings: Optional HexifySettings for full configuration control + border_width: Width of hexagon border in pixels (0 = no border) + border_color: Border color as (R, G, B) tuple + border_style: Style of border (NONE, SOLID, DOUBLE, GLOW) + fill_style: Fill style (SOLID, RADIAL_GRADIENT, LINEAR_GRADIENT) + noise_intensity: Noise texture intensity (0.0 = none, 0.05-0.15 typical) + gradient_inner_color: Inner color for gradient fills (optional) + gradient_outer_color: Outer color for gradient fills (optional) + """ + self.palette = palette + self.input_image = input_image + self.settings = settings + self._config = ConfigResolver(settings) + + # Style augmentation parameters (all optional, defaults preserve existing behavior) + # Settings can override border settings if provided + if settings is not None: + self.border_width = settings.border_width + self.border_color = settings.border_color + else: + self.border_width = border_width + self.border_color = border_color + + self.border_style = border_style if self.border_width > 0 else BorderStyle.NONE + self.fill_style = fill_style + self.noise_intensity = noise_intensity + self.gradient_inner_color = gradient_inner_color + self.gradient_outer_color = gradient_outer_color + + def create_hex_pattern( + self, + center_x: int, + center_y: int, + radius: int, + avg_rgb: np.ndarray + ) -> np.ndarray: + """ + Create the full multi-layer hexagon pattern. + + Renders all layers from outside to center. + Odd layers are solid background, even layers have color-mixing zones. + + Args: + center_x: X coordinate of hexagon center in output space + center_y: Y coordinate of hexagon center in output space + radius: Radius of the hexagon + avg_rgb: Average color sampled from the input image + + Returns: + Pattern as numpy array (2*radius, 2*radius, 3) + """ + # Get configuration values + num_layers = self._config.num_layers + brightness_threshold = self._config.brightness_threshold + + # Determine background color based on brightness + # Dark areas get white background, light areas get black + bw_value = 0 if np.mean(avg_rgb) >= brightness_threshold else 255 + bw_color = (bw_value, bw_value, bw_value) + + # Initialize pattern with background color + pattern = np.full((2 * radius, 2 * radius, 3), bw_value, dtype=np.uint8) + + # Track layer areas and radii for color mixing calculations + # Even layers need to know the area of the odd layer inside them + layer_areas = {i: 0 for i in range(num_layers, 0, -1)} + layer_radii = {i: 0 for i in range(num_layers, 0, -1)} + + # Track colors to avoid reusing in adjacent layers + avoid_rgb = [] + + # Render layers from outside in + for i in range(num_layers, 0, -1): + if i % 2 == 1: + # Odd layers: solid background color + hex_radius = int(radius * (i / num_layers)) + layer_radii[i] = hex_radius + pattern, layer_area = self._fill_odd_layer(pattern, radius, hex_radius, bw_color) + layer_areas[i] = layer_area + else: + # Even layers: patterned color mixing zones + pattern, layer_area = self._fill_even_layer( + layer_index=i, + avg_rgb=avg_rgb, + radius=radius, + layer_radii=layer_radii, + avoid_rgb=avoid_rgb, + layer_areas=layer_areas, + pattern=pattern, + center_x=center_x, + center_y=center_y, + bw_color=bw_color + ) + layer_areas[i] = layer_area + + # Apply style augmentations (only if enabled) + pattern = self._apply_style_augmentations(pattern, radius) + + return pattern + + def _apply_style_augmentations( + self, + pattern: np.ndarray, + radius: int + ) -> np.ndarray: + """ + Apply optional style augmentations to the pattern. + + This method applies any enabled style effects in the correct order: + 1. Gradient fill (if enabled) + 2. Noise texture (if enabled) + 3. Border (if enabled) + + Args: + pattern: The base pattern to augment + radius: Hexagon radius + + Returns: + Pattern with style augmentations applied + """ + # Get configuration values + hex_orientation = self._config.hex_orientation + hex_num_vertices = self._config.hex_num_vertices + + # Get hexagon coordinates for border and mask operations + hexagon = RegularPolygon( + (radius, radius), + numVertices=hex_num_vertices, + radius=radius, + orientation=hex_orientation + ) + hex_coords = hexagon.get_verts().astype(np.int32) + + # Create mask for selective effects + mask = np.zeros((2 * radius, 2 * radius), dtype=np.uint8) + cv2.fillPoly(mask, [hex_coords], 255) + + # Apply gradient fill if enabled + if self.fill_style != FillStyle.SOLID: + # Determine gradient colors (use average pattern colors if not specified) + inner_color = self.gradient_inner_color + outer_color = self.gradient_outer_color + + if inner_color is None or outer_color is None: + # Default to subtle gradient based on pattern colors + center_color = pattern[radius, radius].tolist() + edge_color = pattern[0, radius].tolist() if radius > 0 else center_color + + if inner_color is None: + inner_color = tuple(center_color) + if outer_color is None: + outer_color = tuple(edge_color) + + if self.fill_style == FillStyle.RADIAL_GRADIENT: + pattern = apply_radial_gradient( + pattern, + center=(radius, radius), + radius=radius, + inner_color=inner_color, + outer_color=outer_color, + mask=mask + ) + elif self.fill_style == FillStyle.LINEAR_GRADIENT: + pattern = apply_linear_gradient( + pattern, + start_point=(0, 0), + end_point=(2 * radius, 2 * radius), + start_color=inner_color, + end_color=outer_color, + mask=mask + ) + + # Apply noise texture if enabled + if self.noise_intensity > 0: + pattern = add_noise_texture(pattern, self.noise_intensity, mask) + + # Apply border if enabled + if self.border_width > 0 and self.border_style != BorderStyle.NONE: + pattern = add_hexagon_border( + pattern, + hex_coords, + border_width=self.border_width, + border_color=self.border_color, + border_style=self.border_style + ) + + return pattern + + def _fill_odd_layer( + self, + pattern: np.ndarray, + radius: int, + hex_radius: int, + bw_color: tuple + ) -> tuple: + """ + Fill an odd layer with solid background color. + + Odd layers provide visual separation between the color-mixing even layers. + They use the background color (black or white based on image brightness). + + Args: + pattern: The pattern array to draw on + radius: The full hexagon radius (for centering) + hex_radius: The radius of this layer's hexagon + bw_color: Background color tuple (R, G, B) + + Returns: + Tuple of (updated pattern, layer area in pixels) + """ + # Get configuration values + hex_num_vertices = self._config.hex_num_vertices + hex_orientation = self._config.hex_orientation + + # Create hexagon centered in the pattern + inner_hexagon = RegularPolygon( + (radius, radius), + numVertices=hex_num_vertices, + radius=hex_radius, + orientation=hex_orientation + ) + inner_coords = inner_hexagon.get_verts().astype(int) + + # Fill with background color + pattern = cv2.fillPoly(pattern, [inner_coords], tuple(map(int, bw_color))) + + # Calculate area for color mixing: A = (3 * sqrt(3) / 2) * r^2 + hex_area = 3 * math.sqrt(3) * (hex_radius ** 2) / 2 + + return pattern, hex_area + + def _fill_even_layer( + self, + layer_index: int, + avg_rgb: np.ndarray, + radius: int, + layer_radii: dict, + avoid_rgb: list, + layer_areas: dict, + pattern: np.ndarray, + center_x: int, + center_y: int, + bw_color: tuple + ) -> tuple: + """ + Fill an even layer with color-mixing angular zones. + + Even layers are the core of the color approximation algorithm. They divide + the layer into angular zones alternating between two palette colors. + The relative sizes of the zones determine the visual color blend. + + The zone sizing is based on how close the primary color is to the target: + - Perfect match: primary color gets larger zones, secondary gets minimal + - Poor match: zones sizes approach equality + + Args: + layer_index: Which layer (e.g., 6, 4, or 2 with default settings) + avg_rgb: Target color to approximate + radius: Full hexagon radius + layer_radii: Dict of radii for each layer + avoid_rgb: Colors already used in outer layers + layer_areas: Dict of areas for each layer + pattern: Pattern array to draw on + center_x: X coordinate in output space + center_y: Y coordinate in output space + bw_color: Background color + + Returns: + Tuple of (updated pattern, layer area in pixels) + """ + # Get configuration values + num_layers = self._config.num_layers + hex_num_vertices = self._config.hex_num_vertices + hex_orientation = self._config.hex_orientation + hex_scale_factor = self._config.hex_scale_factor + layer_6_max_diameter = self._config.layer_6_max_diameter + layer_6_min_diameter = self._config.layer_6_min_diameter + inner_layer_diameter_range = self._config.inner_layer_diameter_range + num_zones = self._config.num_zones + float_epsilon = self._config.float_epsilon + + # Calculate layer diameter based on brightness. + # Colors closer to mid-gray (128) get larger zones for more accurate mixing. + # Extreme brightness values (0 or 255) get smaller zones. + brightness = np.mean(avg_rgb) + + # Determine which layer is the "outer even layer" based on num_layers + outer_even_layer = num_layers - 1 if num_layers % 2 == 0 else num_layers - 1 + + if layer_index == outer_even_layer or (num_layers == 7 and layer_index == 6): + # Outer even layer: varies based on brightness + diameter = layer_6_max_diameter - abs(brightness - 128) * (layer_6_max_diameter - layer_6_min_diameter) / 128 + else: + # Inner even layers: based on outer odd layer radius with brightness adjustment + diameter = (layer_radii[layer_index + 1] * 2) - abs(brightness - 128) * inner_layer_diameter_range / 128 + + hex_radius = int(diameter // 2) + + # Create the hexagon for this layer + inner_hexagon = RegularPolygon( + (radius, radius), + numVertices=hex_num_vertices, + radius=hex_radius, + orientation=hex_orientation + ) + inner_coords = inner_hexagon.get_verts().astype(int) + + # Sample average color at this layer's scale from input image + scaled_center_x = center_x // hex_scale_factor + scaled_center_y = center_y // hex_scale_factor + scaled_radius = hex_radius // hex_scale_factor + + input_mask = HexagonMask.create( + scaled_center_x, scaled_center_y, scaled_radius, self.input_image.shape[:2], + orientation=hex_orientation + ) + layer_avg_rgb = average_color(self.input_image, input_mask) + + # Select primary color (closest to target, avoiding already-used colors) + color_1 = self.palette.closest_color(layer_avg_rgb, avoid_rgb) + avoid_rgb.append(color_1) + + # Calculate distance from primary color to target + dist_1 = np.linalg.norm(color_1 - layer_avg_rgb) + + # Find the palette color most similar to primary (for zone angle calculation) + color_adj = min( + self.palette.colors, + key=lambda c: np.linalg.norm(c - color_1) if not np.array_equal(c, color_1) else float('inf') + ) + dist_adj = np.linalg.norm(color_1 - color_adj) + + # Calculate zone angles based on color distance ratios. + # percentage_off measures how "off" the primary color is from the target. + # Higher values mean more equal zone sizes for better blending. + percentage_off = min(dist_1, dist_adj) / max(dist_1, dist_adj) if dist_adj != 0 else 0 + even_angle = percentage_off * 60 # Primary color zone angle (0-60 degrees) + odd_angle = 60 - even_angle # Secondary color zone angle + + # Calculate areas for each zone type (used for secondary color selection) + # Area of a circular sector: A = 0.5 * r^2 * theta + even_area = 6 * (0.5 * hex_radius * hex_radius * math.radians(even_angle)) + odd_area = 6 * (0.5 * hex_radius * hex_radius * math.radians(odd_angle)) + layer_area = even_area + odd_area + + # Select secondary color that best approximates target when mixed with primary + # Takes into account the background color area from the inner odd layer + inner_layer_area = layer_areas[layer_index + 1] - layer_area + color_2 = self.palette.select_mixing_color( + layer_avg_rgb, + color_1, + even_area, + odd_area, + bw_color, + inner_layer_area + ) + avoid_rgb.append(color_2) + + # Convert to tuples for OpenCV + color_1_rgb = tuple(map(int, color_1)) + color_2_rgb = tuple(map(int, color_2)) + + # Draw angular zones with vectorized angle calculations + # Start angle offset centers the pattern (base angle - half of even_angle) + # Base zone angle is 360/num_zones (30 degrees for 12 zones) + base_zone_angle = 360 / num_zones # Full zone angle + initial_offset = base_zone_angle - (even_angle / 2) + + # Pre-compute all angles vectorized (alternating even_angle and odd_angle) + zone_indices = np.arange(num_zones) + zone_angles = np.where(zone_indices % 2 == 0, even_angle, odd_angle) + + # Cumulative sum gives end angles, shifted gives start angles + cumulative_angles = np.cumsum(zone_angles) + end_angles = initial_offset + cumulative_angles + start_angles = np.concatenate([[initial_offset], end_angles[:-1]]) + + # Convert to radians for vectorized trigonometry + start_rads = np.radians(start_angles) + end_rads = np.radians(end_angles) + + # Pre-compute all boundary points vectorized + p1_x = radius + hex_radius * np.cos(start_rads) + p1_y = radius + hex_radius * np.sin(start_rads) + p2_x = radius + hex_radius * np.cos(end_rads) + p2_y = radius + hex_radius * np.sin(end_rads) + + # Pre-compute midpoints vectorized + p12_x = (p1_x + p2_x) / 2 + p12_y = (p1_y + p2_y) / 2 + + # Pre-compute distances and triangle apex points vectorized + d = np.sqrt((p2_x - p1_x) ** 2 + (p2_y - p1_y) ** 2) / 2 + length_of_shorter_leg = d / np.sqrt(3) + + # Direction perpendicular to p1-p2 + dx = p12_x - p1_x + dy = p12_y - p1_y + lengths = np.sqrt(dx ** 2 + dy ** 2) + + # Avoid division by zero + safe_lengths = np.where(lengths > float_epsilon, lengths, 1.0) + dx_norm = dx / safe_lengths + dy_norm = dy / safe_lengths + + # Calculate p3 (apex points) - use p1 for degenerate cases + p3_x = np.where(lengths > float_epsilon, + p12_x + length_of_shorter_leg * dy_norm, + p1_x) + p3_y = np.where(lengths > float_epsilon, + p12_y - length_of_shorter_leg * dx_norm, + p1_y) + + # Now iterate through zones (still need loop for fillPoly calls) + for zone in range(num_zones): + angle_color = color_1_rgb if zone % 2 == 0 else color_2_rgb + + # Get pre-computed points + p1 = (p1_x[zone], p1_y[zone]) + p2 = (p2_x[zone], p2_y[zone]) + p3 = (p3_x[zone], p3_y[zone]) + + # Clip all points to stay within the hexagon + p1 = clip_point_to_hexagon(p1, inner_coords) + p2 = clip_point_to_hexagon(p2, inner_coords) + p3 = clip_point_to_hexagon(p3, inner_coords) + + # Draw the main triangular zone (center to edge) + vertices1 = np.array([(radius, radius), p1, p2], dtype=np.int32) + cv2.fillPoly(pattern, [vertices1], angle_color) + + # Odd zones (secondary color) get an additional triangular cap + # This creates the characteristic "pinwheel" appearance + if zone % 2 == 1: + vertices2 = np.array([p1, p2, p3], dtype=np.int32) + cv2.fillPoly(pattern, [vertices2], angle_color) + + return pattern, layer_area diff --git a/v2/presets.py b/v2/presets.py new file mode 100644 index 0000000..3d5ca53 --- /dev/null +++ b/v2/presets.py @@ -0,0 +1,177 @@ +""" +Preset configurations for common Hexify use cases. + +This module provides pre-configured HexifySettings for common scenarios, +making it easy to quickly select an appropriate configuration. + +Usage: + from v2.presets import get_preset, PRESETS + + settings = get_preset("fast") + processor = HexagonProcessor(settings=settings) + + # Or access presets directly + settings = PRESETS["detailed"] +""" + +from typing import Dict + +from .settings import ( + HexifySettings, + ColorSpace, + HexOrientation, + QuantizationMethod, +) + + +# ============================================================================= +# Preset Definitions +# ============================================================================= + +PRESETS: Dict[str, HexifySettings] = { + # Default settings - balanced quality and performance + "default": HexifySettings(), + + # Fast processing - uses MiniBatch K-means and larger chunks + "fast": HexifySettings( + quantization_method=QuantizationMethod.MINIBATCH_KMEANS, + chunk_size=200, + kmeans_n_init=5, # Fewer initializations for speed + ), + + # Detailed output - smaller hexagons for more detail + "detailed": HexifySettings( + hex_width=128, # Smaller hexagons = more detail + num_layers=7, + num_zones=12, + chunk_size=64, # Smaller chunks for detailed work + ), + + # Minimal/artistic - larger hexagons for stylized look + "minimal": HexifySettings( + hex_width=512, # Larger hexagons + num_layers=5, + num_zones=6, + num_palette_colors=8, # Fewer colors for more stylized look + ), + + # High quality - best color matching (slower) + "high_quality": HexifySettings( + hex_width=256, + num_layers=7, + num_zones=12, + color_space=ColorSpace.RGB, # Can be changed to LAB when implemented + kmeans_n_init=20, # More initializations for better palette + num_palette_colors=24, # More colors for better matching + max_palette_sample_pixels=2_000_000, # Sample more pixels + ), + + # Large format - for very large output images + "large_format": HexifySettings( + hex_width=512, + hex_scale_factor=32, # Even larger scale + num_layers=7, + num_zones=12, + chunk_size=32, # Smaller chunks due to larger hexagons + ), + + # Thumbnail - for small preview images + "thumbnail": HexifySettings( + hex_width=64, + hex_scale_factor=8, + num_layers=5, + num_zones=6, + chunk_size=200, # Larger chunks for small hexagons + quantization_method=QuantizationMethod.MINIBATCH_KMEANS, + ), + + # Pointy top orientation variant + "pointy": HexifySettings( + orientation=HexOrientation.POINTY_TOP, + ), + + # Video processing optimized + "video": HexifySettings( + quantization_method=QuantizationMethod.MINIBATCH_KMEANS, + chunk_size=150, + kmeans_n_init=5, + max_palette_sample_pixels=500_000, # Faster palette generation + ), +} + + +# ============================================================================= +# Preset Access Functions +# ============================================================================= + +def get_preset(name: str) -> HexifySettings: + """ + Get a preset configuration by name. + + Args: + name: Name of the preset (case-insensitive) + + Returns: + HexifySettings instance for the requested preset + + Raises: + KeyError: If the preset name is not found + + Example: + >>> settings = get_preset("fast") + >>> settings.quantization_method + + """ + name_lower = name.lower() + if name_lower not in PRESETS: + available = ", ".join(sorted(PRESETS.keys())) + raise KeyError( + f"Unknown preset '{name}'. Available presets: {available}" + ) + return PRESETS[name_lower].copy() + + +def list_presets() -> Dict[str, str]: + """ + Get a dictionary of preset names and their descriptions. + + Returns: + Dictionary mapping preset names to brief descriptions + """ + descriptions = { + "default": "Balanced quality and performance", + "fast": "Optimized for speed with MiniBatch K-means", + "detailed": "Smaller hexagons for more detail", + "minimal": "Large hexagons for stylized look", + "high_quality": "Best color matching (slower)", + "large_format": "For very large output images", + "thumbnail": "Quick previews with small output", + "pointy": "Pointy-top hexagon orientation", + "video": "Optimized for video processing", + } + return descriptions + + +def create_custom_preset( + base: str = "default", + **overrides +) -> HexifySettings: + """ + Create a custom preset based on an existing one. + + Args: + base: Name of the base preset to start from + **overrides: Settings to override from the base + + Returns: + New HexifySettings with overrides applied + + Example: + >>> settings = create_custom_preset( + ... base="fast", + ... hex_width=128, + ... num_palette_colors=32 + ... ) + """ + base_settings = get_preset(base) + return base_settings.copy(**overrides) diff --git a/v2/processor.py b/v2/processor.py new file mode 100644 index 0000000..be662f5 --- /dev/null +++ b/v2/processor.py @@ -0,0 +1,434 @@ +""" +Main hexagon processor orchestrating the pattern generation. + +This module provides the high-level HexagonProcessor class that coordinates +all the components: +- ColorPalette: Generates and manages the color palette +- HexagonGrid: Manages the hexagon layout +- LayerRenderer: Renders individual hexagon patterns + +The processor handles parallelization, caching, and image I/O, delegating +the actual rendering work to the specialized components. + +The processor supports a settings-based configuration system through the +HexifySettings class. For backward compatibility, all existing parameters +continue to work unchanged - if no settings are provided, defaults are used. + +RGBA support: +- Set output_format="RGBA" for transparent background outside hexagons +- Hexagon interiors remain opaque, background becomes transparent +""" + +import logging +import os +from concurrent.futures import ProcessPoolExecutor, as_completed +from multiprocessing import Manager +from typing import List, Literal, Optional, Tuple + +import cv2 +import numpy as np + +from .color import ColorPalette +from .config import DEFAULT_CHUNK_SIZE, ConfigResolver +from .exceptions import InvalidImageError +from .geometry import HexagonGrid, HexagonMask, average_color +from .layers import LayerRenderer +from .settings import HexifySettings, QuantizationMethod +from .styles import BorderStyle, FillStyle, apply_alpha_channel + +# Lazy import for matplotlib (heavy module) +_plt = None + + +def _get_plt(): + """Lazy load matplotlib.pyplot.""" + global _plt + if _plt is None: + import matplotlib.pyplot as plt + _plt = plt + return _plt + +# Module-level logger +logger = logging.getLogger(__name__) + + +class HexagonProcessor: + """ + Main processor for generating hexagonal pattern images. + + Orchestrates the full pipeline: + 1. Generate color palette from input image + 2. Set up hexagon grid layout + 3. Process each hexagon in parallel: + a. Sample average color from input + b. Generate multi-layer pattern + c. Composite onto output image + 4. Cache patterns by color for efficiency + + The processor can be configured in two ways: + 1. Traditional parameters (backward compatible): + processor = HexagonProcessor(num_palette_colors=16) + + 2. Settings-based configuration: + settings = HexifySettings(hex_width=128, num_layers=5) + processor = HexagonProcessor(settings=settings) + + When settings are provided, they take precedence over individual parameters. + + Attributes: + settings: HexifySettings configuration (or None for defaults) + num_palette_colors: Number of colors in the palette + num_processes: Number of parallel workers + hexagons_dir: Directory to save individual hexagon images + chunk_size: Number of hexagons per processing chunk + save_hexagons: Whether to save individual hexagon images + fast_mode: Whether to use MiniBatchKMeans for faster palette generation + output_format: Output image format - "RGB" (default) or "RGBA" for transparency + """ + + def __init__( + self, + num_palette_colors: int = 16, + num_processes: Optional[int] = None, + hexagons_dir: Optional[str] = None, + chunk_size: int = DEFAULT_CHUNK_SIZE, + save_hexagons: bool = True, + fast_mode: bool = False, + settings: Optional[HexifySettings] = None, + output_format: Literal["RGB", "RGBA"] = "RGB", + style_params: Optional[dict] = None + ): + """ + Initialize the hexagon processor. + + Args: + num_palette_colors: Number of colors to extract from image + num_processes: Number of parallel workers (default: CPU count) + hexagons_dir: Directory to save hexagon images + chunk_size: Hexagons per processing chunk + save_hexagons: Whether to save individual hexagons + fast_mode: If True, use MiniBatchKMeans for faster palette generation + settings: Optional HexifySettings for full configuration control. + When provided, overrides num_palette_colors, chunk_size, + and fast_mode parameters. + output_format: Output format - "RGB" (default) or "RGBA" for transparency. + When RGBA, pixels outside hexagons are fully transparent. + style_params: Optional dict of style parameters for LayerRenderer: + - border_width: Width of hexagon border in pixels (0 = none) + - border_color: Border color as (R, G, B) tuple + - border_style: BorderStyle enum value + - fill_style: FillStyle enum value + - noise_intensity: Noise texture intensity (0.0-1.0) + """ + # Store settings for access by other components + self.settings = settings + self._config = ConfigResolver(settings) + + # Determine effective values based on settings or parameters + if settings is not None: + self.num_palette_colors = settings.num_palette_colors + self.chunk_size = settings.chunk_size + self.fast_mode = settings.quantization_method == QuantizationMethod.MINIBATCH_KMEANS + else: + self.num_palette_colors = num_palette_colors + self.chunk_size = chunk_size + self.fast_mode = fast_mode + + # These are always taken from parameters (not in settings) + self.num_processes = num_processes or os.cpu_count() + self.hexagons_dir = hexagons_dir + self.save_hexagons = save_hexagons + + # Output format and style parameters + self.output_format = output_format + self.style_params = style_params or {} + + # Initialize components with settings + self.palette = ColorPalette( + self.num_palette_colors, + fast_mode=self.fast_mode, + settings=settings + ) + self.grid = HexagonGrid(settings=settings) + + # Shared cache for parallel processing + # Use Manager for shared objects (required for ProcessPoolExecutor) + # Note: Manager().Value has some IPC overhead but is required for + # sharing between processes spawned by ProcessPoolExecutor + manager = Manager() + self.hexagon_cache = manager.dict() + self.cache_lock = manager.Lock() + self.cache_hits = manager.Value('i', 0) + self.cache_misses = manager.Value('i', 0) + + # Will be set during processing + self.input_image = None + self._palette_generated = False + + @property + def hex_centers(self): + """Get hexagon centers from grid (for backward compatibility).""" + return self.grid.hex_centers + + @property + def palette_hash(self): + """Get palette hash (for backward compatibility).""" + return self.palette.palette_hash + + def generate_palette(self, image: np.ndarray) -> None: + """ + Generate the color palette from an image. + + Args: + image: Input image as numpy array (H, W, 3) in RGB format + """ + self.palette.generate_from_image(image) + self._palette_generated = True + + def setup_hexagon_grid(self, input_shape: tuple) -> None: + """ + Set up the hexagon grid based on input dimensions. + + Args: + input_shape: Shape of input image (height, width, channels) + """ + self.grid.setup(input_shape) + + def _validate_image(self, image: np.ndarray) -> None: + """ + Validate that the input image has the correct format. + + Args: + image: Image to validate + + Raises: + InvalidImageError: If the image format is invalid + """ + if not isinstance(image, np.ndarray): + raise InvalidImageError( + f"Expected numpy array, got {type(image).__name__}", + shape=None + ) + + if image.ndim != 3: + raise InvalidImageError( + f"Expected 3D array (H, W, C), got {image.ndim}D array", + shape=image.shape + ) + + if image.shape[2] != 3: + raise InvalidImageError( + f"Expected 3 channels (RGB), got {image.shape[2]} channels", + shape=image.shape + ) + + if image.shape[0] < 1 or image.shape[1] < 1: + raise InvalidImageError( + f"Image dimensions must be positive, got {image.shape[:2]}", + shape=image.shape + ) + + def process_image(self, input_image: np.ndarray, pbar=None) -> np.ndarray: + """ + Process an input image to generate hexagonal pattern output. + + This is the main entry point for image processing. It handles + palette generation, grid setup, and parallel hexagon processing. + + Args: + input_image: Input image as numpy array (H, W, 3) in RGB format + pbar: Optional progress bar to update + + Returns: + Output image as numpy array, HEX_SCALE_FACTOR times larger + + Raises: + InvalidImageError: If the input image has invalid format + """ + # Validate input + self._validate_image(input_image) + logger.info(f"Processing image with shape {input_image.shape}") + + self.input_image = input_image + + # Generate palette if not already done + if not self._palette_generated: + logger.debug("Generating color palette...") + self.generate_palette(input_image) + logger.debug(f"Palette generated with {self.num_palette_colors} colors") + + # Set up grid if not already done + if self.grid.hex_centers is None: + logger.debug("Setting up hexagon grid...") + self.setup_hexagon_grid(input_image.shape) + logger.debug(f"Grid created with {len(self.grid.hex_centers)} hexagons") + + return self._process_hexagons(pbar) + + def _process_hexagons(self, pbar=None) -> np.ndarray: + """ + Process all hexagons in parallel. + + Divides hexagon centers into chunks and processes them using + a process pool for parallelization. + + Args: + pbar: Optional progress bar + + Returns: + Complete output image (RGB or RGBA based on output_format) + """ + # Create output image with appropriate number of channels + if self.output_format == "RGBA": + # RGBA: 4 channels, alpha starts at 0 (fully transparent) + output_shape = (*self.grid.output_shape[:2], 4) + output_image = np.zeros(output_shape, dtype=np.uint8) + else: + # RGB: 3 channels (default) + output_image = np.zeros(self.grid.output_shape, dtype=np.uint8) + + # Track hexagon mask for RGBA alpha channel + if self.output_format == "RGBA": + combined_hex_mask = np.zeros(self.grid.output_shape[:2], dtype=np.uint8) + + # Divide into chunks for parallel processing + hex_center_chunks = [ + self.grid.hex_centers[i:i + self.chunk_size] + for i in range(0, len(self.grid.hex_centers), self.chunk_size) + ] + + logger.info(f"Processing {len(self.grid.hex_centers)} hexagons in {len(hex_center_chunks)} chunks") + logger.debug(f"Using {self.num_processes} worker processes") + + with ProcessPoolExecutor(max_workers=self.num_processes) as executor: + futures = [ + executor.submit(self._process_hexagon_chunk, chunk) + for chunk in hex_center_chunks + ] + + for future in as_completed(futures): + results = future.result() + for result in results: + if result: + x_start, y_start, x_end, y_end, hex_pattern_masked, mask_slice = result + # Composite hexagon onto output (RGB channels only) + hex_slice = output_image[y_start:y_end, x_start:x_end] + hex_slice[mask_slice != 0, :3] = hex_pattern_masked[mask_slice != 0] + + # For RGBA, also update alpha channel and track mask + if self.output_format == "RGBA": + hex_slice[mask_slice != 0, 3] = 255 # Fully opaque + combined_hex_mask[y_start:y_end, x_start:x_end][mask_slice != 0] = 255 + + if pbar: + pbar.update(self.chunk_size) + + logger.info(f"Processing complete. Cache hit rate: {self.get_cache_hit_rate():.1%}") + return output_image + + def _process_hexagon_chunk( + self, centers: List[Tuple[int, int]] + ) -> List[Optional[Tuple[int, int, int, int, np.ndarray, np.ndarray]]]: + """ + Process a chunk of hexagons. + + For each hexagon: + 1. Sample average color from input image + 2. Check cache for existing pattern + 3. Generate new pattern if not cached + 4. Prepare pattern for compositing + + Args: + centers: List of (center_x, center_y) tuples + + Returns: + List of results for each hexagon (or None for out-of-bounds) + """ + local_cache = {} + results = [] + + # Create layer renderer for this chunk with style parameters + renderer = LayerRenderer( + self.palette, + self.input_image, + settings=self.settings, + **self.style_params + ) + + for center_x, center_y in centers: + # Get clipped bounds + x_start, y_start, x_end, y_end = self.grid.get_hex_bounds(center_x, center_y) + + if x_start < x_end and y_start < y_end: + # Convert to input coordinates + input_center_x, input_center_y = self.grid.output_to_input_coords(center_x, center_y) + input_hex_radius = self.grid.get_input_hex_radius() + + # Sample average color from input + input_mask = HexagonMask.create( + input_center_x, input_center_y, input_hex_radius, self.input_image.shape[:2] + ) + avg_rgb = average_color(self.input_image, input_mask) + avg_rgb_key = tuple(map(int, avg_rgb)) + + # Check caches for existing pattern + if avg_rgb_key in local_cache: + hex_pattern = local_cache[avg_rgb_key] + with self.cache_lock: + self.cache_hits.value += 1 + elif avg_rgb_key in self.hexagon_cache: + hex_pattern = self.hexagon_cache[avg_rgb_key] + local_cache[avg_rgb_key] = hex_pattern + with self.cache_lock: + self.cache_hits.value += 1 + else: + # Generate new pattern + hex_pattern = renderer.create_hex_pattern( + center_x, center_y, self.grid.hex_radius, avg_rgb + ) + + # Cache the pattern + local_cache[avg_rgb_key] = hex_pattern + with self.cache_lock: + self.hexagon_cache[avg_rgb_key] = hex_pattern + self.cache_misses.value += 1 + + # Optionally save to disk + if self.save_hexagons and self.hexagons_dir: + hex_filename = f"{self.palette.palette_hash[:6]}_hexagon_{avg_rgb_key[0]:03d}_{avg_rgb_key[1]:03d}_{avg_rgb_key[2]:03d}.png" + hex_path = os.path.join(self.hexagons_dir, hex_filename) + _get_plt().imsave(hex_path, hex_pattern) + + # Create mask for compositing + full_mask = HexagonMask.create( + center_x, center_y, self.grid.hex_radius, self.grid.output_shape[:2] + ) + mask = full_mask[y_start:y_end, x_start:x_end] + + # Calculate pattern crop coordinates + pattern_y_start = y_start - (center_y - self.grid.hex_radius) + pattern_x_start = x_start - (center_x - self.grid.hex_radius) + pattern_y_end = pattern_y_start + (y_end - y_start) + pattern_x_end = pattern_x_start + (x_end - x_start) + + # Crop and mask the pattern + hex_pattern_cropped = hex_pattern[pattern_y_start:pattern_y_end, pattern_x_start:pattern_x_end] + hex_pattern_masked = cv2.bitwise_and(hex_pattern_cropped, hex_pattern_cropped, mask=mask) + + results.append((x_start, y_start, x_end, y_end, hex_pattern_masked, mask)) + else: + results.append(None) + + return results + + def get_cache_hit_rate(self) -> float: + """ + Calculate the cache hit rate. + + Returns: + Hit rate as float between 0 and 1 + """ + total = self.cache_hits.value + self.cache_misses.value + if total == 0: + return 0 + return self.cache_hits.value / total diff --git a/v2/settings.py b/v2/settings.py new file mode 100644 index 0000000..2e48874 --- /dev/null +++ b/v2/settings.py @@ -0,0 +1,314 @@ +""" +Configuration settings for Hexify. + +This module provides a configurable settings system that allows users to +customize hexagon generation parameters. Settings can be loaded from YAML +files or created programmatically. + +The settings system maintains backward compatibility - if no settings are +provided, the default values from config.py are used. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, Dict, Any, Tuple +import math + + +class ColorSpace(Enum): + """Color space for color matching algorithms.""" + RGB = "rgb" # Standard RGB color space (current default) + LAB = "lab" # CIE LAB - better perceptual matching + HSV = "hsv" # Hue-Saturation-Value space + + +class HexOrientation(Enum): + """Orientation of hexagons in the grid.""" + FLAT_TOP = "flat_top" # Current default (pi/2 rotation) + POINTY_TOP = "pointy_top" # No rotation (0 radians) + + +class QuantizationMethod(Enum): + """Method for color palette quantization.""" + KMEANS = "kmeans" # Standard K-means (current default) + MINIBATCH_KMEANS = "minibatch_kmeans" # Faster, slightly less accurate + + +@dataclass +class HexifySettings: + """ + Configuration settings for Hexify hexagon pattern generation. + + This dataclass provides a clean interface for configuring all aspects + of hexagon generation, from geometry to color processing. + + Attributes: + hex_width: Width of each hexagon in output pixels (default: 256) + hex_scale_factor: Output image scale factor from input (default: 16) + num_layers: Number of concentric layers in each hexagon (default: 7) + num_zones: Number of angular zones in even layers (default: 12) + orientation: Hexagon orientation - flat top or pointy top + color_space: Color space for color matching + quantization_method: Method for palette generation + border_width: Width of hexagon borders (0 = no border) + border_color: RGB color tuple for borders + chunk_size: Number of hexagons per processing chunk + num_palette_colors: Number of colors in the palette (default: 16) + kmeans_random_state: Random seed for K-means reproducibility + kmeans_n_init: Number of K-means initializations + max_palette_sample_pixels: Max pixels to sample for palette generation + brightness_threshold: Threshold for black/white background selection + + Example: + >>> settings = HexifySettings(hex_width=128, num_layers=5) + >>> processor = HexagonProcessor(settings=settings) + """ + + # Hexagon geometry + hex_width: int = 256 + hex_scale_factor: int = 16 + num_layers: int = 7 + num_zones: int = 12 + orientation: HexOrientation = HexOrientation.FLAT_TOP + + # Color settings + color_space: ColorSpace = ColorSpace.RGB + quantization_method: QuantizationMethod = QuantizationMethod.KMEANS + num_palette_colors: int = 16 + + # K-means settings + kmeans_random_state: int = 42 + kmeans_n_init: int = 10 + max_palette_sample_pixels: int = 1_000_000 + + # Style settings (for future use) + border_width: int = 0 + border_color: Tuple[int, int, int] = (0, 0, 0) + + # Processing settings + chunk_size: int = 100 + + # Brightness threshold for background selection + brightness_threshold: int = 128 + + # Layer diameter settings + layer_6_max_diameter: int = 256 + layer_6_min_diameter: int = 192 + inner_layer_diameter_range: int = 64 + + def __post_init__(self): + """Validate settings after initialization.""" + if self.hex_width < 16: + raise ValueError("hex_width must be at least 16") + if self.hex_scale_factor < 1: + raise ValueError("hex_scale_factor must be positive") + if self.num_layers < 1: + raise ValueError("num_layers must be at least 1") + if self.num_zones < 2: + raise ValueError("num_zones must be at least 2") + if self.num_palette_colors < 2: + raise ValueError("num_palette_colors must be at least 2") + if self.chunk_size < 1: + raise ValueError("chunk_size must be positive") + + @property + def hex_height(self) -> int: + """Calculate hexagon height for regular hexagon proportions.""" + return round(self.hex_width * (math.sqrt(3) / 2)) + + @property + def hex_radius(self) -> int: + """Calculate hexagon radius (half of width).""" + return self.hex_width // 2 + + @property + def hex_horizontal_spacing(self) -> float: + """Calculate horizontal spacing between hexagon centers.""" + return self.hex_width * 0.75 + + @property + def hex_vertical_spacing(self) -> int: + """Calculate vertical spacing between hexagon centers.""" + return self.hex_height + + @property + def hex_orientation_radians(self) -> float: + """Get hexagon orientation in radians.""" + if self.orientation == HexOrientation.FLAT_TOP: + return math.pi / 2 + else: # POINTY_TOP + return 0.0 + + @property + def base_zone_angle(self) -> float: + """Calculate base angle per zone in degrees.""" + return 360 / self.num_zones + + def to_dict(self) -> Dict[str, Any]: + """ + Convert settings to a dictionary. + + Enum values are converted to their string values for serialization. + + Returns: + Dictionary representation of settings + """ + return { + 'hex_width': self.hex_width, + 'hex_scale_factor': self.hex_scale_factor, + 'num_layers': self.num_layers, + 'num_zones': self.num_zones, + 'orientation': self.orientation.value, + 'color_space': self.color_space.value, + 'quantization_method': self.quantization_method.value, + 'num_palette_colors': self.num_palette_colors, + 'kmeans_random_state': self.kmeans_random_state, + 'kmeans_n_init': self.kmeans_n_init, + 'max_palette_sample_pixels': self.max_palette_sample_pixels, + 'border_width': self.border_width, + 'border_color': list(self.border_color), + 'chunk_size': self.chunk_size, + 'brightness_threshold': self.brightness_threshold, + 'layer_6_max_diameter': self.layer_6_max_diameter, + 'layer_6_min_diameter': self.layer_6_min_diameter, + 'inner_layer_diameter_range': self.inner_layer_diameter_range, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'HexifySettings': + """ + Create settings from a dictionary. + + Enum string values are converted back to enum instances. + + Args: + data: Dictionary with settings values + + Returns: + HexifySettings instance + """ + # Make a copy to avoid modifying the input + data = data.copy() + + # Convert enum string values to enum instances + if 'orientation' in data and isinstance(data['orientation'], str): + data['orientation'] = HexOrientation(data['orientation']) + if 'color_space' in data and isinstance(data['color_space'], str): + data['color_space'] = ColorSpace(data['color_space']) + if 'quantization_method' in data and isinstance(data['quantization_method'], str): + data['quantization_method'] = QuantizationMethod(data['quantization_method']) + + # Convert border_color list to tuple if needed + if 'border_color' in data and isinstance(data['border_color'], list): + data['border_color'] = tuple(data['border_color']) + + return cls(**data) + + @classmethod + def from_yaml(cls, path: str) -> 'HexifySettings': + """ + Load settings from a YAML file. + + Requires PyYAML to be installed. Falls back to JSON if YAML is + not available. + + Args: + path: Path to YAML configuration file + + Returns: + HexifySettings instance + + Raises: + ImportError: If PyYAML is not installed + FileNotFoundError: If the file does not exist + """ + try: + import yaml + except ImportError: + raise ImportError( + "PyYAML is required for YAML config file support. " + "Install it with: pip install pyyaml" + ) + + with open(path, 'r') as f: + data = yaml.safe_load(f) + + return cls.from_dict(data) + + def to_yaml(self, path: str) -> None: + """ + Save settings to a YAML file. + + Requires PyYAML to be installed. + + Args: + path: Path to save the YAML configuration file + + Raises: + ImportError: If PyYAML is not installed + """ + try: + import yaml + except ImportError: + raise ImportError( + "PyYAML is required for YAML config file support. " + "Install it with: pip install pyyaml" + ) + + with open(path, 'w') as f: + yaml.dump(self.to_dict(), f, default_flow_style=False, sort_keys=False) + + @classmethod + def from_json(cls, path: str) -> 'HexifySettings': + """ + Load settings from a JSON file. + + Args: + path: Path to JSON configuration file + + Returns: + HexifySettings instance + + Raises: + FileNotFoundError: If the file does not exist + """ + import json + + with open(path, 'r') as f: + data = json.load(f) + + return cls.from_dict(data) + + def to_json(self, path: str, indent: int = 2) -> None: + """ + Save settings to a JSON file. + + Args: + path: Path to save the JSON configuration file + indent: Indentation level for pretty printing + """ + import json + + with open(path, 'w') as f: + json.dump(self.to_dict(), f, indent=indent) + + def copy(self, **overrides) -> 'HexifySettings': + """ + Create a copy of settings with optional overrides. + + Args: + **overrides: Setting values to override in the copy + + Returns: + New HexifySettings instance with overrides applied + """ + data = self.to_dict() + + # Convert enums back for from_dict processing + for key, value in overrides.items(): + if isinstance(value, Enum): + data[key] = value.value + else: + data[key] = value + + return self.from_dict(data) diff --git a/v2/styles.py b/v2/styles.py new file mode 100644 index 0000000..1d74110 --- /dev/null +++ b/v2/styles.py @@ -0,0 +1,472 @@ +""" +Style augmentation for hexagon patterns. + +This module provides style rendering utilities for enhancing hexagon patterns +with borders, gradients, noise textures, and transparency effects. + +All style features are opt-in to maintain backward compatibility with the +existing rendering pipeline. +""" + +import numpy as np +import cv2 +from typing import Tuple, Optional, Dict, Any +from enum import Enum + + +class BorderStyle(Enum): + """Border styles for hexagon edges.""" + NONE = "none" + SOLID = "solid" + DOUBLE = "double" + GLOW = "glow" + + +class FillStyle(Enum): + """Fill styles for hexagon patterns.""" + SOLID = "solid" # Current default + RADIAL_GRADIENT = "radial_gradient" + LINEAR_GRADIENT = "linear_gradient" + + +def add_hexagon_border( + pattern: np.ndarray, + hex_coords: np.ndarray, + border_width: int = 2, + border_color: Tuple[int, int, int] = (0, 0, 0), + border_style: BorderStyle = BorderStyle.SOLID +) -> np.ndarray: + """ + Add a border around the hexagon edge. + + Draws a border along the hexagon perimeter using the specified style. + The border is drawn on top of the existing pattern. + + Args: + pattern: The pattern array to draw on (H, W, 3 or 4) + hex_coords: Array of hexagon vertex coordinates, shape (N, 2) + border_width: Width of the border in pixels + border_color: Border color as (R, G, B) tuple + border_style: Style of the border (SOLID, DOUBLE, or GLOW) + + Returns: + Pattern with border applied + """ + if border_style == BorderStyle.NONE or border_width <= 0: + return pattern + + result = pattern.copy() + coords = hex_coords.astype(np.int32) + + if border_style == BorderStyle.SOLID: + # Simple solid border + cv2.polylines(result, [coords], isClosed=True, color=border_color, thickness=border_width) + + elif border_style == BorderStyle.DOUBLE: + # Double line border - outer and inner lines + outer_width = border_width + inner_width = max(1, border_width // 2) + gap = max(1, border_width // 2) + + # Draw outer line + cv2.polylines(result, [coords], isClosed=True, color=border_color, thickness=outer_width) + + # Calculate inner hexagon coords (scaled down) + center = np.mean(coords, axis=0) + scale_factor = 1 - (outer_width + gap) / np.linalg.norm(coords[0] - center) + inner_coords = (center + (coords - center) * scale_factor).astype(np.int32) + cv2.polylines(result, [inner_coords], isClosed=True, color=border_color, thickness=inner_width) + + elif border_style == BorderStyle.GLOW: + # Glow effect - multiple fading layers + num_layers = min(border_width, 5) + for i in range(num_layers, 0, -1): + # Calculate alpha for this layer (fades outward) + alpha = i / num_layers + layer_width = border_width - (num_layers - i) + + if layer_width > 0: + # Blend color toward white for glow effect + glow_color = tuple( + int(c * alpha + 255 * (1 - alpha) * 0.5) + for c in border_color + ) + cv2.polylines(result, [coords], isClosed=True, color=glow_color, thickness=layer_width) + + # Draw the core border + cv2.polylines(result, [coords], isClosed=True, color=border_color, thickness=1) + + return result + + +def apply_radial_gradient( + pattern: np.ndarray, + center: Tuple[int, int], + radius: int, + inner_color: Tuple[int, int, int], + outer_color: Tuple[int, int, int], + mask: Optional[np.ndarray] = None +) -> np.ndarray: + """ + Apply a radial gradient fill. + + Creates a smooth color transition from the center outward. + If a mask is provided, the gradient is only applied within the masked region. + + Args: + pattern: The pattern array to modify (H, W, 3 or 4) + center: Center point of the gradient (x, y) + radius: Radius of the gradient in pixels + inner_color: Color at the center as (R, G, B) + outer_color: Color at the edge as (R, G, B) + mask: Optional binary mask (H, W) where gradient is applied + + Returns: + Pattern with radial gradient applied + """ + result = pattern.copy() + h, w = pattern.shape[:2] + + # Create coordinate grids + y, x = np.ogrid[:h, :w] + + # Calculate distance from center for each pixel + dist = np.sqrt((x - center[0])**2 + (y - center[1])**2) + + # Normalize distance to [0, 1] range + normalized_dist = np.clip(dist / max(radius, 1), 0, 1) + + # Interpolate colors + gradient = np.zeros((h, w, 3), dtype=np.float32) + for i in range(3): + gradient[:, :, i] = ( + inner_color[i] * (1 - normalized_dist) + + outer_color[i] * normalized_dist + ) + + gradient = gradient.astype(np.uint8) + + # Apply with mask if provided + if mask is not None: + mask_bool = mask > 0 + # Handle both RGB and RGBA patterns + for i in range(3): + result[:, :, i] = np.where(mask_bool, gradient[:, :, i], result[:, :, i]) + else: + result[:, :, :3] = gradient + + return result + + +def apply_linear_gradient( + pattern: np.ndarray, + start_point: Tuple[int, int], + end_point: Tuple[int, int], + start_color: Tuple[int, int, int], + end_color: Tuple[int, int, int], + mask: Optional[np.ndarray] = None +) -> np.ndarray: + """ + Apply a linear gradient fill. + + Creates a smooth color transition along a line from start to end point. + If a mask is provided, the gradient is only applied within the masked region. + + Args: + pattern: The pattern array to modify (H, W, 3 or 4) + start_point: Start point of the gradient (x, y) + end_point: End point of the gradient (x, y) + start_color: Color at the start as (R, G, B) + end_color: Color at the end as (R, G, B) + mask: Optional binary mask (H, W) where gradient is applied + + Returns: + Pattern with linear gradient applied + """ + result = pattern.copy() + h, w = pattern.shape[:2] + + # Calculate gradient direction vector + dx = end_point[0] - start_point[0] + dy = end_point[1] - start_point[1] + length = np.sqrt(dx**2 + dy**2) + + if length < 1: + return result + + # Normalize direction + dx /= length + dy /= length + + # Create coordinate grids + y, x = np.ogrid[:h, :w] + + # Project each pixel onto the gradient line + # Distance along the gradient direction from start point + proj_dist = (x - start_point[0]) * dx + (y - start_point[1]) * dy + + # Normalize to [0, 1] range + normalized_dist = np.clip(proj_dist / length, 0, 1) + + # Interpolate colors + gradient = np.zeros((h, w, 3), dtype=np.float32) + for i in range(3): + gradient[:, :, i] = ( + start_color[i] * (1 - normalized_dist) + + end_color[i] * normalized_dist + ) + + gradient = gradient.astype(np.uint8) + + # Apply with mask if provided + if mask is not None: + mask_bool = mask > 0 + for i in range(3): + result[:, :, i] = np.where(mask_bool, gradient[:, :, i], result[:, :, i]) + else: + result[:, :, :3] = gradient + + return result + + +def add_noise_texture( + pattern: np.ndarray, + intensity: float = 0.1, + mask: Optional[np.ndarray] = None +) -> np.ndarray: + """ + Add subtle noise/grain for artistic effect. + + Applies random noise to the pattern to create a textured appearance. + The noise is additive and respects the color channel ranges [0, 255]. + + Args: + pattern: The pattern array to modify (H, W, 3 or 4) + intensity: Noise intensity from 0.0 (none) to 1.0 (maximum). + Values around 0.05-0.15 work well for subtle texture. + mask: Optional binary mask (H, W) where noise is applied + + Returns: + Pattern with noise texture applied + """ + if intensity <= 0: + return pattern + + result = pattern.copy() + h, w = pattern.shape[:2] + + # Clamp intensity to reasonable range + intensity = min(max(intensity, 0), 1.0) + + # Generate random noise + # Scale is based on intensity - max deviation of ~25 at intensity=0.1 + noise_scale = intensity * 255 * 0.1 + noise = np.random.randn(h, w, 3) * noise_scale + + # Apply noise to RGB channels + if mask is not None: + mask_3d = np.expand_dims(mask > 0, axis=2) + noisy = result[:, :, :3].astype(np.float32) + noise * mask_3d + else: + noisy = result[:, :, :3].astype(np.float32) + noise + + # Clip to valid range + noisy = np.clip(noisy, 0, 255).astype(np.uint8) + result[:, :, :3] = noisy + + return result + + +def apply_alpha_channel( + pattern: np.ndarray, + mask: np.ndarray, + background_alpha: int = 0 +) -> np.ndarray: + """ + Convert RGB pattern to RGBA with transparency. + + Creates an RGBA image where the alpha channel is determined by the mask. + Pixels inside the mask are fully opaque, pixels outside have the specified + background alpha value. + + Args: + pattern: The pattern array (H, W, 3) in RGB format + mask: Binary mask (H, W) defining the opaque region + background_alpha: Alpha value for pixels outside the mask (0-255). + 0 = fully transparent, 255 = fully opaque. + + Returns: + Pattern as RGBA image (H, W, 4) + """ + h, w = pattern.shape[:2] + + # Create RGBA output + if pattern.shape[2] == 4: + result = pattern.copy() + else: + result = np.zeros((h, w, 4), dtype=np.uint8) + result[:, :, :3] = pattern[:, :, :3] + + # Create alpha channel from mask + alpha = np.where(mask > 0, 255, background_alpha).astype(np.uint8) + result[:, :, 3] = alpha + + return result + + +def apply_vignette( + pattern: np.ndarray, + center: Tuple[int, int], + radius: int, + strength: float = 0.3, + mask: Optional[np.ndarray] = None +) -> np.ndarray: + """ + Apply a vignette effect (darkening towards edges). + + Creates a subtle darkening effect that increases with distance from center. + + Args: + pattern: The pattern array to modify (H, W, 3 or 4) + center: Center point of the vignette (x, y) + radius: Radius where darkening begins + strength: Vignette strength from 0.0 (none) to 1.0 (maximum darkness) + mask: Optional binary mask (H, W) where vignette is applied + + Returns: + Pattern with vignette effect applied + """ + if strength <= 0: + return pattern + + result = pattern.copy() + h, w = pattern.shape[:2] + + # Create coordinate grids + y, x = np.ogrid[:h, :w] + + # Calculate distance from center + dist = np.sqrt((x - center[0])**2 + (y - center[1])**2) + + # Calculate vignette factor (1 at center, decreasing toward edges) + vignette = 1 - np.clip((dist / max(radius, 1)) * strength, 0, strength) + + # Apply vignette to RGB channels + if mask is not None: + mask_bool = mask > 0 + for i in range(3): + darkened = (result[:, :, i] * vignette).astype(np.uint8) + result[:, :, i] = np.where(mask_bool, darkened, result[:, :, i]) + else: + for i in range(3): + result[:, :, i] = (result[:, :, i] * vignette).astype(np.uint8) + + return result + + +# ============================================================================= +# Style Effect Presets +# ============================================================================= + +STYLE_EFFECTS: Dict[str, Dict[str, Any]] = { + "classic": {}, # No effects, current behavior + "outlined": { + "border_width": 2, + "border_color": (0, 0, 0), + "border_style": BorderStyle.SOLID + }, + "glowing": { + "border_width": 3, + "border_style": BorderStyle.GLOW, + "border_color": (255, 255, 255) + }, + "textured": { + "noise_intensity": 0.05 + }, + "neon": { + "border_width": 2, + "border_color": (255, 255, 255), + "border_style": BorderStyle.GLOW, + "noise_intensity": 0.02 + }, + "vintage": { + "noise_intensity": 0.08, + "vignette_strength": 0.2 + }, + "double_border": { + "border_width": 4, + "border_color": (0, 0, 0), + "border_style": BorderStyle.DOUBLE + }, +} + + +def get_style_preset(name: str) -> Dict[str, Any]: + """ + Get a style preset by name. + + Args: + name: Name of the style preset + + Returns: + Dictionary of style parameters + + Raises: + ValueError: If the preset name is not found + """ + if name not in STYLE_EFFECTS: + available = ", ".join(STYLE_EFFECTS.keys()) + raise ValueError(f"Unknown style preset '{name}'. Available: {available}") + return STYLE_EFFECTS[name].copy() + + +def apply_style_preset( + pattern: np.ndarray, + hex_coords: np.ndarray, + center: Tuple[int, int], + radius: int, + preset_name: str, + mask: Optional[np.ndarray] = None +) -> np.ndarray: + """ + Apply a named style preset to a hexagon pattern. + + Convenience function that applies all effects from a preset. + + Args: + pattern: The pattern array to modify + hex_coords: Hexagon vertex coordinates + center: Center point of the hexagon + radius: Radius of the hexagon + preset_name: Name of the style preset to apply + mask: Optional binary mask for effects + + Returns: + Pattern with style preset applied + """ + style = get_style_preset(preset_name) + + if not style: + return pattern + + result = pattern.copy() + + # Apply noise if specified + if "noise_intensity" in style: + result = add_noise_texture(result, style["noise_intensity"], mask) + + # Apply vignette if specified + if "vignette_strength" in style: + result = apply_vignette(result, center, radius, style["vignette_strength"], mask) + + # Apply border if specified + if "border_width" in style: + result = add_hexagon_border( + result, + hex_coords, + border_width=style.get("border_width", 2), + border_color=style.get("border_color", (0, 0, 0)), + border_style=style.get("border_style", BorderStyle.SOLID) + ) + + return result diff --git a/v2/tests/__init__.py b/v2/tests/__init__.py new file mode 100644 index 0000000..e9cc4ce --- /dev/null +++ b/v2/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Hexify v2.""" diff --git a/v2/tests/test_benchmark.py b/v2/tests/test_benchmark.py new file mode 100644 index 0000000..9ab7799 --- /dev/null +++ b/v2/tests/test_benchmark.py @@ -0,0 +1,395 @@ +""" +Performance benchmarks for Hexify v2. + +This module provides benchmarks for: +- Palette generation with KMeans vs MiniBatchKMeans +- Full image processing pipeline +- Cache hit rate analysis + +Usage: + pytest v2/tests/test_benchmark.py -v + pytest v2/tests/test_benchmark.py -v --benchmark-only # with pytest-benchmark + +Note: Tests use synthetic images to ensure reproducibility across environments. +""" + +import time +from typing import Tuple + +import numpy as np +import pytest + +# Import from parent package +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from v2.color import ColorPalette +from v2.processor import HexagonProcessor + + +def create_test_image(size: Tuple[int, int] = (100, 100), seed: int = 42) -> np.ndarray: + """ + Create a synthetic test image with various color regions. + + Args: + size: Image size as (height, width) + seed: Random seed for reproducibility + + Returns: + RGB image as numpy array + """ + np.random.seed(seed) + height, width = size + + # Create a gradient background + image = np.zeros((height, width, 3), dtype=np.uint8) + + # Add horizontal gradient + for x in range(width): + image[:, x, 0] = int(255 * x / width) # Red gradient + + # Add vertical gradient + for y in range(height): + image[y, :, 1] = int(255 * y / height) # Green gradient + + # Add some random regions for color diversity + for _ in range(5): + x1 = np.random.randint(0, width - 20) + y1 = np.random.randint(0, height - 20) + x2 = x1 + np.random.randint(10, 20) + y2 = y1 + np.random.randint(10, 20) + color = np.random.randint(0, 256, 3) + image[y1:y2, x1:x2] = color + + # Add blue channel based on position + image[:, :, 2] = ((image[:, :, 0].astype(int) + image[:, :, 1].astype(int)) // 2).astype(np.uint8) + + return image + + +class TestPaletteGenerationBenchmark: + """Benchmarks for palette generation comparing KMeans and MiniBatchKMeans.""" + + @pytest.fixture + def small_image(self): + """Small test image (100x100).""" + return create_test_image((100, 100)) + + @pytest.fixture + def medium_image(self): + """Medium test image (500x500).""" + return create_test_image((500, 500)) + + @pytest.fixture + def large_image(self): + """Large test image (1000x1000).""" + return create_test_image((1000, 1000)) + + def test_kmeans_small_image(self, small_image): + """Benchmark KMeans palette generation on small image.""" + palette = ColorPalette(num_colors=16, fast_mode=False) + + start = time.perf_counter() + palette.generate_from_image(small_image) + elapsed = time.perf_counter() - start + + assert palette.colors is not None + assert len(palette.colors) == 16 + print(f"\nKMeans (100x100): {elapsed:.4f}s") + + def test_minibatch_kmeans_small_image(self, small_image): + """Benchmark MiniBatchKMeans palette generation on small image.""" + palette = ColorPalette(num_colors=16, fast_mode=True) + + start = time.perf_counter() + palette.generate_from_image(small_image) + elapsed = time.perf_counter() - start + + assert palette.colors is not None + assert len(palette.colors) == 16 + print(f"\nMiniBatchKMeans (100x100): {elapsed:.4f}s") + + def test_kmeans_medium_image(self, medium_image): + """Benchmark KMeans palette generation on medium image.""" + palette = ColorPalette(num_colors=16, fast_mode=False) + + start = time.perf_counter() + palette.generate_from_image(medium_image) + elapsed = time.perf_counter() - start + + assert palette.colors is not None + assert len(palette.colors) == 16 + print(f"\nKMeans (500x500): {elapsed:.4f}s") + + def test_minibatch_kmeans_medium_image(self, medium_image): + """Benchmark MiniBatchKMeans palette generation on medium image.""" + palette = ColorPalette(num_colors=16, fast_mode=True) + + start = time.perf_counter() + palette.generate_from_image(medium_image) + elapsed = time.perf_counter() - start + + assert palette.colors is not None + assert len(palette.colors) == 16 + print(f"\nMiniBatchKMeans (500x500): {elapsed:.4f}s") + + def test_kmeans_large_image(self, large_image): + """Benchmark KMeans palette generation on large image.""" + palette = ColorPalette(num_colors=16, fast_mode=False) + + start = time.perf_counter() + palette.generate_from_image(large_image) + elapsed = time.perf_counter() - start + + assert palette.colors is not None + assert len(palette.colors) == 16 + print(f"\nKMeans (1000x1000): {elapsed:.4f}s") + + def test_minibatch_kmeans_large_image(self, large_image): + """Benchmark MiniBatchKMeans palette generation on large image.""" + palette = ColorPalette(num_colors=16, fast_mode=True) + + start = time.perf_counter() + palette.generate_from_image(large_image) + elapsed = time.perf_counter() - start + + assert palette.colors is not None + assert len(palette.colors) == 16 + print(f"\nMiniBatchKMeans (1000x1000): {elapsed:.4f}s") + + def test_palette_quality_comparison(self, medium_image): + """Compare palette quality between KMeans and MiniBatchKMeans.""" + palette_kmeans = ColorPalette(num_colors=16, fast_mode=False) + palette_minibatch = ColorPalette(num_colors=16, fast_mode=True) + + palette_kmeans.generate_from_image(medium_image) + palette_minibatch.generate_from_image(medium_image) + + # Both should produce valid palettes + assert palette_kmeans.colors is not None + assert palette_minibatch.colors is not None + assert len(palette_kmeans.colors) == 16 + assert len(palette_minibatch.colors) == 16 + + # Colors should be in valid range + assert np.all(palette_kmeans.colors >= 0) + assert np.all(palette_kmeans.colors <= 255) + assert np.all(palette_minibatch.colors >= 0) + assert np.all(palette_minibatch.colors <= 255) + + # Palettes may differ slightly due to different algorithms + # but should have similar color distributions + print(f"\nKMeans palette hash: {palette_kmeans.palette_hash[:16]}") + print(f"MiniBatchKMeans palette hash: {palette_minibatch.palette_hash[:16]}") + + +class TestImageProcessingBenchmark: + """Benchmarks for full image processing pipeline.""" + + @pytest.fixture + def small_image(self): + """Small test image for processing.""" + return create_test_image((50, 50)) + + def test_process_image_standard_mode(self, small_image): + """Benchmark full image processing with standard KMeans.""" + processor = HexagonProcessor( + num_palette_colors=8, + num_processes=1, + save_hexagons=False, + fast_mode=False + ) + + start = time.perf_counter() + output = processor.process_image(small_image) + elapsed = time.perf_counter() - start + + assert output is not None + assert output.shape[0] > small_image.shape[0] # Should be scaled up + assert output.shape[1] > small_image.shape[1] + + print(f"\nStandard mode (50x50): {elapsed:.4f}s") + print(f"Cache hit rate: {processor.get_cache_hit_rate():.2%}") + + def test_process_image_fast_mode(self, small_image): + """Benchmark full image processing with fast MiniBatchKMeans.""" + processor = HexagonProcessor( + num_palette_colors=8, + num_processes=1, + save_hexagons=False, + fast_mode=True + ) + + start = time.perf_counter() + output = processor.process_image(small_image) + elapsed = time.perf_counter() - start + + assert output is not None + assert output.shape[0] > small_image.shape[0] + assert output.shape[1] > small_image.shape[1] + + print(f"\nFast mode (50x50): {elapsed:.4f}s") + print(f"Cache hit rate: {processor.get_cache_hit_rate():.2%}") + + def test_cache_effectiveness(self, small_image): + """Test that cache is working effectively.""" + processor = HexagonProcessor( + num_palette_colors=8, + num_processes=1, + save_hexagons=False, + fast_mode=True + ) + + # Process the image + processor.process_image(small_image) + + # Get cache statistics + hits = processor.cache_hits.value + misses = processor.cache_misses.value + hit_rate = processor.get_cache_hit_rate() + + print(f"\nCache statistics:") + print(f" Hits: {hits}") + print(f" Misses: {misses}") + print(f" Hit rate: {hit_rate:.2%}") + + # For a typical image, we should have some cache hits + # (identical colors in different regions reuse patterns) + assert hits + misses > 0, "No cache operations recorded" + + +class TestSpeedComparison: + """Direct speed comparisons between optimization modes.""" + + def test_palette_speedup(self): + """Measure speedup from using MiniBatchKMeans.""" + image = create_test_image((500, 500)) + + # Standard KMeans + palette_std = ColorPalette(num_colors=16, fast_mode=False) + start = time.perf_counter() + palette_std.generate_from_image(image) + time_std = time.perf_counter() - start + + # MiniBatchKMeans + palette_fast = ColorPalette(num_colors=16, fast_mode=True) + start = time.perf_counter() + palette_fast.generate_from_image(image) + time_fast = time.perf_counter() - start + + speedup = time_std / time_fast if time_fast > 0 else float('inf') + + print(f"\nPalette generation speedup comparison:") + print(f" Standard KMeans: {time_std:.4f}s") + print(f" MiniBatch KMeans: {time_fast:.4f}s") + print(f" Speedup: {speedup:.2f}x") + + # MiniBatchKMeans should generally be faster (or at worst similar) + # We don't assert specific speedup as it varies by system + assert palette_std.colors is not None + assert palette_fast.colors is not None + + +# Optional: pytest-benchmark compatible benchmarks +# These will run if pytest-benchmark is installed +try: + import pytest_benchmark + + class TestBenchmarkPlugin: + """Benchmarks using pytest-benchmark plugin.""" + + def test_benchmark_kmeans(self, benchmark): + """Benchmark KMeans with pytest-benchmark.""" + image = create_test_image((200, 200)) + palette = ColorPalette(num_colors=16, fast_mode=False) + + def run(): + palette.colors = None # Reset + palette.generate_from_image(image) + + benchmark(run) + assert palette.colors is not None + + def test_benchmark_minibatch_kmeans(self, benchmark): + """Benchmark MiniBatchKMeans with pytest-benchmark.""" + image = create_test_image((200, 200)) + palette = ColorPalette(num_colors=16, fast_mode=True) + + def run(): + palette.colors = None # Reset + palette.generate_from_image(image) + + benchmark(run) + assert palette.colors is not None + +except ImportError: + # pytest-benchmark not installed, skip these tests + pass + + +if __name__ == "__main__": + # Run basic benchmarks when executed directly + print("=" * 60) + print("Hexify v2 Performance Benchmarks") + print("=" * 60) + + # Create test images + small = create_test_image((100, 100)) + medium = create_test_image((500, 500)) + large = create_test_image((1000, 1000)) + + print("\n--- Palette Generation Benchmarks ---") + + for size_name, image in [("100x100", small), ("500x500", medium), ("1000x1000", large)]: + print(f"\nImage size: {size_name}") + + # Standard KMeans + palette = ColorPalette(num_colors=16, fast_mode=False) + start = time.perf_counter() + palette.generate_from_image(image) + std_time = time.perf_counter() - start + + # MiniBatchKMeans + palette = ColorPalette(num_colors=16, fast_mode=True) + start = time.perf_counter() + palette.generate_from_image(image) + fast_time = time.perf_counter() - start + + speedup = std_time / fast_time if fast_time > 0 else 0 + print(f" Standard: {std_time:.4f}s | Fast: {fast_time:.4f}s | Speedup: {speedup:.2f}x") + + print("\n--- Image Processing Benchmark ---") + + test_image = create_test_image((50, 50)) + + # Standard mode + processor = HexagonProcessor( + num_palette_colors=8, + num_processes=1, + save_hexagons=False, + fast_mode=False + ) + start = time.perf_counter() + output = processor.process_image(test_image) + std_time = time.perf_counter() - start + std_cache_rate = processor.get_cache_hit_rate() + + # Fast mode + processor = HexagonProcessor( + num_palette_colors=8, + num_processes=1, + save_hexagons=False, + fast_mode=True + ) + start = time.perf_counter() + output = processor.process_image(test_image) + fast_time = time.perf_counter() - start + fast_cache_rate = processor.get_cache_hit_rate() + + print(f"Processing 50x50 image:") + print(f" Standard: {std_time:.4f}s (cache: {std_cache_rate:.2%})") + print(f" Fast: {fast_time:.4f}s (cache: {fast_cache_rate:.2%})") + + print("\n" + "=" * 60) + print("Benchmarks complete!")