Skip to content

Clean uneven document lighting

CookbookDocuments

Estimate the background illumination, divide it out, then apply local thresholding.

Try the recipe ↓

The pipeline

VISUAL WALKTHROUGHDocuments
Follow the images, then read what passes to the next algorithm.
STARTING IMAGEOriginal sample448 × 320
The unchanged source image. All steps use this same example.

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

STEP 01 · 1 / 1Illumination estimate448 × 320
Illumination estimate: A broad blur approximates slowly varying page illumination.

A broad blur approximates slowly varying page illumination.

STEP 01 / 03

Estimate illumination

Explore the algorithm →
GaussianBlur

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

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.

  1. 01

    Estimate illumination

    GaussianBlur

    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.

    Receives
    Grayscale page intensity.
    Passes on
    A smooth estimate of background illumination.
  2. 02

    Normalize the page

    Mat.convertTodivide

    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.

    Receives
    Original grayscale and illumination estimate in float32.
    Passes on
    A page with reduced broad brightness variation.
  3. 03

    Separate ink

    adaptiveThresholdADAPTIVE_THRESH_GAUSSIAN_C

    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.

    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.

YOUR IMAGE · REAL OPENCV

Experiment at pixel level

Open full lab ↗

Estimate the background illumination, divide it out, then apply local thresholding.

The engine loads on your first run. Your images stay in this browser.

Input448 × 320
OutputWaiting for a result

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
Hover to inspect. Click to pin a pixel.
Input
Select a pixel

Output
Select a pixel

Sample models and licenses

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.