← Applied algorithms
Images, maps, and geometry From gray levels to dots

Floyd–Steinberg dithering

Two inks, every shade.

Ask Python’s Pillow for a black-and-white picture with image.convert("1") and you get a scatter of dots, not flat blobs. Converting to mode "1" dithers, and Dither.FLOYDSTEINBERG is the default. ImageMagick says the same of its -dither option: “Dithering is turned on by default.”

A label printer has that problem with every picture you send it: each dot is black or bare paper. We’ll build its preview, so a shaded logo prints as shading, and the screen shows exactly the dots that will print.

TypeScriptGoOne label picture in each language

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.

Floyd–Steinberg dithering

Two inks, every shade.

SHADED BALL · 48 × 48 DOTS
Gray · average 181.2
Threshold · 772 black
01/ 03
Round each pixel

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.

Round

128 and up → white

The pixel plus the error waiting for it, kept within 0 to 255.

Measure

level − dot

From −127 to 127: how far the dot missed, and in which direction.

Hand on

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.

TypeScriptReading
dither.ts
// 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)) };
}
GoAlongside
dither.go
// 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.

The last pixel of a row, not on the bottom row, prints with a difference of 32. Its right and below-right neighbors are outside the picture. What happens to their shares?

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.

Floyd–Steinberg dithering: time and extra space for a picture of n = w × h pixels
OperationTimeExtra spaceWhat it assumes
ThresholdO(n)O(n)One comparison per pixel, written into a new picture.
DitherO(n)O(w)Four shares per pixel. Besides the n output dots, two rows of w error counters.
Canvas pixels to grayO(n)O(n)Four RGBA values in, one gray level out, per pixel.
Pack the dotsO(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.

label-preview.ts
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 luminanceToAlpha and grayscale.
  • MDN: getImageData, putImageData, and imageSmoothingEnabled on CanvasRenderingContext2D; image-rendering; using cross-origin images in a canvas, on the SecurityError from 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

Copy the complete example, change the flat gray from 96 to 32, and predict roughly how many of the 4,096 dots come out white before you run it.

Back to applied algorithms →