Reducing Tones to Sun and Shadow
The cyanotype paper doesn’t care about your continuous tones. You can coat the most beautiful gradient onto a transparency, and the ferric ammonium citrate underneath will just… pick a side. Enough UV photons? Prussian blue. Not enough? Washes right out. Binary. On or off.
This is the same problem every graphics programmer hit in the 1970s when displays had exactly two colours and photographs had millions. The solution was dithering: scatter the decision threshold around in a pattern, so that groups of pixels average out to mid-tones even though each individual pixel is still just on or off.
Ordered dithering uses a small matrix—often 4×4 or 8×8—of threshold values. You tile this matrix across the image, and at each pixel you ask: is my brightness greater than the threshold at this position? If yes, white. If no, black. The Bayer matrix arranges these thresholds in a particular recursive pattern that minimizes visual artifacts.
The Bayer 4×4 matrix:
0 8 2 10
12 4 14 6
3 11 1 9
15 7 13 5
Normalized to 0-1 range, then compared against each pixel's brightness.
Here’s the algorithm in Go, reading a greyscale value and returning whether that pixel should be “on” in the dithered output:
var bayer4x4 = [4][4]float64{
{0.0 / 16, 8.0 / 16, 2.0 / 16, 10.0 / 16},
{12.0 / 16, 4.0 / 16, 14.0 / 16, 6.0 / 16},
{3.0 / 16, 11.0 / 16, 1.0 / 16, 9.0 / 16},
{15.0 / 16, 7.0 / 16, 13.0 / 16, 5.0 / 16},
}
func dither(x, y int, brightness float64) bool {
threshold := bayer4x4[y%4][x%4]
return brightness > threshold
}
And in Lua, where arrays are 1-indexed and everything is a table:
local bayer = {
{0/16, 8/16, 2/16, 10/16},
{12/16, 4/16, 14/16, 6/16},
{3/16, 11/16, 1/16, 9/16},
{15/16, 7/16, 13/16, 5/16}
}
local function dither(x, y, brightness)
local threshold = bayer[(y % 4) + 1][(x % 4) + 1]
return brightness > threshold
end
The key insight is that the matrix is designed so adjacent thresholds are as far apart as possible in value. Position (0,0) has threshold 0, but (0,1) jumps to 8/16. This scattering prevents the banding you’d get if thresholds increased smoothly left-to-right.
I printed a dithered test gradient today as my second cyanotype. The dots are visible up close—about 1mm per pixel at the resolution I used—but from arm’s length, the eye blends them into smooth greys. Same trick newspapers used for a century with halftone dots. Same trick the Bayer matrix was designed for in 1973.
The chemistry doesn’t know it’s looking at a gradient. Each dot of sensitizer either gets enough photons or it doesn’t. But tile that binary decision across thousands of positions with carefully chosen thresholds, and continuous tone emerges from the discontinuous.