01 / The idea
A printer with one ink still needs gray.
Take a shaded ball, 48 dots square, lit from the bottom right. It is darkest at the top left, almost black, and fades to light gray toward the bottom right. The printer can do two things with each dot: burn it black or leave it white.
The obvious rule is to round: below 128 is black, the rest white. That gives 772 black dots, and the shading is gone. The ball becomes a flat shape with a hard edge where the light was, and its average level drops from 181.2 to 169.6.
Floyd–Steinberg dithering rounds each pixel too, then hands the amount it rounded away to the neighbors it hasn’t printed yet, so the dots keep the picture’s tone on average.
Watch it round the ball, walk a flat gray patch, and compare the two results.
Two inks, every shade.
Round each pixel on its own.
At 128, each of the ball’s 2304 pixels becomes black or white by itself. 772 come out black, the shading collapses into flat shapes, and the average level falls from 181.2 to 169.6.
Reduced motion: choose a scene to see its completed state.
Read this scene
At 128, each of the ball’s 2304 pixels becomes black or white by itself. 772 come out black, the shading collapses into flat shapes, and the average level falls from 181.2 to 169.6.
The 48 by 48 ball, and the same ball thresholded at 128: 772 black dots, average level 169.6.
Watch and Step through replay the ball and a flat gray patch. Try it runs the same TypeScript on the lesson’s pictures.
Dithered, the ball prints 664 black dots, and its average level is 181.5, within half a level of the original. Up close it is dots. At the printer’s size, your eye does the averaging.
02 / Name the rule
Round the pixel. Hand the difference on.
Visit the pixels in reading order: left to right, top to bottom. Add whatever error is waiting for the pixel, round the result to black or white, and measure how far off that was. Then split the difference among the four neighbors that come later.
128 and up → white
The pixel plus the error waiting for it, kept within 0 to 255.
level − dot
From −127 to 127: how far the dot missed, and in which direction.
7 · 3 · 5 · 1 sixteenths
Right, below-left, below, and below-right. Never a pixel already printed.
On a patch where every pixel is 96, the first pixel prints black, 96 levels too dark. Seven sixteenths of that, 42 levels, goes right. The next pixel is 96 + 42 = 138, so it prints white, 117 levels too light, and hands that on. The pixel after it reads 96 − 51 = 45 and prints black. Six of the patch’s 15 dots come out white, where 96 is 37.6% of the way to white.
Those four weights, 7, 3, 5, and 1 out of 16, are the ones Robert Floyd and Louis Steinberg published in 1976. They add up to one whole difference.
Why the tone survivesThe difference is moved, not thrown away
Rounding alone throws each pixel’s difference away, and on a smooth area every pixel throws it away in the same direction. That is the flat shape and the dropped average.
Here the whole difference goes into pixels still to be printed, so what the dots have missed so far is always waiting ahead, at most a pixel’s worth at any one pixel. Only three things are lost: shares that would land outside the picture, the fraction of a level cut off when a pixel reads its sixteenths, and anything clamped when a pixel would pass 0 or 255.
The tests check it. On a 64 × 64 flat picture of every level from 0 to 255, the share of white dots stays within one percentage point of the level’s share of white (level ÷ 255); the worst of the 256 is off by 0.81 points. The shaded balls and a gradient keep their average within one level.
03 / Read the shape
Keep the error in sixteenths.
Fractions would work, but whole numbers keep the two languages identical. The code stores
each share multiplied by its weight, in sixteenths of a level, and divides by 16 when the
pixel reads it, rounding toward zero. Only two rows of error exist at a time: row for the pixels being printed and below for the next row. At
the end of a row they swap, and the new below is emptied.
Basic form is dither and threshold. In the wild takes canvas pixels: toGray puts each pixel on
white paper by its alpha and weighs red, green, and blue by 0.2126, 0.7152, and 0.0722, then packBits writes one bit per dot. At the call site draws the lesson’s
pictures and prints four lines. Both languages print the same four lines.
dither visits pixels in reading order, rounds each to black or white at 128, and hands the difference on in sixteenths: 7 right, 3 below-left, 5 below, 1 below-right. It keeps two rows of error. threshold is the comparison.
// Floyd–Steinberg dithering to black and white. A picture is gray levels 0 (black) to
// 255 (white), row by row, top to bottom and left to right.
export const MAX_SIDE = 256; // a teaching bound on width and height
export type DitherErrorCode = 'bad-size' | 'bad-pixels' | 'bad-level';
export class DitherError extends Error {
readonly code: DitherErrorCode;
constructor(code: DitherErrorCode, message: string) {
super(message);
this.name = 'DitherError';
this.code = code;
}
}
export type Gray = { width: number; height: number; pixels: Uint8Array };
// Visit pixels in reading order. Round each one to black or white, then hand the difference
// to the four neighbors not visited yet, in sixteenths: 7 right, 3 below-left, 5 below,
// 1 below-right. A share that would land outside the picture is dropped.
export function dither(image: Gray): Gray {
checkSize(image.width, image.height, image.pixels.length);
const { width, height, pixels } = image;
const dots = new Uint8Array(width * height);
let row = new Int32Array(width); // error waiting for this row, in sixteenths of a level
let below = new Int32Array(width); // error already sent to the next row
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const level = Math.min(255, Math.max(0, pixels[y * width + x] + Math.trunc(row[x] / 16)));
const dot = level >= 128 ? 255 : 0;
dots[y * width + x] = dot;
const error = level - dot; // from -127 to 127
if (x + 1 < width) row[x + 1] += 7 * error;
if (y + 1 < height) {
if (x > 0) below[x - 1] += 3 * error;
below[x] += 5 * error;
if (x + 1 < width) below[x + 1] += error;
}
}
[row, below] = [below, row];
below.fill(0);
}
return { width, height, pixels: dots };
}
// The comparison: every pixel at or above `level` is white, everything else black.
export function threshold(image: Gray, level = 128): Gray {
checkSize(image.width, image.height, image.pixels.length);
if (!Number.isInteger(level) || level < 1 || level > 255)
throw new DitherError('bad-level', 'a threshold is a whole number from 1 to 255');
return { ...image, pixels: image.pixels.map((value) => (value >= level ? 255 : 0)) };
} // Floyd–Steinberg dithering to black and white. A picture is gray levels 0 (black) to
// 255 (white), row by row, top to bottom and left to right.
const MaxSide = 256 // a teaching bound on width and height
type DitherError struct {
Code string // "bad-size", "bad-pixels", or "bad-level"
Message string
}
func (e *DitherError) Error() string { return e.Message }
type Gray struct {
Width, Height int
Pixels []byte
}
// Visit pixels in reading order. Round each one to black or white, then hand the difference
// to the four neighbors not visited yet, in sixteenths: 7 right, 3 below-left, 5 below,
// 1 below-right. A share that would land outside the picture is dropped.
func Dither(image Gray) (Gray, error) {
if err := checkSize(image.Width, image.Height, len(image.Pixels), 1); err != nil {
return Gray{}, err
}
width, height := image.Width, image.Height
dots := make([]byte, width*height)
row := make([]int, width) // error waiting for this row, in sixteenths of a level
below := make([]int, width) // error already sent to the next row
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
level := min(255, max(0, int(image.Pixels[y*width+x])+row[x]/16))
dot := 0
if level >= 128 {
dot = 255
}
dots[y*width+x] = byte(dot)
err := level - dot // from -127 to 127
if x+1 < width {
row[x+1] += 7 * err
}
if y+1 < height {
if x > 0 {
below[x-1] += 3 * err
}
below[x] += 5 * err
if x+1 < width {
below[x+1] += err
}
}
}
row, below = below, row
clear(below)
}
return Gray{width, height, dots}, nil
}
// The comparison: every pixel at or above level is white, everything else black.
func Threshold(image Gray, level int) (Gray, error) {
if err := checkSize(image.Width, image.Height, len(image.Pixels), 1); err != nil {
return Gray{}, err
}
if level < 1 || level > 255 {
return Gray{}, &DitherError{"bad-level", "a threshold is a whole number from 1 to 255"}
}
out := make([]byte, len(image.Pixels))
for i, v := range image.Pixels {
if int(v) >= level {
out[i] = 255
}
}
return Gray{image.Width, image.Height, out}, nil
} Reading the TypeScriptByte arrays and truncating division
A picture is a Uint8Array of gray levels with its width and height. The
error rows are Int32Arrays, so negative sixteenths fit, and Math.trunc divides toward zero the way Go’s integer division does.
Canvas pixels arrive as a Uint8ClampedArray, and toGray accepts either array. Errors are a DitherError whose code matches the Go version.
Reading the GoByte slices and the min, max, clear builtins
A picture is a []byte with its width and height, and the two error rows are
integer slices that swap at the end of each row. The min, max, and clear builtins, all in Go 1.21, keep a level within 0
to 255 and empty the next row. Errors come back as a DitherError with the same codes.
What is refusedSizes, pixel counts, and levels
Width and height must be whole numbers from 1 to 256 (bad-size), a teaching
bound. The pixel count must match them, four values per pixel for RGBA (bad-pixels). A threshold outside 1 to 255, or a flat gray outside 0 to 255, is bad-level.
Both languages check the shared cases, including the story’s patch and levels that would
pass white or black, then compare dither with a separate whole-picture version
on 200 generated pictures.
04 / Try a decision
Where does the edge error go?
The weights never change. But a pixel on the right edge has no neighbor to its right, and the bottom row has nothing below it.
Dropped shares are one of the few places the walk loses tone outright. The roughest part of a dithered picture is its first row and column, though: no error has reached them yet.
05 / Follow the cost
One pass, two rows of error.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Threshold | O(n) | O(n) | One comparison per pixel, written into a new picture. |
| Dither | O(n) | O(w) | Four shares per pixel. Besides the n output dots, two rows of w error counters. |
| Canvas pixels to gray | O(n) | O(n) | Four RGBA values in, one gray level out, per pixel. |
| Pack the dots | O(n) | O(n) | One bit per dot: ⌈w / 8⌉ bytes for each row. |
Each pixel is read once and sends at most four shares, so dithering costs the same order of work as thresholding. The only extra memory is the error waiting in two rows.
What it buys is tone. On a flat gray of 96, 64 × 64, 1,532 of the 4,096 dots come out white: 37.4%, where 96 is 37.6% of white. The 64 × 64 ball averages 72.0% of white dithered against 71.9% in gray. Thresholded, it prints 1,324 black dots instead of 1,146, and its average falls to 67.7%.
What dithering can’t doTone, not detail
It keeps the average, not the edges. A line thinner than a dot, small text, or a barcode whose edges scaling has turned gray comes back ragged, with stray dots scattered along it. Rounding keeps those edges clean.
It is a walk, not a filter. Each pixel waits on the error from the one before it, so the rows can’t be split up and run separately, and the dots always lean in the reading direction. Some implementations scan alternate rows right to left, called serpentine scanning, to break that up.
Libraries differ in the details, so their dots won’t match yours pixel for pixel. In Pillow 11.3.0’s C code, the two-level conversion keeps one row of width + 1 error values and prints white only above 128. Compare tone, not individual dots.
06 / Give it a real job
Print the label in dots.
A shipping app lets people put a logo on their labels. The printer takes one bit per dot, so labelBitmap in In the wild takes the logo’s canvas pixels, puts them on white paper, dithers
them, and packs the dots: 8 bytes a row, leftmost dot in the high bit, 1 for black. For the 64
× 64 ball that is 512 bytes and 1,146 black dots. The format is this lesson’s own, not a particular
printer’s command set.
The preview matters because the user can’t take a print back. A logo that looks fine in gray may turn to mush in dots, and the only honest preview is the dots themselves.
Build UIs?The browser hands you pixels and crisp enlargement, but not dots. The label preview is yours to run.
Where it already is in your components
Not as dithering. React, Svelte, and the canvas API have no call that turns gray into
black and white dots: CanvasRenderingContext2D draws, transforms, and reads pixels, and “dither” isn’t on its reference page. CSS filter: grayscale(1) gets you gray, still not two inks.
What the browser gives you is both ends of the job. getImageData returns a canvas’s pixels as RGBA bytes, and putImageData “paints data from the given ImageData object onto the canvas.” And image-rendering: pixelated enlarges with nearest-neighbor scaling, so a 64-dot preview can fill a card without blurring
back into gray.
When you have to own it
Draw the logo onto a canvas at the printer’s size in dots, over white paper, run the
same dither, and paint the dots back. drawImage smooths as it scales: imageSmoothingEnabled “determines whether scaled images are smoothed (true, default) or not.” That is what you want
on the way down to printer size; the enlargement on screen is left to CSS.
One browser rule bites. A logo from another origin without CORS approval taints the
canvas, and getImageData then throws a SecurityError. Serve
logos from your own origin, or load them with crossOrigin set and CORS headers
on the other side. The walk itself runs over the dots at print size, not the screen’s pixels,
so it stays small.
import { dither, packBits, toGray } from '../dither'; // this lesson's own functions
// Draw a picture at the printer's size in dots, dither it, and show exactly the dots that
// will print. Returns the bytes for this lesson's one-bit-per-dot label format.
export function previewLabel(
picture: CanvasImageSource,
canvas: HTMLCanvasElement,
width: number,
height: number
): Uint8Array {
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) throw new Error('this browser gave no 2D canvas');
context.fillStyle = '#fff'; // label paper
context.fillRect(0, 0, width, height);
// Scales the picture to the label's size, stretching it if the shapes differ. The lesson's code
// accepts sides up to 256 dots; raise that limit for a real printer's width.
context.imageSmoothingQuality = 'high'; // a large logo shrunk with low-quality smoothing can alias
context.drawImage(picture, 0, 0, width, height);
// Throws a SecurityError if a cross-origin picture without CORS approval tainted the canvas.
const image = context.getImageData(0, 0, width, height);
const dots = dither(toGray(image.data, width, height));
image.data.set(toRGBA(dots.pixels));
context.putImageData(image, 0, 0);
canvas.style.imageRendering = 'pixelated'; // enlarge the dots without blurring them
return packBits(dots);
}
export function toRGBA(levels: Uint8Array): Uint8ClampedArray<ArrayBuffer> {
const rgba = new Uint8ClampedArray(levels.length * 4);
levels.forEach((level, i) => rgba.set([level, level, level, 255], i * 4));
return rgba;
}
Nothing here belongs to React or Svelte. Call previewLabel once the image
has loaded, from an effect with a ref to the canvas or from $effect with bind:this, and send the bytes it returns with the order. Its test drives a
fake canvas and checks the painted pixels and the bytes.
07 / Make the call
Dither when the eye will average.
Dither pictures with shading: photos, logos with gradients, a shaded icon. Round text, line art, QR codes, and barcodes with a plain threshold. They need hard edges, and dithering scatters dots along any edge that scaling has turned gray. A label with both gets both: threshold the address, dither the logo.
For image files, use a library. Pillow’s convert("1") already does Floyd–Steinberg,
and ImageMagick offers “a Riemersma or Floyd-Steinberg error diffusion dither.” Write your own
when the dots must match a preview you draw, or when the output is a device format no library
writes.
SourcesDocumentation, source, and specifications, checked 13 September 2026
- Pillow 11.3.0,
Image.convert: dither is “used when converting from mode "RGB" to "P" or from "RGB" or "L" to "1". Available methods are Dither.NONE or Dither.FLOYDSTEINBERG (default).”libImaging/Convert.c,tobilevel: one error row of width + 1 values, white above 128. - ImageMagick command-line options,
-dither: “Apply a Riemersma or Floyd-Steinberg error diffusion dither to images when general color reduction is applied via an option, or automagically when saving to specific formats,” and “Dithering is turned on by default, to turn it off use the plus form of the setting, +dither.” - Filter Effects Module Level 1: the
luminance coefficients 0.2126, 0.7152, and 0.0722 in
luminanceToAlphaandgrayscale. - MDN:
getImageData,putImageData, andimageSmoothingEnabledonCanvasRenderingContext2D;image-rendering; using cross-origin images in a canvas, on theSecurityErrorfrom a tainted canvas. - R. W. Floyd and L. Steinberg, “An adaptive algorithm for spatial grey scale,” Proceedings of the Society of Information Display 17, 75–77, 1976, as cited by Wikipedia’s article, which also gives the weights, the scan order, and serpentine scanning. The paper itself was not read.
08 / Take the idea with you
Explain the dots without saying “Floyd–Steinberg.”
“Go through the picture the way you read a page. Make each spot black or white, whichever is closer, and note how wrong that was. Pass most of the mistake to the next spot on the right and the rest to the spots underneath, so they make up for it.”
Before moving on, find a receipt or shipping label with a logo and look closely at its gray parts. Are they scattered dots, a regular pattern, or solid black? Each is a different answer to the same printer.
Connections to follow nextRelated lessons
- Seam carving also reads a
canvas with
getImageDataand writes it back, and has to decide where the work runs. - Bresenham’s line algorithm carries an error term along a row of pixels too, deciding one dot at a time.
- Ramer–Douglas–Peucker keeps what the eye needs from a line with far fewer points.