Task 3 of 6

A Threshold Per Neighbourhood

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.

Goal: return 1 where gray[y][x] exceeds the mean of its clamped 9×9 neighbourhood by more than this.constants.c, and 0 everywhere else.

Requirements

Hint 1 — it is a box blur that ends in a question

The 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.

Hint 2 — the clamped sample
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];.

Hint 3 — the finish
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.

Same idea elsewhere

Every vision toolkit ships this: OpenCV's 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.

All tasks in Thresholding & Morphology

  1. One Number for the Whole Image
  2. Let the Histogram Pick the Number
  3. A Threshold Per Neighbourhood
  4. Erode and Dilate: the Sweep, With Min and Max
  5. Opening and Closing: Order Is the Answer
  6. Payoff: Clean the Mask, Count What Is Left

This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.