Skip to content

outfox/paletti

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

54 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

paletti logo

Paletti - The Image Chameleon

Apply colour palettes to images from the command line.

paletti is a Python reinterpretation of the palette / dithering shader demonstrated in Palette Shader 2 Godot 3 utility by GreenF0x.

For each pixel it finds the two nearest palette colours and then snaps, blends, or ordered-dithers between them.

Paletti does a bunch more, such as allow easily ad-hoc composed palettes and adjusting metrics and dithering patterns per color channel.

The utility defaults to the OKLAB color space, enabling it to match perceptually similar colors.

Showcase

paletti works on all kinds of source art β€” from chunky pixel art to 3D-rendered lighting. The examples below run a source through a range of palettes and dither settings.

Use Case: Pixel Art Palette Swaps
Pixel art re-painted with paletti
Use Case: SVG Recoloring
Logo for project 'Newtype' Logo re-colored with paletti
Use Case: Illustrative Reshading / Dithering
Pixel art re-painted with palettiPixel art re-painted with paletti
Use Case: Re-Lighting Art and Photos
sample-spaceman.mp4

Install / run

This is a uv project:

uv sync
uv run paletti --help

Usage

paletti INPUT [OUTPUT] -p PALETTE [options]

If OUTPUT is omitted, the result is written next to the input as paletti-<input-name>.png (e.g. paletti in.png -p pal.png β†’ paletti-in.png); an SVG input defaults to paletti-<input-name>.svg (see SVG input).

Each palette source (-p / --palette) can be:

  • an image β€” its distinct colours become the palette (-p palette.png, optionally --max-colors 16);
  • a JSON file β€” -p sweetie16.json;
  • an inline JSON array β€” -p '["#1a1c2c","#5d275d"]';
  • a bare hex/name colour β€” -p 000, -p '#1a1c2c', -p lavender.

-p is repeatable and variadic, and every source is concatenated into one palette. Repeat the flag or list sources after a single -p:

paletti in.png out.png -p 000 -p palette.json -p lospec-pal8.png
paletti in.png out.png -p 000 palette.json lospec-pal8.png lavender

A bare colour token accepts any of:

  • hex β€” with or without a leading #, in 3- or 6-digit form (000, #000, 1a1c2c, #1a1c2c);
  • a CSS/SVG colour name β€” white, lavender, rebeccapurple;
  • a CSS colour function β€” rgb(), hsl(), hsv()/hsb(), hwb(), lab(), lch(), oklab(), oklch(), in either the legacy comma form or the modern space-separated CSS Color 4 syntax. Hue units (deg/grad/rad/ turn) and percentages are honoured; a trailing / alpha is parsed and ignored. Colours outside the sRGB gamut are clipped.
paletti in.png out.png -p 'rgb(255 0 0)' 'hsl(120deg 100% 50%)' 'oklch(0.7 0.15 30)'

(Quote any token containing spaces, #, or parentheses so the shell passes it through intact.) A token that names an existing file is read as that file, so files always win over same-named colours.

JSON palettes accept the same hex strings, colour names and colour functions, plus 0..255 integer triples ([26, 28, 44]) or 0..1 float triples ([0.1, 0.11, 0.17]). The numeric range is detected automatically; override with --palette-range.

Because a variadic -p greedily consumes the values that follow it, place it after the image paths (or before another flag).

How the two nearest colours are combined

By default each pixel snaps to its closest palette colour. Two flags change that:

selection result
(default) snap each pixel to the closest palette colour
--blend smooth lerp between the two nearest colours
--dither KIND ordered dither between the two nearest colours (1-bit edges, or soften with --antialias)
--dither KIND --rgb ordered dither each RGB channel independently, then snap to the palette (dissolves banding; great with --dither bayer or a blue-noise --dither texture). With an RGB --texture each colour channel drives the matching image channel; a greyscale texture is reused with a 1/3 phase shift per channel.

--blend and --dither are mutually exclusive. KIND is one of nearest, sine, bayer, halftone, texture.

Examples

A wonderful place to get appealing palettes is the lospec palette list!

# Quantise to a palette extracted from an image
paletti photo.png out.png -p lospec-palette.png

# Dither against a 16-colour palette using an 8x8 Bayer matrix
paletti photo.png out.png -p sweetie16.json --dither bayer --bayer 8

# Halftone / screentone dots (classic 45-degree grid, 8px dot spacing)
paletti photo.png out.png -p sweetie16.json --dither halftone --res 8

# Tile an arbitrary dither texture, scaled up 10x
paletti photo.png out.png -p sweetie16.json \
    --dither texture --texture screentone.png --scale 10

# Smooth two-tone blending with an inline palette
paletti photo.png out.png -p '[[26,28,44],[244,244,244]]' --blend

# Build a palette right on the command line (names or hex)
paletti photo.png out.png -p white black
paletti photo.png out.png -p sweetie16.json FFFFFF 000000  # palette + extra colours

# Mix sources freely: a bare colour, a JSON file, an image, and a name
paletti photo.png out.png -p 000 sweetie16.json lospec-pal8.png lavender

# Per-channel ordered dithering to dissolve banding (Bayer or blue-noise)
paletti photo.png out.png -p sweetie16.json --dither bayer --rgb --bayer 8
paletti photo.png out.png -p sweetie16.json --dither texture --rgb --texture bluenoise.png

# Match in HSV space, weighting hue twice as heavily
paletti photo.png out.png -p sweetie16.json --metric hsv --hsv-weights 2,1,1

SVG input

paletti accepts SVG inputs, rendered via resvg. The output extension picks how the SVG is handled:

  • a raster output (out.png, …) rasterizes the SVG and runs the full pixel pipeline β€” every mode, metric, and dither works as usual. SVGs have no inherent resolution, so --svg-scale N renders NΓ— larger for a crisper result (it re-renders at the larger size rather than upscaling pixels);
  • a .svg output keeps it vector: each colour the SVG uses β€” fill, stroke, gradient stop-color, and inline / <style> CSS β€” is snapped to its nearest palette colour with the same matching logic, and the vectors are written back unchanged. none, currentColor, and url(#…) references are left as-is. Dither needs pixels, so it doesn't apply here (--blend does).
paletti logo.svg out.png -p sweetie16.json --svg-scale 8   # rasterize β†’ PNG
paletti logo.svg out.svg -p sweetie16.json                 # recolour, keep vector
paletti logo.svg        -p sweetie16.json                  # β†’ paletti-logo.svg (vector)

Other options

  • --blur SIGMA β€” Gaussian-blur the source (sigma in pixels) before palettizing. Matching is per-pixel, so source noise / JPEG blocking / faint gradients near a palette-colour boundary flip the chosen colours and show up as sharp pixel-sized speckle. A small pre-blur (try 0.5-2) makes the selection spatially coherent and cleans that up while leaving the dither pattern intact.
  • --denoise STRENGTH β€” edge-preserving bilateral denoise of the source before palettizing. Like --blur it suppresses source noise / JPEG blocking, but it keeps the colour edges that drive palette matching crisp (instead of blurring them), giving cleaner flat regions. STRENGTH is the colour sigma in [0,1] units (try 0.05-0.3). Requires scikit-image; slower than --blur. Can be combined with --blur.
  • --metric {oklab,rgb,hsl,hsv,hue,luma} β€” colour-distance metric used for matching (default oklab, which measures perceptual difference). rgb is plain Euclidean; hsl/hsv compare in cylindrical space (hue on its circle); hue and luma match on that single axis alone. --hsv-weights tunes the per-axis weighting of hsv.
  • --hsv-adjust H,S,V β€” pre-shift hue (add) and scale saturation/value (multiply) before matching; identity is 0,1,1.
  • --dither {nearest,sine,bayer,halftone,texture}, --res, --bayer, --angle, --texture β€” control the dither pattern. halftone reproduces the Godot project's "Screentone" pattern as procedural dots: --res sets the dot spacing in pixels (try 6-12) and --angle rotates the grid (45 = classic screentone, 0 = an axis-aligned square grid). texture tiles an arbitrary image (e.g. the original screentonesdf.png) via --texture, and --scale zooms that tiled texture (e.g. 10 for 10x, 0.5 to shrink). The texture is laid over the image at a 1:1 pixel ratio and repeated to fill it, so --scale 1.0 is an exact 1:1 mapping; other values zoom the tiled field about the origin via seamless bilinear sampling.
  • --antialias β€” anti-alias dithered edges (e.g. halftone/texture dots). 0 (default) gives hard 1-bit edges. For plain --dither it is the smoothstep blend width across the A/B boundary (~1 blends the two colours across the whole dot). For --rgb the crisp per-channel result is strictly on-palette (and A/B are nearest neighbours with no colour between them), so its edges can't be softened on-palette β€” they are anti-aliased in the render by a blur that grows with the value (try ~0.5-1.5).
  • --prefer-smallest β€” when dithering, bias toward the darker of the two colours.
  • -ehb / --extra-half-brite β€” double the palette by appending a half-brightness copy of every colour before matching, giving a quick set of darker shades.

Transparency in the source image is preserved.

Options that the current run doesn't use are reported as a warning: on stderr (e.g. --bayer without --dither, --hsv-weights with --metric rgb) rather than being silently ignored.

Project layout

src/paletti/
  cli.py        # click command-line interface
  core.py       # the shader port: two-nearest match + mode rendering
  color.py      # vectorised rgb<->hsv (ports rgb2hsv / hsv2rgb)
  dither.py     # ordered-dither value sources (nearest/sine/bayer/texture)
  palette.py    # load palettes from images or JSON
  imageio.py    # image load/save with alpha preservation (incl. SVG rasterizing)
  svg.py        # recolour an SVG's vector colours onto a palette (vector output)

About

🎨 Easily Apply Palettes to Images and Vector Art via Perceptive Metrics

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Contributors

Languages