System designTroubleshooting

Canny before findContours leaves open contours: threshold the mask

Why Canny before findContours leaves open contours on a two-level image, how to threshold the mask instead, and the test that proves the polygon closes.

LumaSpec Vision10 min read Share on LinkedIn

On this page 9 sections
  1. 01Why does Canny leave open contours on a two-level image?
  2. 02Why does cv::pointPolygonTest return d = -1.00 for points inside the part?
  3. 03What is the fix: how should the mask reach findContours?
  4. 04What should the corrected program print on the same image?
  5. 05When does Canny before findContours still make sense?
  6. 06What else quietly breaks a binary image before findContours?
  7. 07How do you prove a polygon is closed before you measure with it?
  8. 08So which calls belong in your contour pipeline?
  9. 09Questions

A two-level image — background at gray 0, part at gray 100 — goes into cv::Canny, and what cv::findContours returns is a 29-point chain that never closes. Interior pixels such as (60,50) then score d = -1.00 in cv::pointPolygonTest, as if they sat outside the part. The edge map is the problem, not the contour call: on an image that is already binary, Canny traces both sides of every boundary as separate 1-pixel lines and drops the connecting pixels at junctions. This guide shows why that happens, gives the threshold-first fix with the exact calls and the closure test that proves the polygon, so you can ship a contour pipeline that measures what you see. It is written for anyone wiring OpenCV contour code behind area scan cameras, smart cameras or vision controllers.

Why does Canny leave open contours on a two-level image?

Because cv::Canny is a gradient edge detector, not a region segmenter: on a mask with only two gray levels it traces both transitions of every boundary as separate 1-pixel chains, and its hysteresis stage erases the pixels that would join those chains at junctions.

Canny runs four stages: Gaussian smoothing, a Sobel gradient, non-maximum suppression and double-threshold hysteresis (the Canny edge detector article follows the same outline). On a two-level image the gradient is a clean step, so suppression keeps a 1-pixel ridge on each side of the transition — every boundary becomes two parallel 1-pixel lines. The double threshold then splits those ridge pixels into strong and weak. In the failing run the thresholds were high = 99 and low = 99 >> 1 = 49; because every boundary pixel carries nearly the same gradient strength, that split is arbitrary along the boundary. A weak pixel survives only when it chains onto a strong one, and at a concave junction such as (66,52) the chain breaks, so pixels drop out and the line opens.

cv::findContours then does exactly what it is built to do: it returns the boundary of the nonzero pixels you hand it. The nonzero pixels here are the broken edge lines, not the part. So the 29-point result traces a 2-pixel-wide sliver — down one side and back the other, which is why coordinates such as (71,53) and (65,46) appear twice in the printed list — and squeezes through the junction gap instead of closing around the part. The contour call is innocent; the edge map is the wrong input.

Why does cv::pointPolygonTest return d = -1.00 for points inside the part?

Because the 29-point curve wraps only the edge-line sliver, not the part: with measureDist = false, cv::pointPolygonTest returns +1 inside, 0 on the boundary and -1 outside, and a curve that runs down one side of the line and back up encloses an area of almost nothing.

Whatever the curve encloses, it encloses the broken edge line — on this shape a sliver of about 40 px². Pixels of the actual part, (60,50) and (70,60), sit far outside that sliver, so both print d = -1.00, exactly the observed output. Nothing about the test call is wrong, and no retrieval mode, approximation method or flag turns the sliver into the part outline. The signed result is the measurement working correctly on the wrong polygon, which is why the fix belongs upstream, in how the mask is made.

What is the fix: how should the mask reach findContours?

Skip Canny entirely and give findContours what it was designed for: a thresholded mask with the threshold anywhere between the two modes, so cv::threshold at t = 50 with THRESH_BINARY turns gray 100 into 255 and gray 0 into 0, one solid region with a closed boundary.

#include <opencv2/imgproc.hpp>

// Illustrative inputs: 8-bit image, background gray 0, part gray 100.
// The API names are OpenCV's documented ones; t is this example's input.
cv::Mat src = cv::imread("part.png", cv::IMREAD_GRAYSCALE);   // 8-bit single channel
cv::Mat mask;
double t = 50.0;                                    // any 0 < t < 100 separates the modes
cv::threshold(src, mask, t, 255, cv::THRESH_BINARY);

std::vector<std::vector<cv::Point> > contours;
cv::findContours(mask, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

cv::Point2f probe(60.0f, 50.0f);                    // a pixel known to lie inside the part
double d = cv::pointPolygonTest(contours[0], probe, false);  // +1 in, 0 on edge, -1 out

t = 50 is simply the midpoint of the two modes. Any value strictly between 0 and 100 yields the identical mask here, so the setting has 49 gray levels of slack on each side and cannot drift with a normal exposure wobble. That insensitivity is exactly what the Canny threshold pair lacks. Python readers get the same three calls as cv2.threshold, cv2.findContours and cv2.pointPolygonTest with the same arguments.

Clean noise after thresholding, not before: a 3 × 3 morphological opening followed by a closing removes speckle and seals pinholes without moving a straight edge. A Gaussian blur ahead of Canny is what rounds the concave corners you are trying to keep.

What should the corrected program print on the same image?

On the same two-level test image the fixed path prints one external contour, d = 1.00 at both (60,50) and (70,60), and a contourArea of 5,141 px² against 5,300 mask pixels — a closure ratio of 0.97 where the Canny path scored 0.01.

Here is the arithmetic, with the counts as this example's inputs:

  • Failing path: Canny with high = 99, low = 99 >> 1 = 49 leaves a 29-point chain hugging the boundary line. Treated as a polygon it encloses about 40 px² of sliver. Ratio: 40 / 5,300 = 0.01 — open.
  • Fixed path: threshold t = 50 gives a mask identical to the part; countNonZero = 5,300 px. findContours with RETR_EXTERNAL returns one contour; contourArea = 5,141 px². Pixel-center sampling shaves about half the perimeter — here roughly 320 × 0.5 = 160 px², so 5,300 − 160 = 5,140, and the sampled run lands on 5,141. Ratio: 5,141 / 5,300 = 0.97 — closed.
  • Proof test: cv::pointPolygonTest at (60,50) prints d = 1.00 and at (70,60) prints d = 1.00. The printf line that reported -1.00 twice now reports +1.00 twice; that swap is the whole fix.

When does Canny before findContours still make sense?

Reserve Canny for gradient edges you must locate on a continuous-tone image, such as a machined edge against an uncontrolled background, and then close the chains with a morphological close; when the histogram shows two clean modes, threshold gives closure with one parameter.

The decision rule lives in the histogram, not in the scene. Two modes with a clean valley: threshold (the threshold basics guide walks that decision in detail). No valley — a part fading into a textured background, or several contrasts at different levels — and an edge or highpass step earns its place, followed by a close so the chains shut before findContours sees them.

The Canny-then-findContours habit comes from photo-processing tutorials, where the input is rarely two-level. On a production line with controlled light, the two-mode case is the norm: a backlit silhouette, a dark print on white packaging. A 2.5 MP global-shutter GigE model such as the MV-CU020-90GC feeds the threshold-and-contour pipeline over a single cable, with a C-mount for whichever lens the field of view needs.

Area scan camerasHikrobot MV-CU020-90GC 2.5 MP Area Scan Camera, GigE, GMAX4002, ColorMV-CU020-90GC
Sensor format
1/1.7"
Lens mount
C-mount
Interface
GigE
Resolution
Up to 3 MP
$309.00View partBuild a system with it

If a concave notch is only a few pixels wide, add resolution rather than chasing Canny thresholds. The LS-CMR-0166 resolves 4000 × 3000 pixels at 30 fps over USB3, so fine concave features survive thresholding instead of merging into the boundary.

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

The same loop runs per frame on a live stream; wiring the grab loop for live edge detection covers the frame-acquisition side that feeds it.

What else quietly breaks a binary image before findContours?

A mask saved as JPEG between processing steps stops being binary: JPEG encodes 8 × 8 pixel blocks and the ringing around every boundary reintroduces gray levels, so the t = 50 threshold that was insensitive by ±49 levels now chases compression artifacts.

Test for it in one line: after the load, count the pixels that are neither background nor part gray — any nonzero count means the file or the loader re-graded your mask. The block edges land anywhere, so ringing adds values like 47 or 96 along a boundary that was a clean step, and the threshold carves whatever the compressor left. Keep every intermediate mask in a lossless format (PNG or BMP), and threshold immediately after every load so the pipeline never trusts a file's gray values. Anti-aliased overlays fail the same way: rendering paints ramps across edges, and thresholding — not Canny — is what turns a ramp back into one boundary.

findContours also insists on an 8-bit single-channel input; a 16-bit or floating-point mask has to be scaled and thresholded into 8-bit first, or the call fails before your code reaches the polygon stage.

The closed-versus-open distinction matters outside OpenCV too: CAM post processors treat an open chain differently from a closed loop, and the branch points and open contours notes for DXF toolpath data cover that side.

How do you prove a polygon is closed before you measure with it?

Assert three checks after every findContours call: at least three points in the contour, a closure ratio contourArea / countNonZero of 0.95 or higher, and cv::pointPolygonTest = +1 on one pixel you know sits inside — all three fail on the 29-point Canny chain.

ratio = contourArea(c) / countNonZero(mask) closed when ratio >= 0.95 (pixel-center sampling keeps it below 1.0) probe = (m10/m00, m01/m00) from cv::moments(mask); require pointPolygonTest(probe) = +1
  1. Non-degenerate: contours is not empty and contours[0].size() >= 3. The Canny chain passed this one with 29 points, which is why the next two checks exist.
  2. Closure ratio: contourArea / countNonZero >= 0.95. The fixed mask scored 5,141 / 5,300 = 0.97; the Canny chain scored 40 / 5,300 = 0.01.
  3. Probe: take the centroid from cv::moments(mask) and require d = +1. On the broken chain both probes returned -1.00; on the thresholded mask both return +1.00.
  4. Visual: drawContours onto a color copy and save it as PNG — ten seconds of looking that catches what ratios miss.

The same mask then feeds whatever comes next, from blob area by pixel counting to contour-based circle checks on binary images; both assume a closed outline, which is now guaranteed.

So which calls belong in your contour pipeline?

For a two-level image the pipeline is cv::threshold at any t strictly between the two gray modes, cv::findContours with RETR_EXTERNAL and CHAIN_APPROX_SIMPLE, then the three closure checks from above — Canny enters only when a gradient edge needs finding first, and then with a close after it.

Symptom Cause Fix
Point list walks down and back, coordinates repeat Canny edge map fed to findContours Threshold first, then findContours
d = -1.00 for pixels inside the part Polygon encloses only the edge-line sliver Fix the mask, not the test
contourArea / countNonZero = 0.01 Chain broken at junctions Assert a ratio of 0.95 or higher
Contour ragged although the image looked binary JPEG or anti-aliasing re-graded the mask Lossless formats, threshold after every load

Ship the threshold path for anything backlit or otherwise two-level; keep Canny for gradient-edge work, always followed by a close, and switch to a Hough route when the feature is a line or a crack — when to switch Hough lines to contour statistics covers that fork. If you are picking hardware for the threshold path, the camera finder filters by resolution, shutter and interface, and the smart cameras page lists versions that run the threshold-and-contour loop on the device itself. To size the whole station — camera, lens, light and a controller to run it on — start at the camera configurator, or send the drawing through the contact page and we will spec it with you. More worked code like this sits on the systems topic page.

Questions

Does cv::findContours modify the image you pass it?

Yes. The function is documented to modify its source, so pass a mask you can afford to lose or hand it mask.clone(). This matters in production: keeping the thresholded mask intact lets you re-run findContours with a different retrieval mode or feed the same mask to pixel counting, without re-thresholding from src.

Which retrieval mode should I use in findContours?

Use RETR_EXTERNAL when you only need outer polygons: it is the fastest mode and ignores holes. Switch to RETR_LIST or RETR_TREE when the part has holes or nested shapes you must tell apart, because an external retrieval collapses a washer to a single ring and never reports the inner circle.

How do I reduce the closed contour to a rectangle?

Simplify the closed polygon with the Douglas-Peucker routine, cv::approxPolyDP, feeding it the contour and an epsilon of roughly 1-3 % of the arc length; with four vertices left you have your rectangle. Simplify only after the closure checks pass, because approxPolyDP on an open Canny chain just outputs a shorter open chain.

Does the fix look different in Python?

The calls mirror one another: cv2.threshold, cv2.findContours and cv2.pointPolygonTest take the same arguments. One trap is the return signature: under OpenCV 3.x, Python findContours returns three values (image, contours, hierarchy), while 4.x returns two (contours, hierarchy), so unpacking written for one version crashes on the other.

Why is contourArea smaller than my pixel count even when the contour is closed?

Contour points sit on pixel centers, so the polygon slices through the outer half of each boundary pixel: a mask of 5,300 pixels with a 320-pixel perimeter loses about 160 px² and prints near 5,141. That is why the closure threshold is 0.95 rather than 1.0; a ratio near 0.01, not 0.97, is what marks an open chain.