Back to Knowledge Base
Circle Fitting RANSAC Sub-pixel Algorithm OpenCV

Sub-Pixel Circle Fitting: Breaking the 0.1 px Barrier

A deep dive into how RANSAC, sector-centroid sampling, and Devernay sub-pixel edge detection combine to achieve circle-centre accuracy below 0.1 pixels.

April 25, 20263 min read

Pixel-level circle fitting is easy. Sub-pixel fitting β€” stable, robust, and accurate to a tenth of a pixel β€” requires three ideas working together. This post unpacks each one.

Why Pixel-Level Fitting Falls Short

Standard Hough-circle detection and even basic least-squares fits operate on integer-coordinate edge pixels. The quantisation error alone introduces ~0.5 px bias, which is unacceptable for precision measurement (wafer holes, bearing races, lens apertures).

To reach < 0.1 px, you need three upgrades:

  1. Sub-pixel edge localisation β€” move edge coordinates off the integer grid
  2. Robust fitting β€” reject outlier edges caused by noise, scratches, or partial occlusion
  3. Uniform angular sampling β€” prevent dense local edges from biasing the fit

Step 1 β€” Devernay Sub-Pixel Edge Localisation

Canny produces edges snapped to integer pixels. Devernay's algorithm instead fits a 1-D quadratic to the gradient profile perpendicular to the edge:

g(t)=at2+bt+cg(t) = a t^2 + b t + c

The sub-pixel position is the quadratic's maximum:

tβˆ—=βˆ’b2at^* = -\frac{b}{2a}

Applied to each Sobel-filtered scan line, this moves the detected edge point to a floating-point coordinate with ~0.1 px localisation accuracy on clean industrial images.

Step 2 β€” Annular ROI + Sector Centroids

Raw edge images contain hundreds of irrelevant edges (text, PCB traces, shadows). The algorithm restricts processing to a ring-shaped Region of Interest centred on the expected circle:

inner_radius < dist(p, roi_centre) < outer_radius

Inside the ring, points are bucketed into 24 angular sectors (15Β° each). Each sector's centroid becomes one representative sample point. This achieves two things:

  • Robustness to occlusion β€” only sectors with insufficient points are skipped; the rest still vote
  • Bias elimination β€” a scratch producing 500 local edge points gets the same vote as a clean 10-point sector

Step 3 β€” RANSAC + Levenberg–Marquardt

Three non-collinear points uniquely determine a circle. Given points P1,P2,P3P_1, P_2, P_3 the centre (xc,yc)(x_c, y_c) satisfies the linear system obtained by expanding:

(xiβˆ’xc)2+(yiβˆ’yc)2=r2βˆ€i∈{1,2,3}(x_i - x_c)^2 + (y_i - y_c)^2 = r^2 \quad \forall i \in \{1,2,3\}

RANSAC samples 3 sector centroids per iteration, fits a candidate circle, counts how many of the remaining centroids are within inlier_dist pixels of that circle, and keeps the best hypothesis after 1000 iterations.

The RANSAC winner is then passed to Levenberg–Marquardt with all inliers:

min⁑xc,yc,rβˆ‘i∈inliers((xiβˆ’xc)2+(yiβˆ’yc)2βˆ’r)2\min_{x_c, y_c, r} \sum_{i \in \text{inliers}} \left( \sqrt{(x_i - x_c)^2 + (y_i - y_c)^2} - r \right)^2

This final refinement eliminates the remaining discretisation bias and pushes accuracy below 0.1 px.

Accuracy Breakdown

StageTypical Centre Error
Hough (integer pixels)~0.5–1.0 px
RANSAC on Canny edges~0.3–0.5 px
+ Sector centroids~0.2–0.3 px
+ Devernay sub-pixel edges~0.08–0.12 px
+ LM refinement< 0.1 px

Practical Code

In VisionLab, the full pipeline is one API call:

CircleFitParams p;
p.roi_center = cv::Point2f(320, 240);
p.roi_inner_radius = 80;
p.roi_outer_radius = 120;
p.ransac_iter = 1000;
p.inlier_dist = 2.0f;
p.use_devernay = true;

cv::Point2f centre;
float radius;
fitCircleRobustAdvanced(gray, p, centre, radius);

Or via the Plugin SDK with a JSON parameter string β€” no OpenCV in the host process required.