Snapshot.jl
← Notebooks/Image dithering🏝️ 7/7 cells interactive
.jl source

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
end

A 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.

make_gradient

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

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

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

Pick the image size β€” smaller images make the individual dithered pixels easier to see: 48

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

Step 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

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

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

Number of gray levels = 2

let
	n = imgsize
	img = make_gradient(n, n)
	q = quantize(img, levels)
	gray_figure(q, n, n)
end

Step 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

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
end

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

How 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_abs_error

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)
end
0.2780832216763785
err_quantize = let
	n = imgsize
	img = make_gradient(n, n)
	mean_abs_error(quantize(img, levels), img)
end
0.3623699989702734
err_dither = let
	n = imgsize
	img = make_gradient(n, n)
	mean_abs_error(floyd_steinberg(img, n, n, levels), img)
end

Mean 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.

Pluto notebooks as lean Therapy components β€” interactive WebAssembly, no server.