System designTroubleshooting

matchTemplate returns NaN: why normalized scores differ by platform

matchTemplate NaN means a normalized score hit 0/0: flat template or ROI. Guard inputs, check scores with np.isnan, and one result holds on ARM and x86.

LumaSpec Vision10 min read Share on LinkedIn

On this page 9 sections
  1. 01Why does matchTemplate return NaN on one machine and a score on another?
  2. 02What is TM_CCOEFF_NORMED dividing, exactly?
  3. 03Which inputs drive the denominator to zero?
  4. 04What does 0/0 look like, number by number?
  5. 05How do you guard the input and the score read?
  6. 06Why does the same code give different numbers on ARM and x86?
  7. 07How should you write the test so it passes on every platform?
  8. 08Which hardware runs this code in production?
  9. 09Questions

A template-matching suite that passes on x86 Linux and Windows can fill its result matrix with NaN on an ARM machine, on the same images, the same OpenCV version and the same TM_CCOEFF_NORMED call. This guide is for engineers shipping OpenCV matching between desktops, controllers and cameras. You will compute the denominator by hand, reproduce the NaN on demand, guard the template, the exposure and the score read, and write a test that passes on every architecture you ship.

Why does matchTemplate return NaN on one machine and a score on another?

A NaN means the normalized method divided zero by zero: the correlation denominator collapsed because your template or the window under it has zero variance. Which machine shows NaN depends on which SIMD kernel the build picked for that architecture.

The NaN is not corruption and not a bug in your threshold logic; it is the honest output for a degenerate input. The platform difference enters because each build evaluates the correlation with the kernel chosen for its architecture, and the two architectures do not add the sums in the same order.

The shape of the failure is a diagnostic on its own. A result matrix that is NaN everywhere points at the template: a flat template poisons every window at once. Scattered NaN pockets point at the image: flat windows such as saturated highlights or black gaps poison only the windows that cover them. Both patterns appear when the same suite runs green on x86 Linux and Windows and floods NaN on an ARM laptop, because the x86 build happens to land on a finite number for the borderline cases while the ARM build lands on 0/0.

What is TM_CCOEFF_NORMED dividing, exactly?

Both normalized methods divide a correlation sum by the square root of energy sums. Every factor in that denominator is zero whenever the template or the matching window is constant, which is the 0/0 that becomes NaN.

TM_CCORR_NORMED: R = Σ(T·I) / sqrt( ΣT² · ΣI² ) TM_CCOEFF_NORMED: R = Σ(T′·I′) / sqrt( ΣT′² · ΣI′² ) T′ = T − mean(T), I′ = I − mean(I), computed per window

The primes mean mean-subtracted: ΣT′² is the template pixel count times its variance, so the denominator is zero exactly when the template variance, the window variance or both are zero — in other words, when the pixels are constant. TM_CCORR_NORMED divides by the raw energies sqrt(ΣT² · ΣI²) with no mean subtraction, so its denominator only reaches zero when pixels are literally all 0. The two methods therefore fail on different inputs, and the guard you write has to know which one you called.

Which inputs drive the denominator to zero?

Three inputs do it: a template cut from a flat area, a search window that is fully saturated at 255 or fully black at 0, and a masked call whose surviving pixels carry no variance.

Flat templates are the classic case. A template cut from a featureless strip — a gasket edge, a blank label field — has zero variance, and TM_CCOEFF_NORMED then divides 0 by 0 for it. Older versions returned exactly 1 for this degenerate input rather than NaN, so the degenerate output has drifted across versions — which is exactly why you never branch on it.

All-zero inputs are the second case: image and template both zero drive every sum to zero, and the NaN appears. Handling of the all-zero case changed across releases, around version 4.4.0 among others, which is one more reason to guard the input rather than the output.

Masks are the third: with TM_CCOEFF_NORMED and a mask, the surviving pixels can carry no variance even when the full template does, and the division by zero creates NaNs. Guard the variance of the unmasked pixels, not just the template's.

Saturation behaves like flatness: a window clipped at 255 by overexposure is constant, and exposure feedback that drifts an ROI into clipping is a common field cause — the auto-exposure ROI feedback loop is where that drift usually starts. Input validation spans the whole template workflow; the template-too-small ROI error is the sibling failure on the size side.

What does 0/0 look like, number by number?

Cut a 2×2 template of four pixels all equal to 100 and the arithmetic collapses on one line: mean 100, deviations all 0, denominator sqrt(0 × ΣI′²) = 0, numerator 0, score 0/0 — and IEEE 754 defines 0/0 as NaN.

Set one pixel apart and everything recovers. Template [100, 101, 100, 101]: sum 402, mean 402/4 = 100.5, deviations ±0.5, ΣT′² = 4 × 0.25 = 1.0, standard deviation sqrt(1.0/4) = 0.5. The denominator becomes sqrt(1.0 × ΣI′²), finite whenever the window has any contrast at all. The whole NaN question is the gap between ΣT′² = 0 and ΣT′² = 1.0.

What sits under the template ΣI′² TM_CCOEFF_NORMED TM_CCORR_NORMED
Textured window > 0 finite score finite score
Flat window, 100 everywhere 0 0/0 = NaN finite, but brightness-only
Saturated window, 255 everywhere 0 0/0 = NaN finite, but brightness-only
Black frame, 0 everywhere 0 0/0 = NaN sqrt(ΣT² · 0) = NaN

The rows assume the textured template above; a flat template sets ΣT′² = 0 and NaN follows under TM_CCOEFF_NORMED for every window. The black-frame row is the common field failure: a missed strobe pulse delivers all zeros, and the timing checks in the strobe-timing fix clear it. The saturated row stays finite under TM_CCORR_NORMED because ΣI² = 4 × 255² = 260,100 sits far from zero — one more reason to know which method you called.

How do you guard the input and the score read?

Compute the template's standard deviation with cv.meanStdDev and skip the call when it reads below 1e-6. Then wrap every result matrix in np.isnan before any threshold comparison, because a NaN compares false against every threshold and reports the match as not found.

import numpy as np
import cv2 as cv

def match_guarded(image_u8, template_u8):
    # image_u8, template_u8: uint8, single-channel arrays
    _, template_stddev = cv.meanStdDev(template_u8)
    if float(template_stddev[0][0]) < 1e-6:
        return None                    # flat template: OpenCV would divide 0 by 0
    result_f32 = cv.matchTemplate(image_u8, template_u8, cv.TM_CCOEFF_NORMED)
    if bool(np.isnan(result_f32).any()):
        return None                    # at least one flat window under the template
    return float(result_f32.max())

rng = np.random.default_rng(7)
image_u8 = rng.integers(0, 256, size=(64, 64), dtype=np.uint8)
flat_template = np.full((16, 16), 100, dtype=np.uint8)   # zero variance by construction
assert match_guarded(image_u8, flat_template) is None     # guarded, no NaN escapes
assert match_guarded(image_u8, image_u8[16:32, 16:32].copy()) >= 0.99  # self-match = 1.0

The 1e-6 line has margin by construction. Pixel values are integers in 0–255, so a 100×100 template with a single pixel one level off the rest still carries stddev 1/100 = 0.01, four orders of magnitude above 1e-6, and only an exactly flat template lands at 0. The guard cannot fire on real texture.

The template guard cannot cover the sliding windows: a saturated highlight or a black gap inside the image stays flat no matter how good the template is. The np.isnan scan is the window guard, and it costs one pass over a result matrix that is typically a few hundred thousand float32 values.

Keep the guard outcome separate from the threshold decision. The score-threshold rules for deciding a match was found assume a finite score, so let None from the guard mean the input is unusable; folding it into no-match-found hides the exposure or cropping fault that caused it.

Why does the same code give different numbers on ARM and x86?

OpenCV compiles different optimized kernels per architecture, so sums inside a correlation happen in a different order. IEEE 754 addition is not associative; on a healthy denominator that noise stays a few decimals out, on a near-zero one it decides between a score and a NaN.

The mechanism is concrete. IEEE 754 float32 spaces representable values 8 apart at 100,000,000, because 2²⁶ ≤ 1e8 < 2²⁷ with a 24-bit mantissa, so adding 1 there changes nothing. Ordering alone then moves the answer: (100000000.0 + (−100000000.0)) + 1.0 = 1.0, while 100000000.0 + ((−100000000.0) + 1.0) = 0.0, since −100000000.0 + 1.0 rounds back to −100000000.0. Same three numbers, different order, answers different by exactly 1 — and a correlation performs millions of adds like these.

x86 builds take SSE and AVX kernels, ARM builds take NEON kernels through the same intrinsics layer, and the two orders differ. Single-precision adds carry about seven significant digits, so for a healthy template and window the scores disagree only around the fourth decimal, which no threshold notices. For a near-zero or zero denominator the same last-bit noise is amplified without bound: one build returns a finite score, the other returns NaN. That is the whole cross-architecture story — the failing cases were degenerate on every machine, and only the rounding decided which side showed it.

How should you write the test so it passes on every platform?

Assert on the guarded pipeline, not on bit-identical floats: reject flat inputs, treat NaN as its own outcome, and give cross-architecture score comparisons a tolerance near 0.01 of full scale instead of exact equality.

  • Guard cases assert None. Flat-template and black-frame fixtures must come back None on every machine; that is the portable part of the contract.
  • Score comparisons carry a tolerance. Healthy-input differences sit around the fourth decimal, so assert abs(score_a − score_b) < 0.01 on scores in [−1, 1] and you hold roughly three orders of margin.
  • One golden image set everywhere. Include a known-flat region so the NaN branch exercises on every architecture, not only where it first appeared.
  • Run once per shipping architecture. Desktop, controller and camera builds differ; the suite takes minutes and the field failure takes days.

When scores feed a coarse-to-fine search, gate the coarse search on a validated score, because a NaN gate compares false, lets nothing through, and the fine stage silently never runs. We keep the rest of the platform-and-pipeline notes on the systems topic page.

Which hardware runs this code in production?

Three builds qualify, not one: an Intel x86 vision controller shares the processor family of a development PC, so scores transfer; a smart camera inspects on the device, on its own processor, and that is the build to re-test on real hardware.

The split is visible in the catalog: vision controllers list Intel Core i3, i5 and i7 processors plus Intel N-series options — the x86 family your development desktop already belongs to — while the smart cameras category is built around inspection on the device, no PC in the loop. The decision follows from that: keep PC-class OpenCV matching on an x86 controller and desktop scores carry over; move matching on-device and the on-device build becomes the one your suite has to pass on.

When the matching keeps its PC-class dependencies, put it on a controller and hold one processor family from bench to line:

Vision controllersHikrobot MV-VC3301H-128G66 I3-8100, 8GB+128G SSD, No PoE, PCIe expansion supported, Incl. VM6200Pro licenseMV-VC3301H-128G66
LAN ports
5+ LAN ports
USB 3 ports
3-4 USB 3 ports
Light outputs
4 built-in channels
Memory
8 GB
$2,419.00View partBuild a system with it

The MV-VC3301H-128G66 pairs an Intel Core i3 with 8 GB of memory, 5+ LAN ports and 4 built-in light channels, so cameras, strobes and the matching code share one Intel box.

Vision controllersHikrobot MV-VC5525-256G70-NN VC5000 Vision Controller, 12th-I5, 32GB+256G SSD, Supported PCIEMV-VC5525-256G70-NN
LAN ports
5+ LAN ports
USB 3 ports
3-4 USB 3 ports
PoE
PoE camera ports
Light outputs
8 built-in channels
$4,479.00 · quotedView and quoteBuild a system with it

The MV-VC5525-256G70-NN steps up to an Intel Core i5 with 32 GB of memory and adds PoE camera ports and 8 built-in light channels for stations running several cameras at once.

When the inspection is small enough to live entirely on the device, the smart cameras above remove the PC, its operating system and its driver stack from the deployment. The trade is that the on-device build, not your desktop build, decides the floating-point behavior — so the golden-image suite from the last section runs on the camera itself before release.

Next step: size the controller against your camera count in the vision controllers category or configure a system, and bring the contact page a suite that fails on one machine only — that asymmetry is the fastest clue you can hand a support engineer.

Questions

Does np.allclose report NaN results as equal?

No. Any comparison involving NaN is false, so np.allclose reports the arrays unequal wherever one side holds NaN, even at identical positions. Compare np.isnan on both matrices first, treat NaN as its own failure class, and only then apply a tolerance to the finite scores. NumPy's equal_nan option exists, but it hides the degenerate input you actually need to fix.

Can a masked matchTemplate call trigger NaN with a healthy template?

Yes. The mask decides which pixels enter the sums, so if the pixels the mask lets through are constant, the denominator still collapses and NaNs appear even though the full template has plenty of contrast. Guard the variance of the unmasked pixels, not just the template's, before assuming the mask is safe.

Why did an older OpenCV return 1 for a flat template instead of NaN?

Degenerate cases were handled differently across versions: a 2015 report showed TM_CCOEFF_NORMED returning exactly 1 on a flat template, and the all-zero-image NaN was only partly tamed around version 4.4.0. Never branch on the degenerate value; guard the input instead.

Is it safe to leave NaN in place if my pass condition is score > 0.8?

A NaN compares false against every threshold, so a pass-only condition treats it as no match, which is usually the safe direction. The danger is inverted logic such as reject-when-score-below, or a test asserting an exact score, where the NaN changes the outcome silently.

How do I report a cross-platform matchTemplate difference upstream?

Build a minimal reproducer: one small image pair, one method constant, the OpenCV version, the architecture and the operating system of each machine, and the two result matrices as .npy files. Post it to the project's own tracker; without a reproducer the difference cannot be traced to a kernel.