Task 3 of 6
Task 1's failure was not bad luck, and no cleverer single number fixes it: Otsu would pick the best one that exists and the lit corner would still saturate. The premise is what is wrong. One number cannot describe an image whose brightness changes across the frame.
So stop asking for one. Adaptive thresholding compares every pixel
against the mean of its own neighbourhood — a 9×9 box average, which is the
clamped sweep the box blur in Convolution & Filters already makes — plus a small
bias c. A pixel is foreground when it is at least c brighter
than its surroundings. That is a statement about local contrast, and it says nothing
whatever about the lamp.
The window size is the one real choice. It has to be comfortably bigger than the things you are hunting, or the mean drowns in them and a mark declares itself average; and comfortably smaller than the lighting changes, or it stops tracking them and you are back to task 1. Here the marks are 5 pixels across and the light drifts over tens of pixels, so 9×9 sits nicely in between.
gray is the same scene's luminance, one number per pixel. A luminance
pass produces it in a real pipeline — the finale of Data In, Data Out is
exactly that pass — and it
is handed over here so the sweep is the only thing you write.
1 where gray[y][x] exceeds
the mean of its clamped 9×9 neighbourhood by more than this.constants.c,
and 0 everywhere else.this.constants.win × this.constants.win neighbourhood, both coordinates clamped to 0…this.constants.lastthis.thread.y + dy - this.constants.radius, likewise for xthis.constants.area to get the mean1 when this pixel is above mean + this.constants.c, otherwise 0The loop is the one from the 3×3 box blur, widened to 9×9 and reading a single number per cell instead of three channels. The only new line is the last one: instead of painting the mean, compare against it.
let sy = this.thread.y + dy - this.constants.radius;
if (sy < 0) sy = 0;
if (sy > this.constants.last) sy = this.constants.last;
— the same four lines for sx, then sum += gray[sy][sx];.
const mean = sum / this.constants.area;
if (gray[this.thread.y][this.thread.x] > mean + this.constants.c) return 1;
return 0;
The bias goes on the mean, raising the bar. Subtract it instead and flat ground starts reporting itself as foreground.
adaptiveThreshold,
Sauvola and Niblack binarisation in document scanning, and the local-contrast test at
the front of most feature detectors. On a GPU the box average is separable and can be
done in two passes, or in one with a summed-area table — the same trick that makes
real-time adaptive thresholding cheap on a phone.
This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.