Turn a photograph into a stylised image — flat facets, polygonal cells, painterly impasto — in a light or a twilight variant.
using FileIO, PhotoEffects
img = load("photo.jpg")
out = apply(Oil(radius = 9, passes = 2), fit_cover(img, 2880, 1864))
save("wallpaper.png", out)
dark = apply(Oil(), fit_cover(img, 2880, 1864);
appearance = Appearance.DARK)The building blocks already exist in the Julia ecosystem —
DelaunayTriangulation.jl for triangulation, DitherPunk.jl for dithering,
ImageFiltering.jl and ImageSegmentation.jl for the rest. The assembly
does not: no package in the General registry does artistic image
stylisation. That is the gap this one fills.
An effect is an immutable value carrying its parameters, never an image:
abstract type AbstractEffect end
struct Oil <: AbstractEffect
radius::Int
passes::Int
endapply(effect, img) dispatches on the effect type. Everything shared across
the catalogue — the fit_cover crop, the twilight variant, eventually the
multi-resolution render loop — is written once, however many effects
there are.
The dark variant is not a separate effect: it is the same image taken to twilight, so both versions of a wallpaper share the exact same geometry.
| Effect | Family | Principle |
|---|---|---|
LowPoly |
tessellation | Delaunay triangulation, flat facets |
Voronoi |
tessellation | polygonal cells, mean colour |
VoronoiStained |
tessellation | polygonal cells separated by leading |
VoronoiLloyd |
tessellation | relaxed cells of increasingly even area |
Cubist |
tessellation | sparse convex planes with shifted colours |
HexMosaic |
tessellation | regular honeycomb cells in their mean colour |
PixelMosaic |
tessellation | square mean-colour blocks with optional joints |
Oil |
painting | Kuwahara filter |
Posterize |
painting | channels snapped to N levels, optional inked edges |
Watercolour |
painting | soft washes, granulation and paper lightening |
Brushes |
painting | gradient-oriented sampled-colour strokes |
Pointillism |
painting | edge-aware sampled-colour dots on paper |
LineArt |
minimal | normalized Sobel edges on plain paper |
Blobs |
minimal | dominant-palette gradient and soft colour masses |
TspArt |
minimal | one closed line through darkness-weighted stipples |
FlowField |
procedural | source-coloured gradient-following trails |
ReactionDiffusion |
procedural | periodic Gray–Scott texture tinted by source |
Glitch |
procedural | channel offsets, displaced slices and pixel sorting |
Duotone |
minimal | luminance mapped onto a colour ramp |
Halftone |
screen | tone as dot area on a tilted lattice |
Contour |
screen | iso-luminance topographic linework |
Hatching |
screen | crossed engraving lines following shadow density |
Ascii |
screen | monospace bitmap glyphs selected by luminance |
Dither |
screen | Floyd–Steinberg or Bayer palette reduction |
Pipeline |
composition | effects applied from left to right |
Grain |
post-processing | deterministic luminance or chromatic texture |
Vignette |
post-processing | smooth radial perimeter attenuation |
Bloom |
post-processing | thresholded highlight diffusion |
TiltShift |
post-processing | sharp band with progressive peripheral blur |
Border |
post-processing | dimension-preserving inward print mat |
See ROADMAP.md for the effects still to come.
Effects use a predictable RGB working representation internally, then return
to the input colour model and precision. Gray, HSV, Lab, floating-point
RGB and transparent images are accepted. Alpha values are carried unchanged.
Effects with an intrinsic palette, such as Duotone and Halftone, keep a
colour output when given a grayscale input. Select another output model
explicitly when needed:
using Colors
lab = apply(Oil(), img; output_type = Lab{Float32})For every pixel, the four overlapping quadrants around it are evaluated; the most homogeneous one wins, and the pixel takes its mean colour.
In the middle of a flat area the quadrants are equivalent and the region smooths into impasto; on an edge, only the quadrant on the correct side is homogeneous, so colour never crosses the boundary. A blur would average both sides — that is the whole difference.
The computation goes through integral images: the cost does not depend on
the radius. Selection arithmetic is integral (luminance in thousandths,
variance compared as n·Σx² − (Σx)²), so the render depends neither on
summation order nor on dependency versions.
radius is in pixels: it must scale with the output width, otherwise the
grain changes from one resolution to the next.
| Width | radius |
|---|---|
| 1920 | 6 |
| 2880 | 9 |
| 5120 | 16 |
Both start from the same seeding: points drawn dense along edges and sparse over flat areas, so facets are small where the image varies and large across the sky — that is what makes shapes survive the simplification.
The seeding strategy is a first-class citizen of the API. By default, constructing an effect generates a pseudo-random draw:
effect = Voronoi(points = 3000, seed = 42)But you can extract this step via the Seeding hierarchy:
# 1. Define the strategy
strategy = Scatter(points = 3000, seed = 42)
# 2. Resolve it into an explicit point cloud
cloud = sow(strategy, img) # Returns a Given(...) containing the points
# 3. Apply it
out = apply(Voronoi(cloud), img)Passing a Given skips the random draw entirely. This is how you share the same exact seeds across multiple effects, or animate them over time.
They then part ways on the tiling, each the dual of the other:
LowPolytriangulates the seeds (DelaunayTriangulation.jl) and fills each triangle with the mean of its centroid and three vertices.Voronoiattaches every pixel to its nearest seed and paints each cell with a genuine area average — gradients survive better.
Sampling without replacement uses the A-Res algorithm of Efraimidis–Spirakis: one single sweep instead of the quadratic sequential draw of a naive approach.
seed fixes the point draw for Scatter. Equal seeds give identical renders, including
across Julia versions: the stream comes from StableRNGs.jl, since the
Random stream is not guaranteed stable between versions — which matters
when the resulting PNGs are version-controlled.
Why there are no explicit Voronoi polygons. Cell membership is "the nearest seed": a KDTree computes it exactly and covers the whole image without the delicate clipping that rasterising polygons at the border would require. Explicit geometry (
DelaunayTriangulation.voronoi,centroidal_smooth) will become necessary for stained-glass leading and for Lloyd relaxation.
HexMosaic assigns pixels to a triangular lattice of centres, whose nearest
regions are regular hexagons. PixelMosaic divides the raster into clipped
square blocks and can add a coloured joint between tiles. Both paint every
cell with the exact mean colour below it and cover partial cells at borders.
Cubist builds a sparse irregular convex tiling and shifts each cell colour
through a deterministic stream. The result keeps the scene's broad geometry
while breaking continuous surfaces into contrasting planes. shift=0
recovers the equivalent Voronoi area averages exactly.
Each channel is snapped to levels values, collapsing gradients into hard
bands the way a screen print reproduces a photograph with a limited number of
inks. The quantisation grid includes both endpoints, so pure black and pure
white survive and the mapping is idempotent — re-applying changes
nothing, and bands never drift.
outline inks the contours above a given edge strength, which turns the
poster look into cel-shading: bands become fills, edges become linework.
Pigment is mixed with a Gaussian neighbourhood to bleed across hard edges,
then modulated by deterministic fine granulation. paper lifts the wash
towards white as if the support showed through. The texture is reproducible
for a fixed seed, while radius is expressed in pixels and should scale
with output width.
Dots are concentrated around image detail by the shared seeding machinery,
painted with their source colour, and given deterministic varying radii.
background_weight balances edge-following density against uniform coverage;
the radius bounds are measured in output pixels.
Thousands of source-coloured strokes follow the local luminance gradient. Their centres use edge-aware seeding; deterministic random orientations keep flat regions painterly instead of imposing an arbitrary global direction. Stroke length and width are measured in output pixels.
Every pixel is reduced to its luminance, which then indexes a ramp built from
stops. Tonal structure survives, the original hues do not.
With two stops the entire image lies on a segment of RGB space — that is what makes it read as two inks rather than a tinted photo. More stops bend the ramp; a saturated third one gives the classic split-tone. Since only luminance survives, light and dark variants are a matter of picking pale or deep stops rather than post-processing.
The image is covered by a tilted lattice; each cell is inked over a fraction of its area proportional to local darkness. Seen from far enough the eye integrates coverage back into continuous tone; up close it is offset printing.
Output holds two colours only — a halftone simulates grey through area, never through intermediate tones.
Dither delegates its raster algorithm to DitherPunk and exposes stable
effect parameters: Floyd–Steinberg error diffusion or an ordered Bayer
matrix, plus either an evenly spaced grayscale ramp or an explicit palette.
The lattice is rotated (45° by default) because an unrotated screen aligns
with the pixel grid and beats against it into moiré. Like Oil's radius,
cell is in pixels and must scale with the output width.
Effects can be animated by treating a sequence as a function of time t -> AbstractEffect. The render function lazily evaluates this sequence without holding multiple frames in memory:
f(t) = Voronoi(points = 3000, detail = 1.4 + 0.5 * sin(t))
frames = render(f, img, range(0, 2π, length=60))
# frames is an iterator; consume it to encode a video or save a GIF
for (i, fr) in enumerate(frames)
save("frame_$i.png", fr)
endFor an isolated frame without shared state, use frame(f, img, t).
fit_cover enlarges until the target format is covered, then trims the
overflow at the centre. Downscaling happens in two stages: antialiased 2:1
decimation via restrict while a factor of two is still available, then
Lanczos for the fractional step. imresize alone interpolates without
averaging and makes foliage crawl on a photo reduced by a factor of two or
more.
julia --project=test test/runtests.jlTests target the properties that define each effect rather than pixel
values: for Oil, "a hard step stays hard, with no intermediate value at
all"; for fit_cover, "a 1px checkerboard reduced gives flat grey, not a
solid field nor moiré".
Renders are meant to be version-controlled, so the same call must produce the same image.
Oil is deterministic by construction: it involves no random draw at all, and
its quadrant selection is entirely integral, so the result depends neither on
summation order nor on dependency versions.
LowPoly and Voronoi draw their seeds, and take a seed parameter to pin
that draw. Their stream comes from StableRNGs.jl rather than Random, whose
output is not guaranteed stable across Julia versions.
Note that the encoded PNG can still differ between machines even when the pixels are identical, since compression depends on the imaging stack. Pin your toolchain if byte-level reproducibility matters.
MIT — see LICENSE.