Circle Finder
A reusable, extensible circle-detection wrapper for the ECOS AI framework. It packages the circle-finding logic that the vision projects each re-implemented into one configurable module, and reports an OK/NG result plus every detected circle (centre, radius, score, and — when calibrated — diameter in mm).
Overview
The circle_finder module extends the BaseModel class. Several circle finders across the projects share the same pipeline and same primitives — they differ only in how they propose candidate circles. The module is split along that seam:
| Layer | File | Responsibility |
|---|---|---|
| Primitives (pure, no torch) | geometry.py |
Circle, circle fits (LSQ / trimmed / RANSAC), sub-pixel refine, circularity, edge_support score |
| Preprocess | preprocess.py |
channel select, brightness/contrast, HSV mask, diameter(mm) → radius band(px) |
| Strategies (pluggable) | detectors.py |
BaseDetector + hough / contour / radial / ransac + a registry |
| Orchestration | circle_finder.py |
Model(BaseModel): config, run methods, re-score, NMS, refine, output |
Key ideas:
- Strategy pattern + registry — candidate generation is the only part that varies, so each method lives behind one interface (
BaseDetector.detect(ctx) -> list[Circle]) and is looked up by name. New methods register with@register_detector("name")and are selectable fromopt.jsonwithout editing the core. - Method-agnostic
edge_supportre-scoring — Hough votes, contour circularity and RANSAC inliers are not comparable, so every candidate is re-scored on one scale (fraction of radial rays whose gradient peak sits at radiusr). This lets a union of methods be ranked fairly, and rewards radius/centre accuracy. - Radius band in mm or pixels —
diameter_mm+calibrationderive a pixel band invariant to camera/scale; explicitr_min/r_maxtake precedence and fill any bound leftnull. Both are per-ROI-aware (scalar or list). - Multiple ROIs — one image can hold several search windows, each searched independently, with per-ROI radius bands.
- Radius gating uses
cv2.boundingRect, nevercv2.minEnclosingCircle, to avoid the latter's pathological ~26 s case on large jagged contours.
The pure geometry / detectors library imports only OpenCV/NumPy, so the primitives are reusable in scripts and notebooks without pulling in torch; the Model wrapper adds the ECOS BaseModel / opt.json workflow on top.
Installation
Please install ecos_core.
Requirements
No extra dependencies beyond ecos_core's base install (OpenCV, NumPy) — see requirements.txt.
Usage
Configuration
Configuration is managed through a JSON file (opt.json).
{
"method": "contour",
"roi": null,
"channel": "gray",
"bc_min": 0, "bc_max": 0, "blur_ksize": 5,
"calibration": 0.0, "diameter_mm": null, "diameter_tol": 0.3,
"r_min": 20, "r_max": 300, "scale": 1.0,
"max_circles": 1, "min_count": 1, "require_per_roi": false,
"min_score": 0.3, "nms_dist_frac": 0.5, "subpixel": false,
"score_rays": 72, "score_band": 0.15, "score_min_grad": 8.0, "score_tol": 0.06,
"methods": { "hough": {}, "contour": {}, "radial": {}, "ransac": {} },
"class_names": ["OK", "NG"],
"visualization": true
}
With:
method: one strategy name, or a list — the candidates from every named method are unioned, re-scored on the common scale, de-duplicated (NMS) and the topmax_circleskept. Built-ins:hough,contour,radial,ransac.roi: one{"x","y","w","h"}/[x1,y1,x2,y2], or a list of them for several search windows. Each ROI is searched independently (up tomax_circlescircles each); results come back in full-image coordinates, tagged withmeta["roi_index"]. Omit (null) for full frame. (A bare 4-number list is one[x1,y1,x2,y2]; use[[...],[...]]for multiple ROIs.)channel: working channel —gray, HSVh/s/v, BGRb/g/r, or the differenceb-r.bc_min/bc_max: brightness/contrast stretch (bc_max<=0disables);blur_ksize: Gaussian blur kernel (odd;<=1disables).- Radius band — explicit
r_min/r_maxtake precedence; otherwisediameter_mm+calibration(mm/px) +diameter_tolderive it (band = expectedr±tol, wherer = (diameter_mm/2) * scale / calibration). Leaver_min/r_maxnullwhen you want the diameter to drive the band. Deriving from mm is invariant to camera/scale and cheaply rejects large dark/bright blobs. max_circles: how many circles to keep per ROI;min_count: OK needs at least this many circles in total;require_per_roi: whentrue, OK instead needs ≥1 circle in every ROI (the "one hole per ROI" pattern);min_score: minimumedge_supportto accept;nms_dist_frac: de-duplication radius (fraction ofr).subpixel: refine the kept circles with the radial sub-pixel method (guarded against latching onto a neighbouring edge).score_rays/score_band/score_min_grad/score_tol: the commonedge_supportre-score — rays cast, search band (± fraction ofr), minimum gradient to count, and how nearrthe gradient peak must land (tighter ⇒ stricter on radius/centre accuracy).class_names:[OK_label, NG_label]— NG is reported when the OK condition is not met.visualization: whether to draw the detected circles and ROI boxes on the saved output image.
Multiple ROIs and per-ROI radius band
Give roi a list to search several windows in one image. Each of diameter_mm, diameter_tol, r_min and r_max may be a scalar (same band for every ROI) or a list (entry i sizes ROI i), so ROIs holding differently-sized holes each get their own band; a 1-element list broadcasts, and calibration / scale stay image-wide.
{
"method": "contour",
"calibration": 0.01,
"roi": [[60, 100, 360, 400], [560, 120, 820, 360]],
"diameter_mm": [2.2, 1.6],
"diameter_tol": 0.15,
"r_min": null, "r_max": null,
"max_circles": 1,
"require_per_roi": true
}
Here ROI 0 is sized for a Ø2.2 mm hole and ROI 1 for a Ø1.6 mm hole; require_per_roi makes the frame OK only when a circle is found in both.
Method parameters (methods.<name>)
Only the method(s) named in method are run; each reads its block under methods. Any omitted key falls back to the default shown.
contour — threshold → external contours → radius/circularity-gated LSQ fit.
| key | default | meaning |
|---|---|---|
thresh_mode |
"otsu" |
otsu | fixed | adaptive | hsv (colour mask, needs a BGR image) |
thresh |
127 |
threshold level for fixed mode |
invert |
true |
target darker than background (invert so it becomes white) |
adaptive_block / adaptive_C |
51 / 5 |
block size (odd) and constant for adaptive mode |
hsv |
null |
{"h":[lo,hi],"s":[lo,hi],"v":[lo,hi],"inv":false,"scale":"cv"} for hsv mode |
close_ksize / open_ksize |
5 / 3 |
morphological close / open kernel (px; <=1 skips) |
min_area_frac |
0.0005 |
reject contours below this fraction of the crop area |
min_circularity |
0.5 |
minimum 4πA/P² to accept a contour |
max_candidates |
5 |
how many top contours to fit |
fit |
"trimmed" |
final fit: trimmed (outlier-robust) | lsq | none (boundingRect circle) |
hough — cv2.HoughCircles on the working channel (centre-agnostic).
| key | default | meaning |
|---|---|---|
downscale |
1 |
detect on a 1/N image then rescale back (speed + vote robustness) |
dp |
1.5 |
accumulator inverse resolution |
min_dist_frac |
1.0 |
minimum centre distance as a fraction of the target radius |
param1 |
120 |
Canny high threshold |
param2 |
30 |
accumulator threshold (lower ⇒ more, weaker circles) |
blur_ksize |
0 |
extra Gaussian blur on the (down-scaled) channel |
radial — radial gradient edge search from a seed → trimmed LSQ (precise).
| key | default | meaning |
|---|---|---|
num_rays |
72 |
rays cast from the seed centre |
r_lo_frac / r_hi_frac |
0.6 / 1.4 |
scan window as a fraction of the seed radius |
grad_frac |
0.4 |
edge peak must exceed this fraction of the ray's max gradient |
grad_min |
6.0 |
absolute minimum gradient for an edge |
seed_method |
"contour" |
cheap first pass used to seed it (null ⇒ image centre) |
seed_params |
null |
params for that seeding detector |
ransac — Canny edges (optional multi-channel union) → band-gated RANSAC. Needs a bounded radius band (r_max finite) to gate hypotheses.
| key | default | meaning |
|---|---|---|
channels |
["gray"] |
channels whose Canny edges are unioned (e.g. ["s","v"]) |
canny_lo / canny_hi |
80 / 160 |
Canny thresholds |
blur_ksize |
7 |
Gaussian blur before Canny |
tol |
2.5 |
inlier distance to the hypothesised circle (px) |
iters |
3000 |
RANSAC hypotheses |
min_inliers |
40 |
reject a fit with fewer inliers |
mask_center |
null |
{"cx","cy","r"} to keep only edges inside a disc |
Choosing a method
| Method | Needs | Strengths | Watch out for |
|---|---|---|---|
contour |
clean segmentation | fast, reliable, precise fit | needs a threshold that isolates the target |
hough |
nothing (centre-agnostic) | finds circles anywhere | phantom circles on smooth gradients |
radial |
a seed (auto via contour) |
sub-pixel-precise boundary | needs a good seed + localised edge |
ransac |
a bounded radius band | robust to heavy edge texture | needs the band; slower |
Inference
As a library (returns Circle objects directly):
import cv2, json
from easydict import EasyDict
from ecos_core.circle_finder import Model
with open("./opt.json", "r") as f:
opt = EasyDict(json.load(f))
model = Model(opt)
model.build_model()
circles = model.predict(cv2.imread("part.png")) # list[Circle]
for c in circles:
print(c.meta.get("roi_index"), c.center, c.r, c.score, c.method)
In the ECOS framework (writes an annotated image + JSON sidecar):
import os
from glob import glob
for image_path in glob("./input/*"):
model.process_predictions(
net_output=None,
raw_image_path=image_path,
raw_image_mat=None,
image_transform=None,
save_image_path=os.path.join("./output", os.path.basename(image_path)),
)
Each call writes the annotated output image plus a {save_image_path}.json sidecar with predict_index, class_name, num_rois, num_circles, and a circles list (each with roi_index, cx, cy, r, score, method, and diameter_mm when calibration is set).
Adding a custom strategy
Register a new candidate generator without touching the module; then select it by name in opt.json.
from ecos_core.circle_finder.detectors import BaseDetector, register_detector
from ecos_core.circle_finder import Circle
@register_detector("my_method")
class MyDetector(BaseDetector):
DEFAULTS = {"foo": 1}
def detect(self, ctx):
# ctx.gray, ctx.bgr, ctx.r_min, ctx.r_max, ctx.seed, ctx.params
return [Circle(cx, cy, r, score=..., method=self.NAME)]
Then set "method": "my_method" (and optionally "methods": {"my_method": {"foo": 2}}).
APIs
Model
Circle Finder - reusable ECOS Core module for detecting circles.
A thin BaseModel wrapper (same contract as match_tool / paddle_ocr)
around the pure geometry / detectors library. It:
- reads config from
opt.json(method(s), radius band, ROI, ...), - preprocesses (channel select + brightness/contrast + blur),
- runs one or more registered detector strategies (with automatic
coarse-to-fine seeding for the
radialmethod), - re-scores every candidate with the common
edge_supportmetric, - filters (radius band, min score), de-duplicates (NMS), keeps the top N,
- optionally refines the winners to sub-pixel accuracy,
- and, via
process_predictions, writes an annotated image + JSON sidecar.
Use it as a library too::
from ecos_core.circle_finder.circle_finder import Model
model = Model(opt); model.build_model()
circles = model.predict(image_bgr) # -> list[Circle], best first
Model
Bases: BaseModel
__init__(opt)
Args: opt (Namespace/EasyDict/Dict): configuration (see README / opt.json).
build_model()
Instantiate the configured detector strategies. Returns self.
get_transform()
BaseModel hook; this module reads images directly, so it is a no-op.
predict(image, roi=None, seed=None)
Detect circles in image, across one or more ROIs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image |
str | ndarray
|
path, or a loaded gray/BGR array. |
required |
roi |
dict | list | None
|
override ROI(s); defaults to
|
None
|
seed |
Circle
|
explicit seed for the |
None
|
Returns:
| Type | Description |
|---|---|
list
|
list[Circle]: results grouped by ROI (in config order); within a |
list
|
ROI, best (highest score) first. Each circle's |
list
|
records which ROI it came from. |
process_predictions(net_output, raw_image_path, raw_image_mat, image_transform, save_image_path)
ECOS framework hook: run predict(), write the annotated image and a
{save_image_path}.json sidecar. Verdict (predict_index=0=OK):
- default: at least opt.min_count circles found in total;
- when opt.require_per_roi is true: at least one circle in every
configured ROI (the "one hole per ROI" pattern).
update_opt(option, output_path='./opt.json')
Update config from a Namespace/Dict and persist to JSON.
Detectors
Circle candidate-generation strategies (the pluggable part).
Every finder differs only in how it proposes candidate circles; the
fit / refine / score steps are shared. So each proposal method lives behind one
interface, BaseDetector.detect(ctx) -> list[Circle], and is looked up by
name in DETECTOR_REGISTRY. Add a new method for another project with::
from ecos_core.circle_finder.detectors import BaseDetector, register_detector
@register_detector("my_method")
class MyDetector(BaseDetector):
DEFAULTS = {...}
def detect(self, ctx):
...
return [Circle(...)]
...without touching the Model or the other detectors (Open/Closed).
Four strategies ship, covering what NITTO uses:
- hough : cv2.HoughCircles on a (down-scaled) channel. Centre-agnostic.
- contour : threshold → contours → radius/circularity-gated LSQ fit.
- radial : radial gradient edge search from a seed → trimmed LSQ. Precise.
- ransac : (optionally multi-channel) Canny edges → band-gated RANSAC.
BaseDetector
Bases: ABC
Common interface. Subclasses set NAME and DEFAULTS and implement
detect.
detect(ctx)
abstractmethod
Return a list of Circle candidates (may be empty).
ContourDetector
Bases: BaseDetector
Threshold → external contours → radius/circularity-gated selection, with a robust LSQ fit for the final circle. The workhorse: fast and reliable when the target segments cleanly against its background.
Threshold modes: otsu (Otsu, optionally inverted), fixed (a fixed
thresh level), adaptive (adaptive Gaussian), hsv (an HSV
in-range colour mask - needs a BGR image).
DetectContext
dataclass
What a detector receives. gray is the working single-channel image
the Model already prepared (channel select + BC + blur); bgr is the
colour image (or None) for methods that need HSV. r_min/r_max is the
resolved pixel radius band; seed is an optional coarse circle (centre /
radius) for methods that refine from a starting guess (radial).
params carries the method's own tunables, so a custom detector can read
anything without changing this dataclass.
r_target: float
property
Mid-band radius; a reasonable default target when no seed is given.
HoughDetector
Bases: BaseDetector
cv2.HoughCircles on the working channel, optionally down-scaled for speed
and vote robustness. Centre-agnostic (no seed needed), but prone to phantom
circles on smooth gradients - pair with a strong min_score gate or the
Model's edge-support re-scoring.
RadialRayDetector
Bases: BaseDetector
From a seed centre, cast rays and locate the edge crossing near a target
radius via the |gradient| peak, then fit a trimmed LSQ circle. The most
precise method for a well-localised boundary; needs a seed (centre + rough
radius). The Model supplies one automatically by running a cheap contour
(or hough) pass first when seed_method is set - a natural
coarse-to-fine composition.
RansacEdgeDetector
Bases: BaseDetector
Canny edges (optionally a union of several channels) → band-gated RANSAC circle. Robust to heavy edge texture (brushed rings, striations) that pulls a contour/least-squares fit off-centre, because RANSAC ignores off-band inconsistent edges. Needs a valid radius band to gate hypotheses.
build_detector(name, params=None)
Instantiate a registered detector by name.
register_detector(name)
Class decorator that registers a detector under name.
Geometry primitives
Pure circle geometry primitives (numpy + OpenCV only, no torch).
These are the building blocks every circle finder re-implemented in its own way: algebraic / robust circle fitting, sub-pixel centre refinement, a circularity metric, and a method-agnostic edge-support quality score. Kept dependency-light so they can be imported standalone (unit tests, notebooks, other projects) without pulling in the ECOS BaseModel / torch stack.
Circle
dataclass
A detected circle in image-pixel coordinates.
Attributes:
| Name | Type | Description |
|---|---|---|
cx, |
(cy, r)
|
centre and radius in pixels. |
score |
float
|
quality in [0, 1]. Its native meaning is method-specific
(circularity for contour, inlier fraction for RANSAC/radial, vote
rank for Hough); the Model re-scores every candidate with the
common |
method |
str
|
name of the detector that produced it. |
meta |
dict
|
free-form extra info (inlier fraction, raw score, ...). |
shifted(dx, dy)
Return a copy translated by (dx, dy) - used to lift a crop-local detection back into full-image coordinates.
bilinear_sample(gray, xs, ys)
Bilinearly sample gray at float coords; out-of-range → NaN.
Accepts scalar or array xs/ys of any matching shape and returns a float array of that shape. Used by every radial/edge routine so sub-pixel sampling is consistent.
circularity(area, perimeter)
4piA / P^2 - 1.0 for a perfect circle, →0 for a ragged shape.
edge_support_score(gray, cx, cy, r, num_rays=72, band=0.15, min_grad=8.0, tol_frac=0.06)
Fraction of radial rays whose strongest intensity step sits at radius r.
A common, detector-independent measure of "how much real edge sits on this circle, concentric with (cx, cy)". Because Hough votes, contour circularity and RANSAC inliers are not comparable across methods, the Model re-scores every candidate with this so a union of methods can be ranked on one scale.
Each ray is scanned over [r(1-band), r(1+band)]; it counts as support
only when its gradient peak both exceeds min_grad and lands within
tol_frac * r of r. Requiring the peak near r (not merely somewhere
in the band) is what makes the score reward radius/centre accuracy: an
off-centre circle's edge crosses each ray at a different radius, so fewer
peaks fall near the assumed r and its score drops below the concentric fit's.
Returns a value in [0, 1].
fit_circle_lsq(points)
Algebraic least-squares circle fit. Returns (cx, cy, r).
fit_circle_ransac(points, r_min, r_max, tol=2.5, iters=3000, rng=None)
Vectorised RANSAC circle fit on edge points, gated to [r_min, r_max].
Samples all 3-point hypotheses at once, keeps those whose circumscribed circle lands in the radius band, then counts inliers in memory-bounded chunks and refines the best by LSQ. Robust to heavy texture noise (brushed rings, striations) that defeats a plain contour fit. Returns (cx, cy, r, n_inliers) or None.
fit_circle_trimmed(points, rounds=2, k=2.5)
LSQ circle fit with iterative outlier trimming.
Rejects points more than k standard deviations from the current circle
each round - so a local edge defect (a notch/chip on the rim, a stray edge
pixel) cannot drag the centre. Returns (cx, cy, r, inlier_frac) or None if
there are too few points.
refine_center_subpixel_radial(gray, cx, cy, radius, num_rays=36, search_range=6.0, trim=True)
Refine a circle centre/radius via radial gradient sampling.
Casts num_rays rays from (cx, cy); on each, samples intensity around
radius ± search_range, locates the |gradient| peak to sub-pixel via
3-point parabolic interpolation, then fits the collected edge points. With
trim=True the final fit rejects rays that landed on a local defect.
Returns (cx_sub, cy_sub, r_sub); falls back to the input on too few edges.
Preprocessing
Image preprocessing helpers shared by every detector.
The candidate-generation strategies differ: pick a working channel (gray / an HSV or BGR channel / a channel difference), optionally stretch brightness-contrast, optionally build an HSV colour mask, and turn a physical part diameter into a pixel radius band. Centralising these keeps the detectors focused on their algorithm and keeps the vocabulary (config keys) identical across methods.
adjust_brightness_contrast(img, min_value, max_value)
Linear brightness/contrast stretch mapping [min_value, max_value] → [0, 255].
Same transform NITTO uses (adjust_brightness_contrast_by_value): with
min_value=0, max_value<255 it boosts contrast so a faint edge separates from
the background before thresholding. A no-op when max_value<=0.
blur(img, ksize)
Gaussian blur with an odd-kernel guard; no-op when ksize<=1.
fiji_hue_to_cv(h_fiji)
Convert a Fiji/ImageJ hue (0-255) to OpenCV's 0-179 range.
hsv_mask(bgr, h_range, s_range, v_range, inv=False, hue_scale='cv')
Binary HSV in-range mask (255 = selected).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
h_range |
tuple
|
(min, max) hue. |
required |
s_range |
tuple
|
(min, max) saturation. |
required |
v_range |
tuple
|
(min, max) value. |
required |
inv |
bool
|
invert the mask. |
False
|
hue_scale |
str
|
|
'cv'
|
radius_band(diameter_mm=None, calibration=0.0, tol=0.3, r_min=None, r_max=None, scale=1.0)
Resolve the (r_min, r_max) pixel band a detector must accept.
Priority: explicit r_min/r_max win; otherwise, when both
diameter_mm and calibration (mm/px) are given, derive the expected
radius r = (diameter_mm/2) * scale / calibration and widen by ±tol.
Falls back to (0, +inf) when nothing is configured.
Deriving from the physical diameter (mm) is the key robustness trick uses: the band is invariant to camera/scale changes and cheaply rejects the large dark/bright blobs that otherwise get picked as a rounder "circle" than the real target.
select_channel(bgr, channel)
Return a single-channel working image for the requested channel name.
Supported: gray (default), HSV channels h/s/v, BGR channels
b/g/r, and the difference b-r (useful to isolate blue
fiducials, as yellow_fpc_alignment does). Falls back to gray for a
single-channel input.
to_bgr(image)
Return a 3-channel BGR image, or None if a single-channel array was given (colour-based methods then simply skip).
to_gray(image)
Accept an image path or an already-loaded array (gray or BGR) and return a single-channel uint8 image.