Vào thẳng nội dung

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 from opt.json without editing the core.
  • Method-agnostic edge_support re-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 radius r). This lets a union of methods be ranked fairly, and rewards radius/centre accuracy.
  • Radius band in mm or pixelsdiameter_mm + calibration derive a pixel band invariant to camera/scale; explicit r_min/r_max take precedence and fill any bound left null. 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, never cv2.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 top max_circles kept. 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 to max_circles circles each); results come back in full-image coordinates, tagged with meta["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, HSV h/s/v, BGR b/g/r, or the difference b-r.
  • bc_min / bc_max: brightness/contrast stretch (bc_max<=0 disables); blur_ksize: Gaussian blur kernel (odd; <=1 disables).
  • Radius band — explicit r_min/r_max take precedence; otherwise diameter_mm + calibration (mm/px) + diameter_tol derive it (band = expected r ± tol, where r = (diameter_mm/2) * scale / calibration). Leave r_min/r_max null when 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: when true, OK instead needs ≥1 circle in every ROI (the "one hole per ROI" pattern); min_score: minimum edge_support to accept; nms_dist_frac: de-duplication radius (fraction of r).
  • 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 common edge_support re-score — rays cast, search band (± fraction of r), minimum gradient to count, and how near r the 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)

houghcv2.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:

  1. reads config from opt.json (method(s), radius band, ROI, ...),
  2. preprocesses (channel select + brightness/contrast + blur),
  3. runs one or more registered detector strategies (with automatic coarse-to-fine seeding for the radial method),
  4. re-scores every candidate with the common edge_support metric,
  5. filters (radius band, min score), de-duplicates (NMS), keeps the top N,
  6. optionally refines the winners to sub-pixel accuracy,
  7. 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 opt.roi. Accepts: - None → full frame; - a single {"x","y","w","h"} or [x1,y1,x2,y2] → one ROI; - a list of either → several ROIs, each searched independently (up to max_circles circles per ROI). Results are returned in full-image coordinates.

None
seed Circle

explicit seed for the radial method. Honoured only when there is a single ROI; with several ROIs each crop is auto-seeded.

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 meta["roi_index"]

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 edge_support metric so a union of methods can be ranked fairly.

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" (0-179, default) or "fiji" (0-255, auto-converted).

'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.