System designHow-to

Divide an image by another image in OpenCV for flat-field correction

cv2.divide is OpenCV's per-pixel divide of one image by another: estimate the background, divide in float32 and keep contrast equal across the field.

LumaSpec Vision11 min read Share on LinkedIn

On this page 11 sections
  1. 01cv2.divide: division with saturation
  2. 02Division or subtraction: the arithmetic
  3. 03Step 1: build the background divisor
    1. An empty-scene reference image
    2. A large median filter
    3. Morphological closing or opening
  4. 04Steps 2 to 6: guard, divide, rescale, verify
  5. 05Worked example: one mark across a 190-to-110 falloff
  6. 06The min-max filter: local range normalization
  7. 07Traps: dtype, zero divisors and wraparound
  8. 08Fix the light first, divide second
  9. 09When division is the wrong tool
  10. 10Checks before you deploy
  11. 11Questions

Uneven illumination is the usual reason a vision threshold that passed on the bench fails on the line: the same mark reads 60 gray levels under the center of the light and 35 at the edge of the field. Software can fix that after the fact with per-pixel division. Divide the frame by an estimate of the background and every pixel becomes a reflectance ratio, independent of how hard that spot was lit. OpenCV-Python has the operation, cv2.divide, and this how-to gives you the call with the right dtype, three ways to build the divisor, the arithmetic that shows why division beats subtraction, and the traps that turn the output black.

cv2.divide: division with saturation

cv2.divide(src1, src2) divides two images pixel by pixel — the operation behind imdivide-style commands in other environments. The cv2.divide entry in the OpenCV core array documentation defines it as src1 × scale / src2, saturated into the output type, with a divisor of 0 written as 0 rather than an error.

The defaults are the trap. Two uint8 inputs return uint8, and 20 ÷ 200 truncates to 0, so an uncorrected call returns an almost black image. Pass a float output type and a scale that maps a pixel equal to its background to 255:

import cv2
import numpy as np

img = np.array([[200, 20]], dtype=np.uint8)    # blank, toner
bg  = np.array([[200, 200]], dtype=np.uint8)   # background estimate

ratio = cv2.divide(img, bg, scale=255.0, dtype=cv2.CV_32F)
print(ratio)                                   # [[255. 25.5]]

# proof, run as written:
assert np.allclose(ratio, np.array([[255.0, 25.5]], dtype=np.float32))

Numpy divides differently: img / bg on uint8 arrays is true division and returns float64, while img // bg truncates like the plain cv2 call. Keep both behaviors in mind for the traps section.

Division or subtraction: the arithmetic

For a diffuse surface in the linear range of the sensor, pixel = reflectance × illumination. Illumination enters multiplicatively, so only division cancels it. Take a printed document: blank paper reads 200 where the light is strong and 50 where it falls off, and the toner reads 20 and 5 in the same two spots. Both zones have a toner-to-paper ratio of 0.10 — the print is equally dark against its local background.

pixel = reflectance × illumination corrected = pixel ÷ background × 255
Zone Paper pixel Toner pixel Subtraction: value − paper + 255 Division: value ÷ paper × 255
Strong light 200 20 20 − 200 + 255 = 75 20 ÷ 200 × 255 = 25.5
Weak light 50 5 5 − 50 + 255 = 210 5 ÷ 50 × 255 = 25.5

After subtraction the toner sits 180 levels under paper in the bright zone and 45 levels under paper in the dim zone: a 4× contrast swing for identical print. After division both toner pixels sit 229.5 levels under paper, and one global threshold works across the field. Subtraction still has honest uses — removing static clutter between frames — with artifact modes of their own, which we treat in the image subtraction guide to contour artifacts. Particle image velocimetry and document scanning both normalize this way; the operation is standard, the dtype handling is where implementations differ.

Step 1: build the background divisor

The divisor should be the image the scene would produce with no part in it — paper everywhere, bare belt everywhere. Three ways to get it.

An empty-scene reference image

Present no part, keep exposure, gain and the light setting at production values, and take the per-pixel median over frames:

frames = [grab() for _ in range(25)]           # 25 uint8 frames from your camera, no part present
bg = np.median(np.stack(frames), axis=0).astype(np.uint8)

A median of 25 frames rejects a flicker frame that a mean would smear. The reference is only valid for that exposure and that light setting; changing either invalidates it.

A large median filter

When parts always cover part of the field, estimate the background from the frame itself. cv2.medianBlur(img, k) with an odd k wider than the largest foreground feature lets the median sample background at every pixel. Strokes up to 7 px wide call for k = 31 — about 4× the stroke width, odd. The rule holds while the marks cover less than half the kernel window; past that the median follows the marks.

bg_est = cv2.medianBlur(img, 31)               # img: uint8 grayscale

Morphological closing or opening

A closing (dilate, then erode) fills dark marks on a light background and leaves the background; an opening removes light marks on a dark background. Same kernel rule as the median:

kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (31, 31))
bg_est = cv2.morphologyEx(img, cv2.MORPH_CLOSE, kernel)  # dark marks, light background

If you are computing other window statistics on the same image, the local image entropy map in C++ shares this neighborhood machinery.

Steps 2 to 6: guard, divide, rescale, verify

  1. Clamp the divisor above zero. bg = np.maximum(bg, 1).astype(np.uint8). cv2.divide maps zero divisors to zero output, so a single zero pixel in the estimate paints a black pixel into every corrected frame.
  2. Divide in float32 with scale 255. norm = cv2.divide(img, bg, scale=255.0, dtype=cv2.CV_32F). A pixel equal to its background now reads 255. On color frames cv2.divide applies per channel, so each channel needs a divisor of the same shape.
  3. Clip before casting. Specular spots can exceed their background, so norm can pass 255. norm = np.clip(norm, 0.0, 255.0) keeps the cast well defined.
  4. Cast once, at the end. out = norm.astype(np.uint8) truncates; np.rint(norm) first gives round-to-nearest. Keep thresholds in float for sub-level precision.
  5. Verify on a two-zone target. Place the same mark under the bright center and the dim edge; both must read within ±3 levels after correction. Log the pair per shift.

The whole correction, with the proof from the arithmetic section:

import cv2
import numpy as np

def flat_field(img, bg):
    # img, bg: uint8 arrays of the same shape. Returns a uint8 image.
    bg = np.maximum(bg, 1).astype(np.uint8)                # no zero divisors
    norm = cv2.divide(img, bg, scale=255.0, dtype=cv2.CV_32F)
    return np.clip(np.rint(norm), 0.0, 255.0).astype(np.uint8)

img = np.array([[200, 20], [50,  5]], dtype=np.uint8)
bg  = np.array([[200, 200], [50, 50]], dtype=np.uint8)
assert flat_field(img, bg).tolist() == [[255, 26], [255, 26]]

Step 6 runs unchanged on the line PC. If the pipeline is hosted in Ignition's Image Streamer and its OpenCV module fails to load there, the Image Streamer OpenCV load-failure notes treat that deployment problem on its own.

Worked example: one mark across a 190-to-110 falloff

A wide bar light over a white label: the blank background measures 190 gray levels under the light center and 110 at the field edge. The same printed mark — one reflectance, about 0.32 — reads 60 there and 35 there. Both readings come from the same multiplication, so division restores them:

  • Center: 60 ÷ 190 × 255 = 0.316 × 255 = 80.5 → 81
  • Edge: 35 ÷ 110 × 255 = 0.318 × 255 = 81.1 → 81
  • Blank, both zones: 190 ÷ 190 × 255 = 255 and 110 ÷ 110 × 255 = 255

One mark, one value, one threshold at 128 anywhere in the field. Subtraction with a +255 re-offset gives 60 − 190 + 255 = 125 at the center and 35 − 110 + 255 = 180 at the edge: 55 levels apart for the same mark, so a threshold at 150 accepts it as background on one side and rejects it as a defect on the other.

Exposure is part of the setup. Set it so the brightest blank sits at 180-200 of 255: a blank that saturates at 255 has no ratio left, because 255 ÷ anything measures the clip, not the reflectance.

The min-max filter: local range normalization

The other common reason to divide two images is the min-max filter: stretch every kernel window between its local minimum and maximum. Grayscale cv2.erode and cv2.dilate are exactly local min and max filters, so the filter reduces to two morphology calls and one division:

k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
low = cv2.erode(img, k).astype(np.float32)     # local minimum
upp = cv2.dilate(img, k).astype(np.float32)    # local maximum
norm = (img.astype(np.float32) - low) / np.maximum(upp - low, 1.0)
out = (norm * 255.0).astype(np.uint8)
normalized = (img − min) ÷ (max − min + 1) × 255

Erode never exceeds the original and dilate never falls below it, so the numerator stays in range; the +1 clamp handles flat windows where max equals min, which an unguarded division would otherwise map to black. The kernel side sets the scale split: features wider than the kernel count as background, narrower ones are stretched to full contrast. After this normalization, uniform regions are exactly where local variance filtering for defect detection hunts for leftover texture.

Traps: dtype, zero divisors and wraparound

  • uint8 output truncates. cv2.divide(img, bg) with no dtype returns uint8: 20 ÷ 200 becomes 0 and the output is black. Always pass dtype=cv2.CV_32F for ratios.
  • Zero divisor writes zero. cv2.divide maps a divisor of 0 to an output of 0 — no exception, no warning. Clamp with np.maximum(bg, 1).
  • numpy wraps, cv2 saturates. On uint8 arrays np.subtract turns 5 − 50 into 211, wrapping around; cv2.subtract clips it at 0. Cast to float32 before any manual arithmetic.
  • astype truncates, cv2 rounds. astype(np.uint8) truncates toward zero (25.5 → 25), OpenCV's saturation rounding gives 26. Mixing the two shifts thresholds by one level; pick one path and stay on it.

Fix the light first, divide second

Software division is the second line of defense; the first is light that does not vary. Position and angle set most of the gradient, as the lighting geometry guide to camera, light and part positions works through, and wide fields need deliberate brightness balancing from the large field of view lighting guide. A bar light that glows in the middle of the field improves most with a diffuser plate in front of it.

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

The lighting accessories range also carries the extension and drag-chain cables that let the light sit where the geometry works; the accessories finder filters the whole set by size. Cables matter here for a second reason: a reference image is only valid while the light is driven identically every cycle, and the lighting stability test before deployment is how you find out whether it is. For cables, diffusers and mounting parts, the configurator starts at the extras step:

Build the whole station

The configurator matches a camera, lens, light, light controller and cables to each other, step by step, and lists only parts that fit what you already picked.

Start with the cables and extras

Cameras add their own layer: industrial cameras offer a flat field function that removes pixel-to-pixel sensitivity differences and dark signal. It runs from the sensor's own references and knows nothing about your optics or your lamp, so it cannot touch a gradient that spans thousands of pixels — that is the division's job. Run both.

When division is the wrong tool

  • The defect is a brightness change. Glare, gloss variation, burn marks: division normalizes luminance away and erases the signal you hunt. Inspect those on the raw frame, with geometry or polarization.
  • Pixels saturate. Keep the uncorrected blank at 180-200 of 255, or the ratio measures the clip.
  • Part height varies. Illuminance follows the inverse square of distance: with the light at 100 mm, a part 5 mm closer receives (100 ÷ 95)² = 1.11× the light and a 190 blank reads 210; 5 mm farther gives (100 ÷ 105)² = 0.91× and 172. A fixed reference divides out only the nominal value, so estimate the background per frame with the median or the closing.
  • You need the shading. Shape-from-shading and texture inspections want the illumination gradient itself; flattening discards it.

Checks before you deploy

  • Blank background within ±5 levels of 255 across the whole field, measured against a physical target as in the machine vision reference target guide.
  • The same mark in the bright zone and the dim zone reads within ±3 levels.
  • bg.min() >= 1 asserted in code, not assumed.
  • Uncorrected blank at 180-200 of 255 — headroom, no saturation.
  • Drift alarm: the mean of a bare-background ROI moving more than 5 levels between shifts triggers a reference rebuild and a look at the light.
  • Thresholds re-tuned on the corrected image; a threshold tuned on raw pixels is meaningless after rescaling.

If contrast stays marginal after all of this, the lighting itself needs work — why adequate contrast is not enough makes that case with station examples. The systems topic page collects the rest of the pipeline-side guides, and the Learn hub holds the full library.

Questions

Why does dividing two uint8 images in OpenCV give an almost black image?

cv2.divide keeps the input dtype unless you say otherwise, and integer division truncates: 20 over 200 is 0.1, which truncates to 0, so nearly every pixel collapses. Call it with dtype=cv2.CV_32F and scale=255.0 so a pixel equal to its background maps to 255 instead of 1.

What does cv2.divide do when the divisor pixel is zero?

It writes 0 to the output pixel, with no exception and no infinity. That is why an unguarded background estimate quietly paints black holes wherever it touches zero. Clamp the divisor with np.maximum(bg, 1) before dividing, or accept black only where you genuinely want it.

How large should the median kernel be when estimating background from the image itself?

Odd, and roughly 2 to 4 times the widest stroke or mark you must keep as signal: 7 px strokes work with k = 31. The rule fails once marks cover more than half the kernel window, because the median then follows the marks and starts erasing the features you need.

Is software division the same as my camera's flat field correction setting?

No. Camera flat field correction compensates pixel-to-pixel sensitivity differences and dark signal using the sensor's own references; it knows nothing about your optics or lamp. A falloff spanning thousands of pixels is a scene gradient, and only the division by a background image removes it. Run both.

How often should I re-capture the background reference image?

After any change to exposure, gain, lens, light setting or lamp, and between changes watch for drift: monitor the mean of a small bare-background ROI and rebuild when it moves more than about 5 gray levels. A monthly check suits a fixed station; parts at varying heights need a per-frame estimate instead.

Can cv2.normalize replace the division?

cv2.normalize with NORM_MINMAX subtracts one global minimum and divides by one global range for the entire image. It rescales overall contrast but cannot cancel a background that varies pixel to pixel. Only a per-pixel divisor, such as a reference image, a large median or a closing, maps a 190 center and a 110 edge to the same 255.