Skip to content

Repository files navigation

PhotoEffects.jl

CI Documentation codecov

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)

Why this package

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.

Design

An effect is an immutable value carrying its parameters, never an image:

abstract type AbstractEffect end
struct Oil <: AbstractEffect
    radius::Int
    passes::Int
end

apply(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.

Effects

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.

Colour management

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})

Oil — oil painting (Kuwahara)

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

LowPoly and Voronoi — the two tessellations

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:

  • LowPoly triangulates the seeds (DelaunayTriangulation.jl) and fills each triangle with the mean of its centroid and three vertices.
  • Voronoi attaches 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 and PixelMosaic — regular tilings

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 — angular colour planes

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.

Posterize — poster / cel-shading

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.

Watercolour — wet washes

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.

Pointillism — sampled-colour dots

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.

Brushes — gradient-oriented marks

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.

Duotone — gradient map

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.

Halftone — offset screen

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 — palette reduction

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.

Animation and sequences

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)
end

For an isolated frame without shared state, use frame(f, img, t).

Cropping

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.

Tests

julia --project=test test/runtests.jl

Tests 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é".

Reproducibility

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.

License

MIT — see LICENSE.

About

Turn a photograph into a stylised image — flat facets, polygonal cells, painterly impasto — in a light or twilight variant.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages