Clean uneven document lighting
Estimate the background illumination, divide it out, then apply local thresholding.
The pipeline

The unchanged source image. All steps use this same example.

A broad blur approximates slowly varying page illumination.

Dividing by the illumination estimate reduces broad brightness variations.

Final output of the complete chain.
Estimate illumination
Explore the algorithm →GaussianBlurA broad Gaussian blur models slowly varying lighting.
- Receives
- Grayscale page intensity.
- Passes to the next step
- A smooth estimate of background illumination.
Why this step? A broad blur suppresses thin strokes while retaining slow brightness changes. Under the assumption that the page is mostly background at this scale, this gives an approximate illumination field rather than a useful sharpened or denoised page.
Normalize the page
Explore the algorithm →Mat.convertTodivideDivide grayscale intensity by that background estimate.
- Receives
- Original grayscale and illumination estimate in float32.
- Passes to the next step
- A page with reduced broad brightness variation.
Why this step? Dividing original intensity by estimated illumination compensates for multiplicative shading. The denominator is clamped to at least one to avoid division by zero, and a scale of 220 maps background near a readable brightness before conversion back to 8-bit.
Separate ink
Explore the algorithm →adaptiveThresholdADAPTIVE_THRESH_GAUSSIAN_CAdaptive thresholding preserves locally dark strokes.
- Receives
- The normalized 8-bit page.
- Produces
- A binary document suitable for inspection or a later OCR stage.
Why this step? A locally weighted mean minus C supplies a threshold for each pixel. The neighbourhood adapts to residual lighting differences that one global threshold would miss, turning darker strokes into black ink on a white background.
Actual OpenCV 5.0.0 results on the illustrated sample, using the lab’s default algorithm settings. Masks, overlays and normalized fields are labelled previews; the data contracts above describe what the algorithms really exchange. Try this chain with your images ↓
Why this chain works
The page combines ink detail with slower lighting variation. Estimate that slow variation, divide it out, then use a local decision rule for the remaining ink.
- 01
Estimate illumination
GaussianBlurA broad blur suppresses thin strokes while retaining slow brightness changes. Under the assumption that the page is mostly background at this scale, this gives an approximate illumination field rather than a useful sharpened or denoised page.
- Receives
- Grayscale page intensity.
- Passes on
- A smooth estimate of background illumination.
- 02
Normalize the page
Mat.convertTodivideDividing original intensity by estimated illumination compensates for multiplicative shading. The denominator is clamped to at least one to avoid division by zero, and a scale of 220 maps background near a readable brightness before conversion back to 8-bit.
- Receives
- Original grayscale and illumination estimate in float32.
- Passes on
- A page with reduced broad brightness variation.
- 03
Separate ink
adaptiveThresholdADAPTIVE_THRESH_GAUSSIAN_CA locally weighted mean minus C supplies a threshold for each pixel. The neighbourhood adapts to residual lighting differences that one global threshold would miss, turning darker strokes into black ink on a white background.
- Receives
- The normalized 8-bit page.
- Passes on
- A binary document suitable for inspection or a later OCR stage.
Tune and diagnose
Choose the parameters
Illumination sigma should be broad compared with stroke width. The threshold window should span several strokes while remaining local to lighting variation. Increasing C lowers the threshold and generally makes more pixels white, which can remove both background noise and faint ink.
Read the result
If text remains visible in the illumination estimate, the blur scale is too small or the background assumption is failing. Large graphics and abrupt shadows can contaminate the estimate. Preserve enough processing resolution for thin strokes.
Try it with your images
Choose your own image or start with the built-in sample. Run the recipe, then use the stage buttons to inspect intermediate results without rerunning it.
Experiment at pixel level
Estimate the background illumination, divide it out, then apply local thresholding.
The engine loads on your first run. Your images stay in this browser.
Scroll over either image to zoom at the pointer. Use the scrollbars to pan both views over the same relative area. Zoom is relative to the input; pixel coordinates belong to each image. Warps can change scene correspondence.
Pixel inspector RGBA · native values · matched scale · 9 × 9 output pixels
Select a pixel
Select a pixel
Assumptions and limits
Large graphics and broad shadows can contaminate the illumination estimate. Use the processing size control to preserve small text.
TypeScript core chain
Initialize the shared engine once with await initOpenCV(), then use these named imports. In a bundled browser app, pass the WASM URL as shown in the quickstart. image is an 8-bit BGR Mat from that same engine; paired recipes receive an equally sized nextImage. Region recipes use an in-bounds pixel rect. Read outputs before the using scope ends. See matrix ownership.
import {
ADAPTIVE_THRESH_GAUSSIAN_C,
COLOR_BGR2GRAY,
CV_32F,
CV_8U,
GaussianBlur,
Mat,
THRESH_BINARY,
adaptiveThreshold,
cvtColor,
divide
} from '@banou/opencv-wasm'
// The engine is already initialized; image is an 8-bit BGR Mat.
// "using" releases native handles at scope exit; inspect or copy outputs before then.
// Allocate brightness, estimated lighting, corrected brightness and binary output.
using gray = new Mat(), illumination = new Mat(), normalized = new Mat(), output = new Mat()
// Convert the page to grayscale so the chain works on ink/background intensity.
cvtColor(image, gray, COLOR_BGR2GRAY)
// Use floating-point arithmetic for the upcoming division without early integer rounding.
gray.convertTo(gray, CV_32F)
// 1. A broad sigma-20 blur estimates slow lighting variation while averaging thin strokes.
// Zero kernel size is selected from sigma; choose a scale wider than the text strokes.
GaussianBlur(gray, illumination, { width: 0, height: 0 }, 20)
// Clamp the estimated illumination to at least one intensity unit to avoid division by zero.
for (let i = 0; i < illumination.data32F.length; i++) illumination.data32F[i] = Math.max(1, illumination.data32F[i])
// 2. Compute 220 * original / estimatedLighting at each pixel.
// This removes approximate multiplicative shading and places page background near 220.
divide(gray, illumination, normalized, 220)
// Adaptive thresholding expects 8-bit input; convert the corrected values back to 0..255.
normalized.convertTo(normalized, CV_8U)
// 3. Compare each pixel with a Gaussian-weighted local mean minus C=9,
// using a 31x31 neighbourhood. THRESH_BINARY makes brighter page pixels 255
// and darker strokes 0; larger C generally removes more faint ink as well as noise.
adaptiveThreshold(normalized, output, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, 31, 9)The snippets isolate the core operations. The complete runnable recipes also include validation, filtering, overlays, intermediate previews and resource cleanup.