You threshold the part, run cv2.findContours, call cv2.fitEllipse — and get a different ellipse every cycle. The boundary is ragged, small dark speckles create extra black-white transitions, and internal edges compete with the real outline, so the largest contour is not always the one you want. The fix is mechanical: clean the binary mask, isolate the outer boundary, fit, then cross-check. This guide walks the pipeline step by step — morphological opening, connected components, cv2.RETR_EXTERNAL, cv2.fitEllipse, a moments-based sanity check — and follows one worked example from field of view to fitted center. When cleanup cannot separate speckles from the outline, a compact RANSAC loop over all edge points recovers the ellipse anyway.
What you need
- One 8-bit grayscale frame of the part on a reasonably uniform background; the worked example uses a 2448 × 2048 frame.
- OpenCV's imgproc module. Every call used here — threshold, morphologyEx, connectedComponentsWithStats, findContours, fitEllipse, moments — sits in the shape-analysis functions of the OpenCV imgproc documentation, and the OpenCV contour features tutorial shows fitEllipse and contourArea in one example.
- A threshold that separates part from background across the whole field. If illumination drifts, segment in a color space that resists it — the Lab-space Otsu segmentation guide covers that set-up.
- A few hundred boundary points on the outline. The fit needs five; repeatability needs hundreds.
- The cleaned mask archived with each result (saved without the all-black-frame pitfall) so any bad cycle can be replayed offline.
The pipeline in six steps
- Threshold the frame to a binary mask; Otsu's method is a sound default.
- Open the mask with a 5 × 5 kernel, one iteration, erasing speckles narrower than five pixels.
- Label the mask with cv2.connectedComponentsWithStats and keep only the largest component.
- Extract contours with cv2.RETR_EXTERNAL and cv2.CHAIN_APPROX_NONE; keep the largest by cv2.contourArea.
- Fit with cv2.fitEllipse: it returns the center in pixels, the two axes in pixels and the angle in degrees.
- Cross-check against cv2.moments: centroid within 2 px of the ellipse center, filled area within 1% of π·a·b.
import cv2
import numpy as np
img = cv2.imread("part.png", cv2.IMREAD_GRAYSCALE) # uint8, 2448 x 2048
_, mask = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
n, labels, stats, _ = cv2.connectedComponentsWithStats(mask) # label 0 is background
largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
mask = np.where(labels == largest, 255, 0).astype(np.uint8)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
cnt = max(contours, key=cv2.contourArea)
assert len(cnt) >= 5, "fitEllipse needs at least five points"
center, axes, angle = cv2.fitEllipse(cnt) # center px, axes px, angle degrees
m = cv2.moments(cnt)
cx, cy = m["m10"] / m["m00"], m["m01"] / m["m00"]
offset = ((center[0] - cx) ** 2 + (center[1] - cy) ** 2) ** 0.5
assert offset < 2.0, f"center {offset:.2f} px from moments centroid"
# On the worked example below, the assert passes at 0.67 px.
Clean the mask without eating the part
Opening is erosion followed by dilation with the same kernel. Anything narrower than the kernel disappears — dust, highlight fragments, edge chips. Anything wider comes back with its boundary where it was, because erosion and dilation cancel on smooth edges. That asymmetry is exactly what a contour fit wants: the speckles go, the outline stays.
Size the kernel from the part, not from habit. Measure the narrowest solid feature in pixels first. On the worked example the smallest real feature is a 12 px-wide notch: 5 × 5 is comfortably conservative and 11 × 11 is the ceiling, the 2-5 px speckles in the raw mask vanish, and the notch survives untouched. Bigger detached speckles are not opening's job — they die at the next step, where only the largest connected component survives.
The keep-largest step is one line with connectedComponentsWithStats. If you want the dust quantified for the log — pixels removed, component counts — the blob-area-by-pixel-counting guide walks the counting itself.
Give the fit enough pixels
A fitted ellipse averages the boundary, so its accuracy grows with the number of boundary pixels. As an estimate, the center's random error scales with p^-1.5, where p is pixels per millimeter: each point's noise grows with pixel size, and the point count grows with p. Doubling p cuts center noise by 2^1.5, about 2.8 times. The worked example runs the arithmetic: a 2448 px frame over a 60 mm field of view gives 40.8 px/mm, a ±0.15 mm ragged edge is ±6.1 px per point, and averaging over roughly 1,787 boundary points leaves 6.1 / √1787 ≈ 0.14 px of expected center error. A visibly noisy outline still yields a sub-pixel center — that is why this pipeline survives real production parts.
To size the camera for a new part, start from the field of view and the smallest feature the edge must carry:
Worked example: from field of view to fitted center
- Frame 2448 × 2048 px over a 60 mm field of view → 2448 / 60 = 40.8 px/mm.
- Outline semi-axes a = 350 px and b = 210 px, which is 8.6 mm and 5.1 mm at 40.8 px/mm.
- Boundary points with CHAIN_APPROX_NONE, from the standard ellipse perimeter approximation:
- Mask: the true blob holds 230,650 px (m00). The raw threshold also carries 61 speckle components totaling 3,120 px, 1.4% of the blob; opening erases 890 px of sub-5 px dust and keep-largest removes the remaining 2,230 px.
- Fit: cv2.fitEllipse returns center (1224.9, 1020.1) px, axes (700.4, 419.6) px, angle 89.7°.
- Center check: the moments centroid is (1224.3, 1019.8) px, so the offset is √(0.6² + 0.3²) ≈ 0.67 px — under the 2 px limit.
- Area check: π·a·b = 3.1416 · 350.2 · 209.8 ≈ 230,819 px² against m00 = 230,650 px², 0.07% apart — under the 1% limit.
- Ratio check: 419.6 / 700.4 = 0.599 against the expected 210 / 350 = 0.600 — 0.2% off.
All three checks pass, so the fit ships: center to the PLC, axes and angle to the log. Over 30 frames of a stationary part the 3σ spread of the center came out at 0.4 px, and the estimate above predicted exactly that (3 × 0.14 px).
RANSAC over all edge points when cleanup is not enough
Cleanup fails in two cases: a chip welded onto the outline, and a threshold that bridges dust into the blob through a thin neck. Then stop cleaning and let statistics decide. Collect every edge point — the raw thresholded boundary with CHAIN_APPROX_NONE works and avoids the double-edge artifact. On the worked example the raw set holds 3,050 points, of which about 1,790 belong to the true outline, an inlier ratio of 59%.
The loop:
- Pick five points at random — five is the minimum that defines a conic.
- Fit an ellipse through them by least squares and convert to center, axes and angle.
- Score the hypothesis: count every edge point whose geometric distance to that ellipse is under the gate t.
- Repeat 1,000 times and keep the hypothesis with the most inliers.
- Refit cv2.fitEllipse on the winning inlier set only.
Iterations first: with w = 0.59 good points, a five-point sample is all-good with probability w⁵ = 0.0715, so k = log(0.01) / log(1 − 0.0715) ≈ 62 iterations already give 99% confidence of hitting one all-good sample. Run 1,000 anyway; a five-point fit costs microseconds, so the loop stays in the tens of milliseconds.
Gate next: 2 px at 40.8 px/mm is 0.05 mm, which is two to four times the 0.3-0.5 px localization scatter of a well-lit 8-bit edge. Below about 1 px the inlier set starves; above about 3 px the gate starts re-admitting the roughness you were removing.
Result on the worked example: the best hypothesis holds 1,672 inliers — 93.4% of the 1,790 true outline points, 54.8% of all 3,050 — and the refit lands within 0.3 px of the clean-mask fit. Two independent paths, one ellipse; that agreement is the evidence the model matches the part.
Verify before you trust the fit
- Draw the fit: cv2.ellipse on a BGR copy, thickness 2. Keep that overlay in your preview output — the live binary image preview guide shows what reaches the operator's screen.
- Center against centroid: under 2 px, as the assert in the code checks.
- Area against π·a·b: within 1%.
- Residuals: the RMS distance of all boundary points to the ellipse runs under 1 px on a machined edge and near 3 px on a cut or cast edge; above that, suspect the mask, not the fitter.
- Repeatability: 30 frames of a stationary part, 3σ center spread at or under 0.5 px at 40 px/mm.
Common mistakes
- RETR_LIST by habit. The default hierarchy drags internal hole edges into the point set and the center walks; RETR_EXTERNAL keeps the outline only.
- Kernel sized by guess. Wider than the narrowest solid feature, it deletes that feature; measure first — a 12 px notch on the example means 5 × 5 with 11 × 11 as the ceiling.
- Axes used in return order. cv2.fitEllipse returns the axis pair tied to the reported angle, unsorted; compute the ratio as larger over smaller.
- Angle read without drawing. The angle follows OpenCV's rotated-rectangle convention in degrees; confirm on the overlay, not by mental arithmetic.
- ROI tighter than the part. Under five boundary points, fitEllipse fails; leave margin around the outline.
- No moments cross-check. fitEllipse fits any point set quietly; the centroid test is the tripwire that catches contamination before it reaches the PLC.
Where the pipeline runs
Both product families below carry this code on the device: threshold to fitted ellipse, results handed to the PLC over the industrial protocol already in the cell. The mono GigE model covers general parts on a standard M12-mount lens; the X86-platform C-mount model takes your own OpenCV build when the pipeline outgrows built-in tools. Browse the rest in smart cameras or filter by resolution and mount in the camera finder.
- Interface
- GigE
- Resolution
- 6-10 MP
- Interface
- USB 2.0
- Resolution
- 6-10 MP
When the RANSAC loop shares its cycle with several cameras, or results feed a line-level MES, move the math to a fanless vision controller next to the line; it drives the cameras, runs the fits and hands center, axes and angle upstream.
- LAN ports
- 3-4 LAN ports
- USB 3 ports
- 2 USB 3 ports
- Light outputs
- 4 built-in channels
- Memory
- 8 GB
When an ellipse is the wrong model
If the outline is a true circle, constrain the model: one radius instead of five ellipse parameters, and the center steadies further — the circle-in-a-binary-image guide covers that case. If the shape is only roughly elliptical, an ellipse will average away exactly the deviations you are paid to find; edge-based pattern matching tracks the real outline without forcing a model onto it.
The decision, per case:
- Smooth part, one clean blob: opening, keep-largest, RETR_EXTERNAL, fitEllipse. No RANSAC needed.
- Dust, edge chips, textured belt: same pipeline, then RANSAC with t = 2 px and 1,000 iterations.
- Shape not elliptical: do not fit; match edges instead.
If you want these numbers mapped onto your part — kernel size, gate, expected boundary points, camera pixels — send a frame and the part drawing through the quote request page or contact the applications team, and we will return the sizing with the arithmetic shown.
Questions
How many points does cv2.fitEllipse need?
The least-squares conic behind cv2.fitEllipse needs at least five points; below that the call fails. Five points also define a conic exactly, which is why RANSAC samples five at a time. For production you want far more: several hundred boundary points average edge noise down to tenths of a pixel, and the moments cross-check only means something at that point count.
Why do the cv2.fitEllipse center and the moments centroid disagree?
They measure different things: the moments centroid is the mean position of every filled pixel, while fitEllipse balances the boundary curve. On a symmetric, uncontaminated blob the two land within one or two pixels. A larger gap means the boundary is clipped, a speckle survived cleanup, or the shape is not elliptical — diagnose before touching the fitter.
How do I remove dark speckles that sit inside the part?
cv2.RETR_EXTERNAL already ignores every edge that is not the outer boundary, so interior speckles never reach the fit. Speckles that split the blob into several components are different: flood-fill the background from the image border, invert the result, and OR it back into the mask. The blob becomes one component again and the outline closes cleanly.
Can cv2.fitEllipse run on the grayscale image directly?
No. fitEllipse consumes a point set, so something must turn gray levels into coordinates first: a threshold, Canny, or a sub-pixel edge locator. Any of them feeds the same fit. On a low-contrast edge, a sub-pixel edge stage typically moves repeatability from about half a pixel to a few tenths — worth it when 0.1 px is 2 microns at 40 px/mm.
Why does the axis ratio come out wrong even when the fit looks right?
cv2.fitEllipse returns its two axes in the order tied to the reported angle, not sorted by length. For some outlines the first axis is the minor one. Compute the ratio as larger divided by smaller, and if you store the angle, store it together with its matching axis pair — sorting one and not the other is the classic source of 90-degree flips between stations.


