Dithering β€οΈ Pluto!
Dithering is a technique for image quantization: it lets us represent an image faithfully using only a handful of colors (or shades of gray).
Everything below the sliders runs in your browser as WebAssembly β no Julia server, no install. We don't load any real image files; instead we build a synthetic grayscale image with plain for loops and then dither it with code we write from scratch.
begin
using PlutoUI, WasmMakie
endA synthetic test image
Each image here is just a flat Vector{Float64} of pixel brightnesses between 0.0 (black) and 1.0 (white), stored column by column with nc columns and nr rows. The helper make_gradient fills it with a smooth diagonal ramp plus a soft circular bump in the middle β enough structure to make dithering interesting.
Build an nr Γ nc grayscale image (flat, column-major) with a smooth gradient and a soft central bump. Values lie in [0, 1].
"Build an `nr Γ nc` grayscale image (flat, column-major) with a smooth gradient and a soft central bump. Values lie in `[0, 1]`."
function make_gradient(nr::Int64, nc::Int64)
img = Vector{Float64}(undef, nr * nc)
cx = (nc + 1) / 2.0
cy = (nr + 1) / 2.0
rad = 0.5 * sqrt(Float64(nr * nr + nc * nc)) / 2.0
for j in 1:nc
for i in 1:nr
ramp = ((i - 1) + (j - 1)) / (nr + nc - 2)
dx = (Float64(j) - cx) / rad
dy = (Float64(i) - cy) / rad
bump = 0.35 * exp(-(dx * dx + dy * dy))
v = ramp + bump
if v < 0.0
v = 0.0
elseif v > 1.0
v = 1.0
end
img[i + (j - 1) * nr] = v
end
end
return img
endRender a flat column-major Vector{Float64} image (nr rows, nc cols, pixel idx = i + (j-1)*nr) as a grayscale WasmMakie figure. Each brightness v is shown as the neutral colour (v, v, v), drawn through the wasm-stable image! path (the heatmap! codegen path traps in compiled islands).
"""
Render a flat column-major `Vector{Float64}` image (`nr` rows, `nc` cols, pixel
`idx = i + (j-1)*nr`) as a grayscale WasmMakie figure. Each brightness `v` is
shown as the neutral colour `(v, v, v)`, drawn through the wasm-stable `image!`
path (the `heatmap!` codegen path traps in compiled islands).
"""
function gray_figure(img::Vector{Float64}, nr::Int64, nc::Int64; px::Int64=320)
# repack column-major (row stride nr) into image! layout (row stride nc),
# flipping rows so image row 1 renders at the TOP, and map gray β RGBA
pix = Vector{NTuple{4,Float64}}(undef, nr * nc)
for i in 1:nr
for j in 1:nc
v = img[i + (j - 1) * nr]
pix[j + (nr - i) * nc] = (v, v, v, 1.0)
end
end
fig = Figure(size = (px, px))
ax = Axis(fig[1, 1])
hidedecorations!(ax)
hidespines!(ax)
image!(ax, (0.0, Float64(nc)), (0.0, Float64(nr)), pix,
Int64(nc), Int64(nr); interpolate = false)
return fig
endPick the image size β smaller images make the individual dithered pixels easier to see:
The original (continuous) image
This is the smooth, full-precision image before any quantization.
let
n = imgsize
img = make_gradient(n, n)
gray_figure(img, n, n)
endStep 1: Just round each pixel
The simplest way to quantize is to snap every pixel to its nearest allowed level. With 2 levels that means black or white; with more levels we get a few shades of gray.
quantize does exactly this: it rounds each pixel to the closest of levels evenly-spaced values. A lot of detail gets lost β large flat bands appear where the smooth gradient used to be. π₯²
Snap a brightness v β [0,1] to the nearest of levels evenly-spaced values.
"Snap a brightness `v β [0,1]` to the nearest of `levels` evenly-spaced values."
function snap(v::Float64, levels::Int64)
steps = levels - 1
q = round(v * steps) / steps
if q < 0.0
q = 0.0
elseif q > 1.0
q = 1.0
end
return q
endQuantize a flat image to levels shades by rounding each pixel independently.
"Quantize a flat image to `levels` shades by rounding each pixel independently."
function quantize(img::Vector{Float64}, levels::Int64)
out = Vector{Float64}(undef, length(img))
for k in 1:length(img)
out[k] = snap(img[k], levels)
end
return out
endNumber of gray levels =
let
n = imgsize
img = make_gradient(n, n)
q = quantize(img, levels)
gray_figure(q, n, n)
endStep 2: Dither with error diffusion
Dithering does much better. Instead of throwing away the rounding error at each pixel, FloydβSteinberg error diffusion spreads that error onto the not-yet-visited neighbours. The errors cancel out on average, so a region that is half-way between two levels gets a 50/50 sprinkle of the two β your eye blends them into the in-between shade.
The classic weights push the error to the right (7/16) and to the three pixels below (3/16, 5/16, 1/16):
FloydβSteinberg dithering on a flat column-major image (nr rows, nc cols) quantized to levels shades. Returns a new flat Vector{Float64}.
"""
FloydβSteinberg dithering on a flat column-major image (`nr` rows, `nc` cols)
quantized to `levels` shades. Returns a new flat `Vector{Float64}`.
"""
function floyd_steinberg(img::Vector{Float64}, nr::Int64, nc::Int64, levels::Int64)
# work on a mutable copy so we can accumulate diffused error in place
buf = Vector{Float64}(undef, nr * nc)
for k in 1:length(img)
buf[k] = img[k]
end
out = Vector{Float64}(undef, nr * nc)
for i in 1:nr
for j in 1:nc
idx = i + (j - 1) * nr
old = buf[idx]
new = snap(old, levels)
out[idx] = new
err = old - new
# right neighbour (same row, next column): 7/16
if j < nc
r = i + j * nr
buf[r] = buf[r] + err * 7.0 / 16.0
end
# below-left (next row, previous column): 3/16
if i < nr && j > 1
bl = (i + 1) + (j - 2) * nr
buf[bl] = buf[bl] + err * 3.0 / 16.0
end
# directly below (next row, same column): 5/16
if i < nr
b = (i + 1) + (j - 1) * nr
buf[b] = buf[b] + err * 5.0 / 16.0
end
# below-right (next row, next column): 1/16
if i < nr && j < nc
br = (i + 1) + j * nr
buf[br] = buf[br] + err * 1.0 / 16.0
end
end
end
return out
endUse the same number of levels as Step 1 (the slider above) so you can compare fairly. The dithered image only ever uses those few shades β yet it looks far closer to the original. Zoom in to see the high-frequency pattern that fools your eye.
let
n = imgsize
img = make_gradient(n, n)
d = floyd_steinberg(img, n, n, levels)
gray_figure(d, n, n)
endHow much error is left?
A simple way to measure quality is the mean absolute error between the quantized result and the original continuous image β averaged over every pixel. Lower is better.
Mean absolute difference between two flat images of equal length.
"Mean absolute difference between two flat images of equal length."
function mean_abs_error(a::Vector{Float64}, b::Vector{Float64})
s = 0.0
for k in 1:length(a)
d = a[k] - b[k]
if d < 0.0
d = -d
end
s = s + d
end
return s / length(a)
enderr_quantize = let
n = imgsize
img = make_gradient(n, n)
mean_abs_error(quantize(img, levels), img)
enderr_dither = let
n = imgsize
img = make_gradient(n, n)
mean_abs_error(floyd_steinberg(img, n, n, levels), img)
endMean abs. error β plain rounding: 0.2780832216763785
Mean abs. error β FloydβSteinberg: 0.3623699989702734
Per-pixel, dithering does not reduce the absolute error (it still snaps to the same few levels). What it changes is where the error goes: it scatters it as high-frequency noise that the eye averages out, instead of leaving big visible bands. That's the whole trick. β¨
Appendix
The idea behind dithering goes back to an engraving technique from the 15th century called stippling, and a related concept, halftoning, is what lets newspapers print shades of gray with only black ink. You may also recognise the look from retro video games with limited palettes β it's what gives the Game Boy Camera its distinctive style!
For the real thing on actual images, take a look at DitherPunk.jl.