System designTroubleshooting

findContours returns the wrong number of contours: JPEG artifacts

findContours returns 132 contours for one object because JPEG left gray values between 0 and 255. Re-threshold after loading and the count drops to 1.

LumaSpec Vision10 min read Share on LinkedIn

On this page 9 sections
  1. 01Why does findContours return 132 contours for one object?
  2. 02How can a binary image contain values other than 0 and 255?
  3. 03How do you check in one line whether the image is truly binary?
  4. 04How do you fix an inflated contour count?
  5. 05What else inflates the contour count once JPEG is ruled out?
  6. 06How do you keep the count stable once images keep moving?
  7. 07Where does this fix run on real inspection hardware?
  8. 08Which fix applies to your case?
  9. 09Questions

When len(contours) prints 132 for an image that holds a single part, the contour function is not broken: the file is. A "binary" image that has passed through JPEG carries gray values such as 1, 2, 253 and 254 along every edge, and findContours traces a boundary around every patch of non-zero pixels it meets, so the encoder's ringing turns into phantom objects. This guide gives you the one-line check that confirms the corruption, the two-line fix that brings the count back to the true number of objects, and the habits that keep the count stable when images travel through compression again — on a smart camera, on a vision controller, or in a training set you label offline.

Why does findContours return 132 contours for one object?

The count of 132 is almost never a findContours defect: the loaded image came through a JPEG encoder, which left intermediate gray values such as 253, 254, 1 and 2 along the edges, and every patch of non-zero pixels becomes its own contour.

findContours has no concept of "binary". Its contract is simpler: every pixel that is not exactly zero counts as foreground, and every boundary between foreground and zero background is a contour. Values of 1 and 2 are non-zero, so compression undershoot at an edge becomes foreground speckle. Values of 253 and 254 are also non-zero, so compression overshoot turns patches of the background into foreground as well. One clean object becomes a patchwork of small regions, each with its own boundary, and len(contours) reports the patchwork: 132 regions, 132 contours, where the truth is 1.

The fix therefore sits upstream of the contour call, in the pixels you hand it.

How can a binary image contain values other than 0 and 255?

A JPEG file never stores your exact pixel values: the encoder cuts the image into 8×8 blocks and throws away small differences inside each block to shrink the file, so a hard black-to-white edge is reconstructed with values that land a step or two away from 0 and 255.

JPEG is lossy by design; PNG, BMP and TIFF are lossless and keep 0 and 255 exact. The damage concentrates where contrast is hardest, which is exactly the outline of your part. Two details make the artifact nastier for contour logic than it looks on screen. The overshoot and undershoot sit only one or two gray levels away from the plateaus, invisible at any normal display contrast. And the ringing follows the 8×8 block grid, so it shows up as short horizontal and vertical runs rather than a smooth halo. On screen the image still looks binary; in memory it is a grayscale image with six or more distinct levels.

The corruption reaches you through more paths than your own save button: a snapshot pulled from a camera's web interface, an image attached to a chat message or ticket, a frame decoded from MJPEG video, and many annotation-tool exports.

How do you check in one line whether the image is truly binary?

Print the distinct gray values and count the pixels that are neither 0 nor 255: if the list holds more than the two levels you expect, the image is not binary and findContours will inflate the count.

import cv2
import numpy as np

# img: uint8 NumPy array, shape (480, 640), loaded from the suspect file
img = cv2.imread("part.jpg", cv2.IMREAD_GRAYSCALE)
print(img.dtype, img.shape)                       # uint8 (480, 640)
print(np.unique(img))                             # [  0   1   2 253 254 255]
print(np.count_nonzero((img > 0) & (img < 255)))  # 3107 corrupted pixels

Two lines decide it. np.unique(img) lists every gray level present: a clean binary image prints [0 255] and nothing else. The count line shows how many pixels sit between the levels — 3,107 of the 307,200 pixels in this 640 × 480 example, about 1 percent, which is typical for a hard-edged part saved through JPEG. If the unique list prints only [0 255], the file is clean and the extra contours have another cause; the causes section below covers those.

How do you fix an inflated contour count?

Re-binarize the image after loading so only 0 and 255 remain: one call to cv2.threshold at 127 maps ringing values of 1-2 to black and 253-254 to white, and in the corrupted example the count drops from 132 contours to 1.

  1. Load the file as grayscale: img = cv2.imread("part.jpg", cv2.IMREAD_GRAYSCALE).
  2. Re-binarize: _, binary = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY); the first return value is the threshold applied, the second the new image.
  3. Confirm the levels with np.unique(binary) — it must print [0 255].
  4. Run the contour call on binary, not on the loaded image.
  5. Spot-check the survivor: cv2.contourArea(contours[0]) should print an area close to the part's known size in pixels, not a handful.
# OpenCV 4.x: findContours returns (contours, hierarchy)
img = cv2.imread("part.jpg", cv2.IMREAD_GRAYSCALE)    # uint8, values 0..255
_, binary = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)
print(np.unique(binary))                               # [  0 255]
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL,
                               cv2.CHAIN_APPROX_SIMPLE)
print(len(contours))                                   # 1   (was 132)

If the two levels are unknown because the image was never binary — a grayscale photo of a part, say — pick the method before the value in auto threshold methods for grayscale images.

If speckle survives the threshold — dirt on the part, sensor noise — the count can stay above 1. The next section gives the test and the cleanup.

What else inflates the contour count once JPEG is ruled out?

After compression, the most common causes in order of likelihood are background noise and dust, a retrieval mode that counts hole contours, an inverted mask, and a Canny edge map used as the input.

  • Background noise and dust. Test: sum(1 for c in contours if cv2.contourArea(c) < 10) — if most of the 132 have an area of a few pixels, noise is the cause. Fix: a 3×3 morphological open (cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) with a 3×3 kernel of ones) before findContours, then an area cut. The same artifact family seen through region statistics is covered in blob analysis finds too many blobs.
  • Retrieval mode counts holes. RETR_LIST returns outer and inner boundaries; RETR_EXTERNAL returns outer boundaries only. One part with two enclosed holes therefore prints 1 + 2 = 3 contours under RETR_LIST and exactly 1 under RETR_EXTERNAL. Test: run both modes and compare the counts.
contours (RETR_EXTERNAL) = number of separate foreground objects contours (RETR_LIST) = foreground objects + enclosed holes
  • Inverted mask. A dark part on a white background makes the whole background the foreground: len(contours) prints 1, but the single contour traces the image border. Check polarity before contouring — fix threshold polarity first shows the check.
  • Edge map as input. A Canny result is a drawing of open lines, not a filled region, so its contour count means something different and no threshold after the fact repairs it. Binarize the mask before the edge stage, as threshold the mask before Canny explains.

How do you keep the count stable once images keep moving?

Fix the format, not only the frame: save intermediate and debug images as PNG, BMP or TIFF, which are lossless and keep 0 exactly 0, and keep the re-threshold call in the pipeline so any JPEG that does arrive cannot move the count.

Four habits cover it:

  • Save every image your code writes as PNG. A two-level image compresses losslessly to a fraction of its JPEG size, so the safer format is also the smaller one here.
  • Threshold immediately after every decode, imread or frame arrival, before any geometry runs.
  • Guard the pipeline: assert set(np.unique(img).tolist()) == {0, 255} fails loudly the day a JPEG slips in.
  • Log len(contours) per part and alarm when it leaves the expected range. The count is the cheapest integration test in the cell.

Where does this fix run on real inspection hardware?

The threshold belongs wherever the contour call runs: inside a smart camera that inspects on the device, and inside the vision controller that executes your script on a PC-based line, because both receive images only after they have been decoded.

On the device side, look at smart cameras: a 6 MP mono GigE model with an 8 mm lens in an M12 mount is the class of camera that runs the decode-threshold-contour chain on itself:

Smart cameras & vision sensorsHikrobot MV-SC5060XM-08M-WBN SC5000X series Smart Camera, 6 MP, Mono, 8 mm focal length, M12-mountMV-SC5060XM-08M-WBN
Interface
GigE
Resolution
6-10 MP
Sensor format
1/2.4"
Shutter
Rolling
$1,629.00View part

On a PC-based line, vision controllers hold the script, and a unit with 8 GB of memory and four built-in light channels drives the inspection and the lighting from one machine:

Vision controllersHikrobot MV-VC2140-128G40-1T VC2100 Vision Controller, N97, 8GB+128G SSD+ 1THDDMV-VC2140-128G40-1T
LAN ports
3-4 LAN ports
USB 3 ports
2 USB 3 ports
Light outputs
4 built-in channels
Memory
8 GB
$1,679.00View partBuild a system with it

Either way the rule is identical: threshold directly after the decode and before any geometry. If snapshots leave the cell for review or labeling, export PNG so the count you debug remotely matches the count on the line.

Which fix applies to your case?

Match the symptom to the fix: JPEG anywhere in the file's history means re-threshold after load; leftover speckle after that means an area cut; a count that equals parts plus holes is already correct; and an edge-map input needs the mask fixed before the edge stage.

Symptom One-line test Fix
132 contours for 1 part, file was JPEG np.unique shows 1, 2, 253, 254 cv2.threshold(img, 127, 255, cv2.THRESH_BINARY) after load
Count still high after re-threshold many contours under 10 px area 3×3 open, then keep contours of 100 px² and up
Count equals parts + holes RETR_LIST and RETR_EXTERNAL counts differ by the hole count RETR_EXTERNAL
One contour traces the image border mask polarity inverted invert the mask before findContours
Input is a Canny map input came from an edge stage threshold the mask before the edge stage

Once the count matches the parts, the geometry tools become useful — detecting a circle in a binary image with contour analysis is the natural next read, and the systems topic page collects the surrounding pipeline failures. If you are choosing the camera or controller to run this inspection, tell us the parts and rates through the contact page and we will spec the chain with you.

Questions

What threshold value should I use when re-binarizing a JPEG-damaged binary image?

Anything strictly between the two levels works: 127 is the safe default because it splits 0 from 255 with the widest margin, so encoder values of 1-2 go black and 253-254 go white in one step. Values like 50 or 200 give the same output on a formerly binary image. Save Otsu and the other automatic methods for grayscale images that were never binarized, where the two levels are unknown.

Why does findContours unpack three values in an older project?

In OpenCV 3.x the call returned three items: the modified source image, the contour list and the hierarchy. OpenCV 4.x returns two: contours and hierarchy. A script moved between versions fails at the unpacking line before any contour is counted, which reads like a corrupt file or a broken camera but is only an interface change.

Do JPEG artifacts return on every frame of an MJPEG stream?

Yes. Every frame is compressed and decoded on its own, so the ringing values return each time and the contour count can wobble from frame to frame even on a stationary part. Threshold each decoded frame before the contour logic; the threshold is one sweep over the pixel values and costs little next to the JPEG decode itself.

How do I discard the speckle contours that survive re-thresholding?

Filter by area after the threshold call. Example: in a 640 x 480 image where the smallest real feature covers 5,000 pixels and ringing speckle stays under 50 pixels, keeping contours with cv2.contourArea of 100 px² or more removes speckle with a 2x margin while the part sits 50x above the cut. Derive the cut from your smallest real feature, then check it on the worst image you have.

Why does findContours fail on a color image?

findContours expects a single-channel 8-bit image; hand it a 3-channel color array and the call raises an error before any contour is counted. Convert first with cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), then threshold, then find. The conversion also blends ringing into mid-gray values rather than leaving it near 0 and 255, which is one more reason the threshold step after conversion is not optional.