Skip to main content

Perceptual photomosaic generator — Oklab color matching, MKL optimal transport, Hungarian placement, and Oklch tile-pool expansion + recoloring

Project description

mosaicraft

A Python photomosaic generator built on the Oklab perceptual color space, MKL optimal transport, Laplacian pyramid blending, and Oklch recoloring.

PyPI version Python CI License: MIT Code style: ruff

日本語 README

Target vs mosaicraft output


mosaicraft rebuilds a target image as a grid of smaller tile photographs. Most photomosaic libraries use mean-color matching in RGB or HSV. mosaicraft does something different: every step of the pipeline runs in a perceptual color space, and every cell of the output is a distinct photograph.

What's inside:

  • Oklab perceptual color space — roughly 8.5× more perceptually uniform than CIELAB for chroma, at the same compute cost.
  • MKL optimal transport color transfer — matches the full covariance of each tile's color distribution to the target, preserving the shape of the original tile instead of flattening it.
  • Hungarian 1:1 placement — globally optimal assignment of tiles to cells via the Jonker–Volgenant algorithm. Falls back to FAISS + Floyd–Steinberg error diffusion when the cost matrix exceeds memory.
  • Laplacian pyramid blending — removes grid lines without blurring detail.
  • Oklch tile-pool expansion — generates N hue-rotated variants of every tile in the pool, multiplying the effective catalog size by (N+1) with zero extra photographs.
  • Oklch whole-image recoloring — rotates the finished mosaic through 20+ named presets (or any #RRGGBB) while preserving every tile's lightness exactly, so the result has no boundary artifacts.

The hero image above is reproducible from this repository. python scripts/download_demo_assets.py fetches ~8 MB of public-domain paintings and CC0 tiles; python scripts/generate_readme_figures.py then writes every image in this README.

Installation

pip install mosaicraft                # PyPI
pip install "mosaicraft[faiss]"       # with FAISS for huge tile pools

Requires Python 3.9+, NumPy ≥ 1.23, OpenCV ≥ 4.6, SciPy ≥ 1.10, scikit-image ≥ 0.20. No GPU required; FAISS is optional.

Quick start

CLI

# Basic: target image + tile directory.
mosaicraft generate photo.jpg --tiles ./tiles --output mosaic.jpg

# Pick a preset and target cell count.
mosaicraft generate photo.jpg -t ./tiles -o vivid.jpg --preset vivid -n 5000

# Expand a 1,024-tile pool into 5,120 candidates with Oklch hue rotation.
mosaicraft generate photo.jpg -t ./tiles -o big.jpg --color-variants 4

# Pre-build a feature cache so subsequent runs load in under a second.
mosaicraft cache --tiles ./tiles --cache-dir ./cache --sizes 56 88 120

# Recolor a finished mosaic in Oklab (no regeneration).
mosaicraft recolor mosaic.jpg -o mosaic_blue.jpg  --preset blue
mosaicraft recolor mosaic.jpg -o mosaic_sepia.jpg --preset sepia
mosaicraft recolor mosaic.jpg -o mosaic_brand.jpg --hex "#3b82f6"

# List all presets.
mosaicraft presets
mosaicraft recolor-presets

Before and after

Target: Vermeer, Girl with a Pearl Earring (1,366 × 1,600 px). 1,024-image CC0 tile pool × 4 augmentations = 4,096 candidates. 52 × 61 = 3,172 cells. Preset ultra.

Python API

from mosaicraft import MosaicGenerator, recolor

gen = MosaicGenerator(
    tile_dir="./tiles",
    preset="ultra",
    color_variants=4,              # 1,024 tiles -> 5,120 candidates
)
result = gen.generate("photo.jpg", "mosaic.jpg", target_tiles=5000)

# Then recolor the finished mosaic without regenerating anything.
recolor("mosaic.jpg", "mosaic_blue.jpg", preset="blue")
recolor("mosaic.jpg", "mosaic_sepia.jpg", preset="sepia")

Pipeline

                  ┌─────────────────────┐
                  │  Tile collection    │
                  └──────────┬──────────┘
                             │
                  ┌──────────▼──────────┐    ┌────────────────────┐
                  │  Feature extraction │───▶│ 4x geometric aug.  │
                  │   (191 dimensions)  │    │ + Oklch variants   │
                  └──────────┬──────────┘    └─────────┬──────────┘
                             │                         │
                             └────────────┬────────────┘
                                          │
   ┌────────────────────┐       ┌─────────▼───────────┐
   │  Target image      │──────▶│  Per-cell features  │
   └────────────────────┘       │  + Oklab means      │
                                └─────────┬───────────┘
                                          │
                       ┌──────────────────▼──────────────────┐
                       │  Saliency-weighted cost matrix      │
                       │  (191-D L2 + Oklab ΔE)              │
                       └──────────────────┬──────────────────┘
                                          │
                       ┌──────────────────▼──────────────────┐
                       │  Hungarian 1:1 assignment           │
                       │  (or FAISS + Floyd–Steinberg)       │
                       └──────────────────┬──────────────────┘
                                          │
                       ┌──────────────────▼──────────────────┐
                       │  Neighbor-swap refinement (2-opt)   │
                       │  then NCC + SSIM rerank             │
                       └──────────────────┬──────────────────┘
                                          │
                       ┌──────────────────▼──────────────────┐
                       │  Per-tile MKL optimal transport     │
                       │  Laplacian pyramid blend            │
                       │  Oklch vibrance / skin protection   │
                       └──────────────────┬──────────────────┘
                                          ▼
                                       output

Why Oklab? CIELAB was calibrated on small color differences; it underestimates perceptual distance for the large jumps a photomosaic routinely makes. Oklab (Björn Ottosson, 2020) was rebuilt on modern data and is roughly 8.5× more perceptually uniform for chroma. Dropping it into the cost function is free and visibly improves matches on saturated photos.

Why MKL optimal transport? Reinhard color transfer matches the first and second moments of the LAB distribution. MKL (Pitié et al., 2007) matches the full covariance, so the shape of the tile's color distribution is preserved as its statistics slide toward the target cell. Details survive; averages don't win.

Zoom detail

Left: the center of the mosaic — at reading distance, the painting is recognizable. Right: a 2× nearest-neighbor zoom — every cell is a distinct CC0 photograph.

Oklch tile-pool expansion

Tile pool sample

One of the hardest problems in photomosaic generation is having enough tiles. A 1,000-image pool gives ~1,000 mean colors, so a 5,000-cell mosaic is forced to repeat. color_variants=N rotates every tile through N evenly-spaced hue shifts in Oklch (the default schedule is 72° / 144° / 216° / 288°), reusing the same photograph at four new positions on the a/b plane:

gen = MosaicGenerator(tile_dir="./tiles", preset="ultra", color_variants=4)

Lightness is preserved exactly, so texture and shading are untouched — only hue and chroma move. For a 1,024-tile pool this turns into 5,120 candidates after Oklch expansion, or 20,480 after the default 4× geometric augmentation on top. The Hungarian assignment then has an order of magnitude more material to work with, which is the difference between a mosaic that repeats and a mosaic that doesn't.

Oklch whole-image recoloring

Recolor gallery

A finished mosaic can be recolored through any of 21 named presets (blue, cyan, teal, purple, pink, orange, yellow, lime, sepia, cyberpunk, ...) or an arbitrary #RRGGBB, and the operation preserves the Oklab L channel exactly. Because L is untouched, the per-tile shading survives the rotation — no boundary artifacts, no re-rendering, no tile reload. One 5-MB mosaic becomes a gallery of themed variants in a few hundred milliseconds each.

from mosaicraft import recolor

recolor("mosaic.jpg", "mosaic_blue.jpg",  preset="blue")
recolor("mosaic.jpg", "mosaic_sepia.jpg", preset="sepia")
recolor("mosaic.jpg", "mosaic_brand.jpg", target_hex="#3b82f6")
recolor("mosaic.jpg", "mosaic_shift.jpg", hue_shift_deg=60)

Under the hood: convert to Oklab, split into L and C·exp(iH), rotate H and scale C, convert back. Optional highlight / shadow chroma fading keeps paper-white and deep-black areas neutral.

Presets

Preset Best for
ultra Highest quality. Hungarian + Laplacian blend.
natural Photo-realistic look, restrained saturation.
vivid MKL optimal transport with skin protection.
tile Emphasizes individual tiles. Strongest mosaic look.
fast FAISS + error diffusion only. No rerank, no Hungarian.

Pass a dict to MosaicGenerator(preset={...}) to override individual keys. See src/mosaicraft/presets.py for the full schema.

Preset comparison

Benchmarks

Small-pool wall time (256-tile pool, cold start)

Produced by python benchmarks/benchmark_pipeline.py — a single MosaicGenerator pass, tiles loaded from disk every time, no feature cache, no GPU, no FAISS.

preset 200 cells 500 cells 1,000 cells
fast 3.00 s 4.42 s 6.87 s
natural 2.79 s 4.38 s 7.49 s
ultra 2.86 s 4.64 s 7.61 s
vivid 2.92 s 4.69 s 7.85 s

AMD Ryzen 7 7735HS, WSL2 / Ubuntu 24.04, Python 3.12, NumPy + OpenCV wheels.

Large-pool regime (1,024-tile pool, up to 30,000 cells)

Run python benchmarks/benchmark_pipeline.py --scale large to reproduce. Every cell is one tile selected from the 1,024 CC0 photograph pool × 4 geometric augmentations = 4,096 candidates. Every case is run cold — tiles loaded from disk on every invocation.

preset metric 5,000 cells 10,000 cells 20,000 cells 30,000 cells
fast wall time 28.3 s 51.1 s 95.0 s 190.2 s
fast peak RSS 4,691 MB 4,840 MB 9,373 MB 7,264 MB
ultra wall time 73.9 s 99.8 s 110.7 s 181.7 s

The 30,000-cell output is 8,904 × 10,472 px ≈ 93 megapixels and the finished JPEG is ~47 MB. (ultra runs faster than fast at the 20k / 30k end because the Hungarian assignment saturates before the FAISS + error-diffusion code path stops benefiting from more cells; your mileage will vary with the tile pool / cell size ratio.)

Compared against other photomosaic OSS

benchmarks/compare_tools.py runs mosaicraft side-by-side with two reference open-source photomosaic tools against an identical target, tile pool, and grid:

Target: Vermeer, Girl with a Pearl Earring (Wikimedia, public domain). Tile pool: 1,024 CC0 photographs via picsum.photos. Grid: 40 × 40 = 1,600 cells. Metrics are computed on the final mosaic against the original painting.

Side-by-side comparison

Tool Wall SSIM ↑ Blur. SSIM ↑ ΔE2000 ↓ LPIPS ↓ Cell diversity ↑
codebox/mosaic (RGB mean) 1.2 s 0.250 0.811 10.32 0.544 0.079
photomosaic 0.3.1 (CIELAB + kd-tree) 1.9 s 0.065 0.443 37.61 0.784 0.114
mosaicraft — fast 15.1 s 0.216 0.750 10.85 0.630 0.341
mosaicraft — ultra 18.7 s 0.166 0.635 13.84 0.622 0.367
mosaicraft — ultra --color-variants 4 68.6 s 0.245 0.715 9.70 0.557 0.337

Metrics:

  • SSIM (Wang et al., 2004) and ΔE2000 (CIEDE2000) are computed on the raw mosaic against the original painting — both reward low-pass pixel fidelity.
  • Blurred SSIM is SSIM after both images are downsampled and box-blurred; it approximates "how close does the mosaic look from a few meters away?"
  • LPIPS (Zhang et al., CVPR 2018) is a learned perceptual distance from an AlexNet feature embedding — closer to human judgement than pixel metrics but still trained on natural photos, not mosaics.
  • Cell diversity is the fraction of 5-bit-quantized cell means that are unique in the final mosaic. A higher number means the mosaic makes use of more of the tile pool.

How to read these numbers

This comparison captures the fundamental trade-off between pixel-fidelity mean-matching and strict photomosaic constraints — and also shows what changes when you let mosaicraft use its full pool.

  • codebox/mosaic picks the tile whose mean color is closest to each cell, independently, with no constraint on tile reuse. The result is effectively a low-pass-filtered version of the target painted from whichever ~100 tiles happen to be closest in RGB. Its cell diversity is 7.9% — only 126 of the 1,600 cells are visually distinct. Viewed from across the room it's the closest to the target; viewed from two feet away it's a blurry smear of the same dozen photographs.
  • photomosaic 0.3.1 is a historical baseline (2018, designed for much larger pools) and struggles at this scale. Included because it is the current pip install photomosaic.
  • mosaicraft — fast / ultra enforce a strict one-to-one Hungarian assignment, which pushes the output away from target pixel values in exchange for 4.6× higher diversity: 34–37% of cells are visually distinct. That is the point of a photomosaic.
  • mosaicraft — ultra --color-variants 4 expands the 1,024-tile pool into 5,120 Oklch hue-rotated candidates (× 4 geometric aug = 20,480 candidates). The Hungarian assignment then has an order of magnitude more material, and the output beats codebox on ΔE2000 (9.70 vs 10.32), effectively ties on SSIM (0.245 vs 0.250), closes the LPIPS gap (0.557 vs 0.544), and holds 4.3× higher cell diversity — all at once. This is the row to beat.

tl;dr: At the same tile pool size, a mean-matching tool will beat a photomosaic on pixel metrics by design. Once mosaicraft can stretch the pool with Oklch variants, the gap disappears on every metric that matters while the structural photomosaic property (diversity) stays intact.

Reproduce locally:

python benchmarks/compare_tools.py --target pearl_earring.jpg --grid 40

Python API

from mosaicraft import MosaicGenerator, recolor, rotate_hue_oklch

# Generator
gen = MosaicGenerator(
    tile_dir="./tiles",          # or cache_dir="./cache"
    preset="ultra",              # preset name or dict
    augment=True,                # 4x geometric + brightness aug
    color_variants=0,            # set to >0 to expand pool via Oklch rotation
)
result = gen.generate("photo.jpg", "mosaic.jpg", target_tiles=2000, tile_size=88)

# Recolor a finished mosaic
recolor("mosaic.jpg", "mosaic_sepia.jpg", preset="sepia")

# Rotate a single tile or patch in Oklch (preserves L exactly)
rotated_bgr = rotate_hue_oklch(tile_bgr, hue_shift_deg=90)

MosaicResult exposes image (numpy BGR), grid_cols, grid_rows, tile_size, output_path, n_tiles.

Helpers:

  • mosaicraft.list_presets() — mosaic preset names.
  • mosaicraft.list_recolor_presets() — recolor preset names.
  • mosaicraft.build_cache(tile_dir, cache_dir, tile_sizes, thumb_size=120) — precompute features.
  • mosaicraft.calc_grid(target_tiles, aspect_w, aspect_h) — pick a grid for a desired cell count.

Lower-level building blocks live in mosaicraft.color, mosaicraft.features, mosaicraft.placement, mosaicraft.blending, mosaicraft.postprocess, mosaicraft.color_augment, mosaicraft.recolor, and mosaicraft.tiles.

Reproducible figures

Every image in this README — hero, before/after, preset comparison, zoom detail, tile sample, paintings gallery, comparison table, recolor gallery — is produced by two self-contained scripts:

# 1. Bootstrap public-domain demo assets (~8 MB, one time).
python scripts/download_demo_assets.py
python scripts/download_demo_assets.py --verify-only   # SHA256 integrity check

# 2. Render figures.
python scripts/generate_readme_figures.py
python scripts/generate_readme_figures.py --quick                 # faster iteration
python scripts/generate_readme_figures.py --target starry_night   # swap target

# 3. Run the OSS comparison benchmark.
python benchmarks/compare_tools.py --target pearl_earring --grid 40

SHA256 and license metadata for every bootstrapped file live in docs/assets/MANIFEST.json. The raw image files are not committed; the manifest is.

Public-domain paintings gallery

All four public-domain targets the scripts can feature — swap with --target {pearl_earring,starry_night,great_wave,red_fuji}.

Testing

pip install -e ".[dev]"
pytest                        # unit + pipeline + CLI tests
ruff check src tests          # lint
bandit -r src -ll             # security scan

Contributing

Bug reports, feature requests, and pull requests are welcome. See CONTRIBUTING.md for the development workflow. Security issues: see SECURITY.md.

License and image credits

MIT License. See LICENSE.

Every figure in this README is reproducible from public-domain / CC0 sources:

  • Target paintings — public domain, via Wikimedia Commons: Johannes Vermeer, Girl with a Pearl Earring (c. 1665); Vincent van Gogh, The Starry Night (1889); Katsushika Hokusai, The Great Wave off Kanagawa (c. 1831) and Fine Wind, Clear Morning (Red Fuji) (c. 1831).
  • Tile pool — 1,024 photographs from picsum.photos (Unsplash-sourced, Unsplash License — effectively CC0).

References

mosaicraft stands on the following classic and modern work:

  • Björn Ottosson, A perceptual color space for image processing (2020, blog). Oklab.
  • Pitié, F. et al., The linear Monge-Kantorovitch linear colour mapping for example-based colour transfer (IET-CVMP 2007). MKL.
  • Reinhard, E. et al., Color transfer between images (IEEE CGA 2001).
  • Zhang, R. et al., The Unreasonable Effectiveness of Deep Features as a Perceptual Metric (CVPR 2018). LPIPS.
  • Wang, Z. et al., Image quality assessment: from error visibility to structural similarity (IEEE TIP 2004). SSIM.
  • Tesfaldet, M. et al., Convolutional Photomosaic Generation via Multi-Scale Perceptual Losses (ECCVW 2018). Multi-scale perceptual loss for photomosaic quality assessment.
  • Burt, P. & Adelson, E., A multiresolution spline with application to image mosaics (ACM ToG 1983). Laplacian pyramid blending.
  • Kuhn, H. W., The Hungarian method for the assignment problem (Naval Research Logistics 1955).

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mosaicraft-0.2.0.tar.gz (50.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mosaicraft-0.2.0-py3-none-any.whl (47.8 kB view details)

Uploaded Python 3

File details

Details for the file mosaicraft-0.2.0.tar.gz.

File metadata

  • Download URL: mosaicraft-0.2.0.tar.gz
  • Upload date:
  • Size: 50.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for mosaicraft-0.2.0.tar.gz
Algorithm Hash digest
SHA256 0e1a55c2a1f51ed215c22721087236fe89fa139d5ccf4c5196117555a66c6592
MD5 43ad94e129dee7b8f8aeffbdba2dc259
BLAKE2b-256 125e31ebad3b33537041b6bb551345484f7013c877a3ccf11cf3b3e43f7d9323

See more details on using hashes here.

Provenance

The following attestation bundles were made for mosaicraft-0.2.0.tar.gz:

Publisher: release.yml on hinanohart/mosaicraft

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mosaicraft-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: mosaicraft-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 47.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for mosaicraft-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 807cde91e5ba53378da270b006cf57fed117252790e40b8f7082aa23be9aa250
MD5 aba74307b0aa1bc8fffee8015ff7ac23
BLAKE2b-256 3372861227606166271a6404c6c790b31cdca4517682f67a9d2f8ae5ccee3d81

See more details on using hashes here.

Provenance

The following attestation bundles were made for mosaicraft-0.2.0-py3-none-any.whl:

Publisher: release.yml on hinanohart/mosaicraft

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page