Skip to content

Repository files navigation

Wallpaper Ricer

Convert any image to a fixed colour scheme — Nord, Solarized, Gruvbox, Everforest, Catppuccin, Dracula, or any other palette added to palettize.py's PALETTES. Colour snapping and dithering operate in the perceptually-uniform Oklab colour space; palette mixing maps pixels onto a convex hull whose geometry varies by mixing model (spectral Kubelka-Munk pigment mixing, or linear-RGB additive light mixing) and is refit per palette.

Installation

python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

For depth estimation and palette mixing, also install:

pip install transformers torch torchvision pillow  # depth_blur.py
pip install mlx                        # --mix (Apple Silicon required)

Pipeline

The pipeline is two scripts run in sequence:

depth_blur.py  →  palettize.py
(crop + blur)     (palette conversion)

Step 1 — depth_blur.py

Crops to a target aspect ratio, estimates monocular depth, and applies a depth-guided defocus (bokeh) blur via layered forward-scatter compositing.

python3 depth_blur.py <input> -o <blurred> [options]
Flag Default Description
--aspect W:H 16:9 Crop aspect ratio
--align center Crop alignment: left/center/right/top/bottom
--no-crop Skip aspect-ratio cropping
--blur PCT 2.0 Max disc radius (circle of confusion) as % of image height
--levels N 16 Number of depth slabs for scatter compositing
--focus D|auto auto Focus plane as normalised disparity: 0.0=background/infinity, 1.0=foreground, or auto to detect the principal figure via SAM segmentation; see Methods below
--depth-only Save the blur-strength map and exit (useful for tuning)
--save-depth PATH Also save the normalised depth map alongside the main output
--save-figure-mask PATH With --focus auto, also save the winning figure region as a mask alongside the main output
--fix-sky Correct sky/foreground depth inversions (seen on stylised art) using an Otsu-segmented Depth Anything V2 sky mask; see Methods below
--flatten-masts Flatten thin, tall, solid vertical structures (chimneys, masts) to their own median depth; see Methods below
--model MODEL Depth Anything V2 Small HuggingFace depth model ID

Step 2 — palettize.py

Maps every pixel to a colour from the chosen palette. Optionally applies a nighttime pre-processing pass before conversion.

python3 palettize.py <input> -o <output> [options]
Flag Default Description
--palette NAME nord Colour scheme to convert to — see Palettes below for the full list
--dither fs Floyd-Steinberg dithering with blue-noise seeding
--mix [spectral|additive] Palette mixing (requires MLX); see Methods below.
--night Nighttime pre-processing: darken and cool the image before palette conversion

Utility — entropy_crop.py

An alternative to depth_blur.py's --align left/center/right: picks a crop offset that minimises the entropy of the edge source's content it would cut, then applies that same offset to one or more images — useful for re-cropping an already-finished wallpaper to a second aspect ratio (e.g. deriving a 3:2 variant from a 16:9 one) while keeping a day/night pair aligned. The edge source is typically a depth map (depth_blur.py --save-depth) so the crop is chosen against object silhouettes rather than painted texture, but any image works. See Methods below.

python3 entropy_crop.py edge_source.png --aspect W:H <in1> <out1> [<in2> <out2> ...]
Flag Default Description
--aspect W:H 16:9 Target aspect ratio
--visualize PATH Save a copy of the edge source with the chosen crop boundaries drawn on it

The edge source is only used to choose the crop offset — it is not implicitly cropped; include it in the <in> <out> pairs if it should be too. Every input must be pixel-aligned with the edge source (i.e. the same dimensions).

Examples

# Plain colour snapping (Nord, the default)
python3 palettize.py photo.jpg -o photo_nord.png

# A different scheme
python3 palettize.py photo.jpg -o photo_gruvbox.png --palette gruvbox-dark

# Floyd-Steinberg dithering
python3 palettize.py photo.jpg -o photo_nord.png --dither fs

# Spectral palette mixing
python3 palettize.py photo.jpg -o photo_nord.png --mix

# Additive (linear-light) palette mixing
python3 palettize.py photo.jpg -o photo_nord.png --mix additive

# Nighttime version with spectral mixing
python3 palettize.py photo.jpg -o photo_night.png --night --mix

# Full wallpaper pipeline: 16:9 crop + depth blur, then palettize
python3 depth_blur.py photo.jpg -o blurred.png
python3 palettize.py blurred.png -o wallpaper.png --mix

# 3:2 wallpaper, keeping left side, heavier blur, Catppuccin Mocha
python3 depth_blur.py photo.jpg -o blurred.png --aspect 3:2 --align left --blur 4
python3 palettize.py blurred.png -o wallpaper.png --mix --palette catppuccin-mocha

# Derive a 3:2 crop from an already-finished 16:9 wallpaper (and its night
# variant) via minimum-entropy cropping against the depth map, keeping both
# pixel-aligned
python3 depth_blur.py photo.jpg -o blurred.png --no-crop --save-depth depth.png
python3 palettize.py blurred.png -o wallpaper.png --mix
python3 entropy_crop.py depth.png --aspect 3:2 \
    wallpaper.png wallpaper_3x2.png \
    wallpaper_night.png wallpaper_night_3x2.png

Samples

Original photo by Philippe Gauthier on Unsplash.

Original Colour snapping
Original Snapped
Floyd-Steinberg dithering Nighttime (--night)
Dithered Night

Palette mixing (--mix):

--mix spectral (default) --mix additive
Mixed spectral Mixed additive

Wallpaper crop + depth-guided defocus blur + additive palette mixing (depth_blur.pypalettize.py --mix additive):

Wallpaper

Methods

Colour snapping

Each pixel's colour is converted to Oklab — a perceptually uniform space — and its hue is snapped to the nearest palette hue while its lightness and chroma are left unchanged. This preserves the tonal contrast of the original image.

Floyd-Steinberg dithering with blue noise

Floyd-Steinberg error diffusion in the Oklab (a, b) plane: the palette mismatch at each pixel is propagated to its neighbours with weights 7/16, 3/16, 5/16, 1/16. Before each palette lookup the effective colour is offset by a blue-noise value (generated via the void-and-cluster algorithm), which breaks up the banding that plain error diffusion can produce in smooth gradients.

Palette mixing (--mix)

Two mixing models, both minimising Oklab distance to each pixel's original colour and both leaving already-reachable pixels unchanged.

--mix spectral (default) fits a Gaussian reflectance spectrum to each Nord colour, then for every pixel optimises a simplex (Σcᵢ = 1) over the 17 palette K/S spectra to minimise Oklab distance to the target. Mixing in spectral K/S space follows Kubelka-Munk theory: convex combinations of K/S spectra correspond to physically realised opaque paint mixtures.

The pipeline:

  1. Augmented palette — the 17 pure colours plus all N(N−1)/2 pairwise 50/50 K/S mixtures are assembled. Each pixel is snapped to the nearest augmented entry; pairwise-mixture entries ensure boundary pixels receive interior-simplex starting weights rather than one-hot corners.
  2. Random diversification — the snapped weights are blended 50/50 with a Dirichlet-sampled random field, then re-projected onto the simplex. This prevents pixels near palette boundaries from stalling in local optima.
  3. Spatial blur — weights are Gaussian-smoothed across neighbours and re-projected, giving spatially coherent mixing in flat regions.
  4. Adam optimisation — cosine-decayed Adam refines the simplex weights per strip to minimise Oklab distance to the target.

--mix additive treats the palette as a set of light sources instead of pigments: the reachable gamut is the convex hull of the 17 colours (plus black and white) in linear RGB, which — unlike the K/S spectral gamut — includes ordinary additive colour mixing. Out-of-gamut pixels are moved directly to the Oklab-nearest point on that hull's surface: each hull facet is a flat triangle in linear RGB that the cube root (on the way to Oklab) warps into a smooth curved surface, and the nearest point on it is found by a few Levenberg-Marquardt-damped Gauss-Newton steps in the triangle's own 2 parameters, clamped back onto the triangle after every step. Because the additive gamut is much larger than the pigment gamut, this model changes photographs more subtly than spectral mixing — visible mainly on strongly saturated or overexposed pixels.

For a more detailed discussion of the algorithms and their artistic rationale, see BACKGROUND.md.

Depth-guided blur (depth_blur.py)

Estimates monocular depth via Depth Anything V2 Small. Depth Anything is a fixed-input-size model — its image processor resizes everything down to a small fixed size (e.g. 518px) regardless of source resolution — so a single whole-image pass alone would destroy thin foreground structures (wires, masts, lattice towers) before the network ever sees them. Instead, several overlapping-tile refinement passes run at different footprint sizes (fractions of the image's width — default [0.5, 0.25, 0.125]), each tile least-squares aligned (scale + shift; monocular depth models are only defined up to an unknown per-inference affine transform) directly against the initial whole-image pass and blended internally with tent-feathered edges, and the final depth map is the per-pixel maximum across the whole-image pass and all the tile passes. Different footprints have different blind spots — a wide pass gives smooth, consistent depth on solid objects but loses thin wires and blends soft things (steam, smoke) into the sky; a narrow, near-native-resolution pass keeps fine detail (down to a lattice tower's individual crossing struts) but is noisier on large solid objects — and taking the max lets each pass contribute only where it's more confident something is close, so one pass's blind spot can't erase another's correctly-recovered detail. (Apple's Depth Pro is still available via --model apple/DepthPro-hf, and skips this tiling entirely — it derives patches from multiple downsamplings of the original image internally, so a single pass already sees fine detail with no tiling needed — but it isn't the default: on stylised/painted art it can misjudge large flat regions in a way Depth Anything doesn't, see --fix-sky below.) Then applies a telecentric defocus (bokeh) model via layered forward-scatter compositing. The depth map is divided into --levels slabs with tent-function membership (an exact partition of unity); each slab forward-scatters its own colour and coverage outward by a disc (pillbox) kernel sized to that slab's circle of confusion — r = sigma_max · |d − d_focus| / denom — exactly as a real aperture spreads light from an out-of-focus point. Slabs are then composited front-to-back with premultiplied alpha, so nearer slabs occlude farther ones. Because blur is scattered from each source pixel outward rather than gathered into each output pixel from a neighbourhood sized by its own depth, background bokeh naturally bleeds up to (and is naturally clipped by) sharp foreground edges, and a blurred foreground naturally bleeds semi-transparently over a sharp background — with no heuristic depth dilation needed. Blur runs in linear light with MLX GPU acceleration on Apple Silicon when available.

Sky depth correction (--fix-sky)

No longer needed by the default pipeline — Depth Anything V2 (the default model) doesn't make the mistake this corrects. Still available via --fix-sky for anyone using --model apple/DepthPro-hf.

Depth Pro, trained on real photographs, can read a flat, desaturated, silhouette-like region as near — a strong learned cue for atmospheric haze in photos — even when it's the sky sitting behind a much nearer, plainly-painted structure. Confirmed on a power-station painting, where Depth Pro placed the sky nearer than the building in front of it while Depth Anything V2 got the same region right — an out-of-distribution failure specific to stylised/painted content, not a general flaw. --fix-sky corrects it: Depth Anything V2's map is Otsu-thresholded to locate the sky (its single farthest, most tightly-clustered region), then that region's Depth Anything values — least-squares fit to Depth Pro's scale using everything outside the sky mask, never the sky itself — are blended into the result through a feathered mask, with a safety clamp guaranteeing the corrected sky never reads nearer than the nearest non-sky pixel. This roughly doubles depth-estimation time (Depth Anything's tiled pass runs alongside Depth Pro's) but that's still small next to palettize.py --mix's per-image runtime.

Mast depth flattening (--flatten-masts)

Superseded by the default pipeline's multi-pass max combination above — the wide (0.5) tiling pass already gives smokestacks smooth, consistent depth directly. Still available via --flatten-masts.

A thin, tall, rigid vertical structure (a smokestack) can come out of either depth model with substantial internal noise along its height, even though its true depth barely varies top to bottom. --flatten-masts finds candidates via Segment Anything's automatic mask generation on the source image rather than the depth map (using the depth map to find its own errors is circular, and both models place smokestacks near the low/"far" end of their own range, so an Otsu split just lumps them in with sky). Masks are kept only if their own bounding box is tall/narrow — SAM's masks already respect real object boundaries, so the transmission tower's wire lattice (equally thin and tall, but sparse, and which legitimately does vary in depth as it recedes toward a vanishing point) isn't returned as a tall/narrow mask in the first place, unlike an earlier classical contrast-based approach that needed a separate solidity check and still missed a mast embedded in busy painted clouds.

Each surviving mast isn't flattened to a single value outright — that would erase genuine perspective drift on a mast large enough to show any. Instead, each pixel's deviation from the mast's own median depth is clamped to a tolerance band: deviations already inside it pass through untouched, and only the excess — the part with no plausible perspective explanation — gets pulled in. That tolerance shrinks quadratically (not linearly) toward the background, since disparity is proportional to inverse distance: a mast twice as far away can plausibly show only a quarter of the depth spread, not half. In practice this gives the scene's nearest object a full ±15% tolerance, dropping to roughly ±2% for a background-level smokestack.

The clamp only ever pulls a pixel toward its mast's median from the far side, never the near side — a mast can't have something genuinely farther "through" it, but a pixel reading nearer could be something real crossing in front (a transmission wire passing over a smokestack was losing its correct depth this way until the clamp was made one-sided).

The image is downsampled (1500px wide by default) before running SAM: its encoder resizes to a fixed internal resolution regardless of input size, so the full-resolution source produces identical mask quality at ~60x the cost (~17 minutes vs. ~17 seconds for one 4500px-wide image).

Automatic figure detection (--focus auto)

Picks the focus plane automatically instead of a manually guessed --focus D, by modelling how sharply a viewer would actually resolve each part of the image and calling "the figure" whichever segmented region has the most total resolvable area under that model.

The viewer is assumed to sit at 1.5x the image's own diagonal and fixate on its centre (the only defensible default with no gaze data). A pixel's visual angle from that centre follows straightforward viewing-distance geometry (no assumption about actual display size needed — the unknown physical pixel pitch cancels out of the ratio), and acuity falls off with that angle via the standard cortical-magnification approximation used in foveated-rendering research (Rovamo & Virsu 1979), E2 ≈ 2.3° — a smooth gradient, not a hard foveal/non-foveal cutoff. This replaces an earlier version that picked "the figure" via five hand-tuned shape criteria (size, compactness, convexity, connectedness, depth) combined by weights fitted reactively against whichever test image had just broken — a single principled quantity in place of five arbitrary ones.

Segmentation still comes from SAM on the source image, not the depth map — the same trade --flatten-masts above already makes, and for the same reason (a classical Canny-on-depth-map approach failed on a stylised power-station painting, where the depth map's silhouette gradient had gaps too small to see by eye but large enough to leave the whole frame as one undifferentiated region). A single object can still come back as several SAM masks; regions whose depth ranges overlap by more than 50% of both ranges' own extents are merged (transitively, via union-find). Since matching depth alone doesn't guarantee spatial contiguity — a tree canopy's many separate leaf clusters can all sit at a similar depth — each merged union is split back into its connected components before scoring, so every candidate is guaranteed to be one contiguous piece.

Nearness is folded into the same per-pixel weight as a second factor — normalised disparity, 0 at the scene's own farthest point and 1 at its nearest — rather than scored separately. Without it, a large, cleanly-segmented but distant region (a patch of sky) can out-score every closer subject on resolvable area alone; confirmed on a photo where the sky won outright over its actual fruit and foliage before this was added.

Minimum-entropy crop (entropy_crop.py)

Slides the crop window along whichever axis the target aspect ratio needs to narrow and scores each candidate offset by how much of the edge source's content its two boundaries would cut. The edge source is usually a depth map (from depth_blur.py --save-depth): its edges are exactly object silhouettes, so cutting one means clipping a real object — unlike a colour image, whose Sobel edges also fire on brushwork and painted texture that have nothing to do with where objects actually are (a colour image still works if no depth map is available). Sobel gradient magnitude (continuous, not thresholded like Canny — a palette-snapped colour image is mostly flat colour and would leave Canny nearly empty) stands in for "how much is here to lose."

Rather than sampling only the exact boundary row/column, every row/column in the entire kept window contributes, weighted by the same parafoveal acuity model depth_blur.py's --focus auto uses (see Automatic figure detection above), run in reverse: acuity is highest at the crop window's own centre, so content lost there is missed most, while content already out near a boundary — at the edge of what a viewer fixating centrally could resolve clearly — costs comparatively little to lose. The weight is e / (e + E2), the complement of that acuity falloff: 0 at the centre, rising smoothly toward (never quite reaching) 1 at each boundary. This replaces an earlier, purely geometric parabola of the same shape, but with no arbitrary exponent to have picked — only quantities with an independent, citable meaning. Treating that weighted gradient magnitude as an unnormalised distribution over rows/columns, entropy is low when the cut is concentrated (mostly flat, only clipping something in a narrow span) and high when it's smeared across many different objects; cutting nothing scores zero. Ties at zero entropy break on total edge weight cut, ascending — preferring the offset that clips the least even when both clip "nothing significant."

Since this weighting is no longer a simple polynomial in position, the weighted sum for every candidate offset is computed as a single batched FFT correlation rather than the closed-form prefix-sum trick a quadratic weight would allow — still fast enough in practice (a few seconds even on a 5000px-wide image).

It has no notion of what an object is, only where the edge source changes sharply — so it can occasionally prefer clipping a large, low-contrast area over a small but sharp-edged one nearby if that minimises the boundary's entropy. Its notion of foreground vs. background comes entirely from whatever edge source it's given: fed a depth map, it favours cuts that avoid silhouettes; fed a colour image, it only knows "how confidently something's outline sits on this line," with no sense of near vs. far.

Nighttime pre-processing (--night)

Transforms pixel colours in Oklab before palette conversion:

  • LuminanceL → 1 − √(1 − L): a curve that darkens mid-tones and highlights while keeping shadows from crushing to black.
  • Yellow/blue axisb is shifted toward blue in inverse proportion to luminance: dark pixels receive the full shift (warming tones become cool), bright pixels are left unchanged (artificial lights and highlights keep their original colour temperature).
  • Detected lights — a lit window or streetlight should stay bright at night, not just avoid being darkened, but picking it out from an ordinary bright or colourful pixel (a sunlit orange, a patch of sky) needs more than a per-pixel colour rule can see: what actually makes something read as "a light" is standing out sharply from its immediate surroundings, not any absolute brightness or colour value. Each compact spot substantially brighter than its own neighbourhood — at whatever spatial scale it happens to be, found via multi-scale Difference-of-Gaussians blob detection (the same core idea behind SIFT keypoints) on the lightness channel — is protected from both effects above and additionally brightened, feathered smoothly around each detected spot. No segmentation model or depth map needed: local contrast alone is exactly the signal that defines a light.

Palettes

Every scheme in palettize.PALETTES (--palette NAME), each with a pure black #000000 prepended (extends the lightness range available to snapping and mixing alike — see palettize.py's own comment on PALETTES):

nord (default)

Group Colours
Polar Night nord0 #2E3440 · nord1 #3B4252 · nord2 #434C5E · nord3 #4C566A
Snow Storm nord4 #D8DEE9 · nord5 #E5E9F0 · nord6 #ECEFF4
Frost nord7 #8FBCBB · nord8 #88C0D0 · nord9 #81A1C1 · nord10 #5E81AC
Aurora nord11 #BF616A · nord12 #D08770 · nord13 #EBCB8B · nord14 #A3BE8C · nord15 #B48EAD

solarized-dark — base03#002B36 base02#073642 base01#586E75 base00#657B83 base0#839496 base1#93A1A1 base2#EEE8D5 base3#FDF6E3 · yellow#B58900 orange#CB4B16 red#DC322F magenta#D33682 violet#6C71C4 blue#268BD2 cyan#2AA198 green#859900

gruvbox-dark — bg0#282828 bg1#3C3836 bg2#504945 bg3#665C54 bg4#7C6F64 · fg0#FBF1C7 fg1#EBDBB2 fg2#D5C4A1 fg3#BDAE93 · red#FB4934 green#B8BB26 yellow#FABD2F blue#83A598 purple#D3869B aqua#8EC07C orange#FE8019

everforest-dark — bg0#2D353B bg1#343F44 bg2#3D484D bg3#475258 bg4#4F585E · fg#D3C6AA · red#E67E80 orange#E69875 yellow#DBBC7F green#A7C080 aqua#83C092 blue#7FBBB3 purple#D699B6 · grey0#7A8478 grey1#859289 grey2#9DA9A0

catppuccin-mocha — base#1E1E2E mantle#181825 crust#11111B · text#CDD6F4 · rosewater#F5E0DC flamingo#F2CDCD pink#F5C2E7 mauve#CBA6F7 red#F38BA8 maroon#EBA0AC peach#FAB387 yellow#F9E2AF green#A6E3A1 teal#94E2D5 sky#89DCEB blue#89B4FA

dracula — background#282A36 current-line#44475A foreground#F8F8F2 comment#6272A4 · cyan#8BE9FD green#50FA7B orange#FFB86C pink#FF79C6 purple#BD93F9 red#FF5555 yellow#F1FA8C


Developed with Claude Code

About

Convert any image to the Nord colour palette using Oklab colour snapping and dithering, or convex-hull palette mixing with Oklab-based optimisation.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages