Halftoning

(Originally published as a part of Shader Advent Calendar 2024)

Introduction

Halftoning is essentially binary thresholding with a pattern. You could also think of it as a monochrome dithering process.

It’s commonly used to mimic a printed look, as seen in magazines or comics.

halftone example

The process is combining a gray scale image (top-left) with a pattern (bottom-left, represented by a “halftone map”). The result is a binary image (to the right).

Basic halftone example on shadertoy (applied to video)

// create grid
gridUv = mod(gridUv * gridSize., 1.0);

// for each grid-cell, just take the distance to center
// and divide by the length of half the diagonal 
float halftone = length(gridUv - 0.5) / length(vec2(0.5, 0.5));

// threshold
float c = halftone < lum ? 1.0 : 0.0;

Halftone dot shapes

I think of the pattern as a grayscale image (halftone map), that defines the detail shapes for all levels of intensity.

halftone-shapes

Halftone dot shapes illustration (shadertoy)

float hatchHalftone(vec2 uv) {
   return 1.0 - 2. * abs(uv.y - 0.5);
}

float squareHalftone(vec2 uv) {
   return 1.0 - 2. * max(abs(uv.x - 0.5), abs(uv.y - 0.5));
}

float roundHalftone(vec2 uv) {
   // just take the distance to center and divide by diagonal
   return 1.0 - length(uv - 0.5) / length(vec2(0.5, 0.5));
}

The dot-shapes most commonly used in printing are: round, elliptical and square. But we can use any kind of pattern!

halftone-shapes

Preserving percieved contrast/brightness

So far we’ve totally ignored the issue with drifting contrast and brightness as we do halftoning. To get a bit more rigour, we should: 1. Do halftoning in linear color space 2. Make sure our halftone maps have uniform histograms

Having a uniform histogram just means that:

The hatched halftone map already has this property, as it kind of opens linearly like a sliding door. The square one needs a simple adjustment (squaring one of the terms), but the dot one is trickier since the dots grow and eventually start overlapping. A few years back I came up with a function that warps the dot halftone map so it’s close to uniform, but I think there is probably a much better solution…

Here is a madtealab link with 3 halftone maps, and a way to verify that they are close to uniform

It’s still too dark…

halftone still to dark example

With linear processing and uniform histograms, it’s still too dark. And running out of rigour, a tried a quick hack to compensate, by adjusting the input channel in linear space, like so:

x += pow(x, ShapeLowBoostGamma) * (1. - x) * ShapeLowBoost; 

Playing around a bit I concluded that the feeling of too much darkness: * increase as the dots gets bigger * decrease when the pattern has lines rather than dots * changes depending on display/platform (I should stop now…)

(But I did not try to compensate for these)


This final shadertoy version displays 5 variants, as the square and round patterns behave differently when reversed:

Halftone - 3 kinds (shadertoy)

ethically sourced lena in halftone stripes

Interesting consequences…

Let’s revisit the hatched ramp:

halftone gray gradient ramp with gamma correction

Now, let’s think of the hatched ramp as a white vector shape against a black background. Intuitively, if a vector shape represents a smooth transition between colors A and B, surely we can swap the colors and expect a similar result!

This is what actually happens:

halftone gray gradient ramp with gamma correction - inverted

The transition is now much too bright, and the end is abrupt. So I guess a shape is never really independent from the color context…

I don’t really know what’s going on, but I guess it has to do with the remarkable dynamic range of human vision. A similar effect is observed in typography, making white text on dark backgrounds appear more bold than dark text on bright backgrounds, sometimes referred to as the irradiation illusion.

I feel it makes sense that a speck of light in the dark would be more salient than absense of light in a sea of brightness. Some of it is probably due to light scattering inside the eye, and some of it is probably in the brain circuitry.

Test image by Morten Rieger Hannemose, 2019