System designHow-to

Threshold white text on a busy background for OCR in Python

Threshold white text on a busy color background with cv2.inRange before OCR in Python: starting bounds, mask cleanup, kernel sizing and the tests to run.

LumaSpec Vision10 min read Share on LinkedIn

On this page 8 sections
  1. 01Why does Tesseract fail on white text over a busy background?
  2. 02What pixel range counts as "white text"?
  3. 03How do you build and test the mask in Python?
  4. 04How big should the morphological kernel be?
  5. 05What should you feed Tesseract: mask, inverse, or both?
  6. 06When does a fixed bound lose to Otsu, HSV or background subtraction?
  7. 07Which camera and lighting choices keep the threshold stable?
  8. 08Questions

White text on a colorful, busy background is the case where pytesseract quietly returns nothing: the glyphs are bright and well formed, yet the engine's document-style binarizer has no clean split to work with. The fix is to binarize the text yourself before recognition — keep the near-white pixels, discard everything else, and hand Tesseract black glyphs on a plain white field. This walkthrough gives you the starting bounds, a six-step OpenCV mask, kernel sizes that follow from stroke width, a coverage test that catches a bad bound, and the camera and lighting choices that keep the bound valid frame after frame on a production line.

Why does Tesseract fail on white text over a busy background?

pytesseract itself is not broken here; it is being handed a photograph where it expects a document. Its first internal stage thresholds the page on the assumption of dark ink on dominant paper, so a thin bright spike of glyphs over a broad mid-tone background offers no split that keeps the text. The usual symptom is an empty string rather than a wrong one. The mirrored case, glyphs darker than a bright field, has its own procedure in OCR thresholding with a dark background; everything below is the white-text counterpart.

What pixel range counts as "white text"?

Keep every pixel whose blue, green and red channels all reach a shared floor, and leave the ceiling at 255: cv2.inRange(frame, (150, 150, 150), (255, 255, 255)) is the working start, tightened toward 200 when glyph edges render near-pure white.

inRange on a BGR frame passes a pixel only when every channel falls inside the bounds. A saturated yellow background around B 60, G 200, R 230 fails a 150 blue floor outright. The same pixel in grayscale can sit within 20 levels of white and survive a luminance split. That per-channel test is the reason to work in color rather than in gray.

Two floors bracket the common cases. Start at 200 when the glyphs render close to pure 255 white with hard edges; drop toward 150 when antialiasing or video compression dims the stroke outline by tens of levels. Then scan upward in steps of 10 and stop at the highest floor that leaves the strokes whole. On bright-overlay footage the passing floor usually lands between 150 and 200, but your scene, not a table, sets the number.

When the text always sits in a known zone, crop it first as in the detection-then-crop OCR pipeline: fewer background pixels behind the glyphs means fewer ways for the mask to leak.

Decision: floor 150 to start, ceiling fixed at 255, step the floor upward until the background vanishes, then drop it only as far as the strokes stay connected.

How do you build and test the mask in Python?

Six steps take a frame to text — grab BGR, mask with inRange, open 3 × 3, close 5 × 5, invert, recognize — and the proof is a coverage assert plus a printed string.

  1. Grab one frame in BGR. cv2.imread for a saved file; cv2.VideoCapture(0) with .read() in a loop for a stream.
  2. Mask the whites. cv2.inRange with the floor and ceiling arrays returns a uint8 mask, 255 where every channel passed.
  3. Clean the mask. A 3 × 3 opening strips specks; a 5 × 5 closing heals the pits a high floor leaves on stroke edges. The next section derives both sizes.
  4. Invert. cv2.bitwise_not turns white glyphs on black into black glyphs on white.
  5. Recognize. pytesseract.image_to_string on the inverted mask, with the single-line segmentation flag --psm 7.
  6. Prove it. Print the mask coverage and the decoded string, and fail the run when either leaves its band.
import cv2                                   # pip install opencv-python
import numpy as np
import pytesseract                           # pip install pytesseract

# Illustrative bounds for near-white text on a colored background.
LOWER = np.array([150, 150, 150], dtype=np.uint8)   # B, G, R floors
UPPER = np.array([255, 255, 255], dtype=np.uint8)   # pure-white ceiling

def ocr_white_text(frame_bgr: np.ndarray) -> str:
    """Return the OCR string for one BGR frame of white text."""
    mask = cv2.inRange(frame_bgr, LOWER, UPPER)        # 255 where text
    kernel_open = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
    kernel_close = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_open)
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_close)
    black_on_white = cv2.bitwise_not(mask)             # glyphs black, field white
    return pytesseract.image_to_string(
        black_on_white, lang="eng", config="--psm 7")
frame = cv2.imread("frame.png")          # np.ndarray, or None if unreadable
assert frame is not None
mask = cv2.inRange(frame, LOWER, UPPER)
coverage: float = np.count_nonzero(mask) / mask.size   # near 0.0007 for one line
assert 0.0002 < coverage < 0.005, "floor clips strokes, or background leaks"
print(ocr_white_text(frame))             # expected: LOT 2214-B

cv2.imread, cv2.inRange, cv2.getStructuringElement, cv2.morphologyEx, cv2.MORPH_OPEN, cv2.MORPH_CLOSE, cv2.bitwise_not and pytesseract.image_to_string are documented library calls; LOWER, UPPER, ocr_white_text, coverage, the test bands and the expected string are this example's own illustrative values.

On a live stream, set the transport to deliver the newest frame only, so a slow OCR pass never queues stale images — stream buffer handling mode: newest only.

How big should the morphological kernel be?

Size the opening just under the stroke width: a 60 px line of regular sans-serif text has strokes near 7 px, so a 3 × 3 opening deletes specks up to 3 px across while every stroke survives.

stroke ≈ cap height / 8 (regular sans-serif; use /5 for bold) opening kernel side ≤ stroke / 2 closing kernel side ≤ stroke

An opening removes bright features thinner than its kernel side, so 3 px of dust, sensor speckle or compression debris disappears while a 7 px stroke stays connected; a 7 px opening would erase a thin font entirely. A closing bridges gaps up to its kernel side, which is exactly what a high floor leaves behind on antialiased strokes. Push the closing much past the stroke width and it starts welding neighboring glyphs together — the failure the touching-digits separation guide untangles. The opening half of the same logic clears particulate noise before a threshold in dust removal on screw threads.

Worked example: a 4000 × 3000 frame, which is what the 12 MP USB3 camera below (LS-CMR-0166) delivers at 30 fps:

Area scan cameras12 MP USB3 area scan camera, IMX226, 1/1.7" rolling shutter, 30 fps, 4000 × 3000 resolution (LS-CMR-0166)LS-CMR-0166
Sensor format
1/1.7"
Interface
USB3
Resolution
12 MP
Shutter
Rolling
$560.00View partBuild a system with it

One text line of 20 characters at a 60 px cap height gives each glyph a 35 × 60 px box, roughly 20 percent of it ink: 20 × 35 × 60 × 0.20 = 8,400 text pixels. Against 4,000 × 3,000 = 12,000,000 pixels, coverage is 8,400 / 12,000,000 = 0.0007, about 0.07 percent. For this frame layout, a mask under 0.02 percent (2,400 px) means the floor is clipping strokes, and anything above 0.5 percent (60,000 px) means background is leaking through.

What should you feed Tesseract: mask, inverse, or both?

Feed the inverted mask — black glyphs on a 255-white field — because Tesseract's page segmentation and binarizer both assume dark ink on light paper, and the raw white-on-black mask is the form that fails on these frames.

Match the segmentation mode to the layout. --psm 7 treats the image as a single text line and --psm 6 as a uniform block; on a cropped single line the single-line mode removes the layout guesses a busy page triggers, so check the psm listing in your Tesseract installation's own documentation before settling on one. When you cannot commit to one form, run both and keep the result with the higher mean confidence from pytesseract.image_to_data.

If the mask is clean and characters still drop out, the fault has moved past preprocessing — work through OCR missing characters: diagnose text filtering errors before touching the bounds again.

The ordering scales past one station: the same mask-first, Python-recognition split is what a license-plate OCR write-up applies to plate readers that keep recognition in Python.

When does a fixed bound lose to Otsu, HSV or background subtraction?

Stay with the fixed floor while the marks sit reliably brighter than every background, and reach for Otsu, the HSV value channel or background subtraction only when scene brightness drifts by more than about 30 gray levels between frames or backgrounds.

Otsu picks the split from the histogram itself, so it follows drifting brightness; it also fails quietly when background pixels outnumber the text and straddle the midpoint. The OpenCV thresholding tutorial shows Otsu beside the fixed forms. Thresholding the HSV value channel is the middle ground: the background may change hue freely, but as long as it stays below the text in brightness, one floor on V separates them; plain grayscale is a simpler stand-in for the same idea. Background subtraction is the strongest tool for a closed set of backgrounds — capture a clean plate of each known background and difference it out before the mask.

Decision: fixed floor first; Otsu or a V-channel floor once brightness drifts; background subtraction only for a known, closed set of backgrounds.

Which camera and lighting choices keep the threshold stable?

A floor only stays valid if the white marks keep roughly 30 gray levels of headroom above the brightest background, and on a production line that headroom comes from locked exposure and fixed light, not from re-tuning the threshold every shift.

Measure the margin once: if the dimmest stroke pixel reads 180 gray levels and the brightest background pixel reads 140, the separating window is 141-179, and a floor at the midpoint 160 leaves 20 levels of margin on each side. Re-measure after any exposure or lens change; that is the whole check.

Lock exposure and gain in software instead of leaving them on auto (runtime camera parameter control covers the live settings), shield the station from changing room light or compensate it deliberately (ambient light variation in pick-and-place vision), and drive the LEDs at constant current through a controller so output does not sag between shifts (why your vision light needs a controller). Motion is the other enemy: a rolling-shutter sensor smears a moving mark, and a smeared edge drags stroke brightness toward the background, so keep the text still during exposure or shorten the exposure and add light.

Any color model from the area scan cameras range can sit under this pipeline; the 20 MP color GigE camera below leaves resolution headroom for cropping zones without shrinking strokes:

Area scan camerasHikrobot MV-CS200-10GC 20 MP Area Scan Camera, GigE, IMX183, Color, BasicMV-CS200-10GC
Sensor format
1"
Lens mount
C-mount
Interface
GigE
Resolution
16-25 MP
$739.00View partBuild a system with it

When the inspection has to live on the device instead of a PC, an x86-platform smart camera runs this same Python and OpenCV stack on the camera itself; the smart cameras range is organized around that idea, and the 8.8 MP x86 model below is that class of part:

Smart cameras & vision sensorsSC / SC smart camera (X86 platform), 8.8 MP, USB3, Color/Mono, global shutter (LS-SMC-0017)LS-SMC-0017
Interface
USB 2.0
Resolution
6-10 MP
Sensor format
1"
Shutter
Global
$1,359.00View part

What happens to the decoded string afterward — packaging it for the line controller — is its own step; smart camera OCR string output over TCP covers the hand-off.

Under a bar light, printed or engraved text keeps its brightness only where the strip is even. A diffuser plate flattens the hot spots that would push background pixels over your floor; the lighting accessories range holds them, and the 170 × 30 mm diffuser below suits common bar-light widths:

Lighting accessories170 x 30 mm bar light diffuser plate (LS-LAC-0010)LS-LAC-0010
Size
100-200 mm
$24.00View partBuild a system with it

Decision, in one line: floor 150, ceiling 255, open 3 × 3, close 5 × 5, invert, single-line segmentation — then defend that floor with fixed light and locked exposure. Pick the camera in the camera finder, the small parts in the accessories finder or at the extras step of the configurator; more preprocessing workflows sit on the systems topic page.

Questions

Which Python packages does this pipeline need?

Three installs: opencv-python supplies cv2, pytesseract supplies the image_to_string wrapper, and the Tesseract engine itself must be installed on the system with the eng language data, because pytesseract calls the engine binary rather than reimplementing recognition. One pip install plus one engine install, and the code above runs unchanged.

How do I run the same mask on a live camera stream?

Swap cv2.imread for a VideoCapture loop and run the identical inRange, morphology and inversion on every frame. Set the stream to deliver the newest frame only so a slow OCR pass never queues stale images, and keep exposure and gain fixed so the white text stays on one side of your bound.

Why do faint background patches survive my mask?

Read the surviving blobs before re-tuning. Large connected regions mean the floor caught background, so raise it; scattered single pixels mean sensor or compression noise, so grow the opening kernel one size; broken strokes mean the floor is already too high. The blob pattern names the fix faster than another guess.

What changes when the text is darker than its background?

Only the comparison direction flips: bound pixels below a ceiling instead of above a floor, then run the same mask, morphology and inversion steps unchanged. If the dark-glyph case is your everyday problem, the dark-background thresholding guide on this site walks it end to end.

Does cv2.inRange work on single-channel mono images?

Yes: on a single-channel image the bounds become two scalars and the function runs unchanged. You lose the per-channel test, though, and with it the strongest reason to work in BGR — a bright yellow background can match white in luminance while failing a 150 blue-channel floor that would reject it.