Introduction
radsym is a Rust library for detecting circles and ellipses in grayscale images using gradient-based radial symmetry analysis. It turns raw pixel data into precise geometric detections — center coordinates, radii, and ellipse parameters — without requiring template matching, Hough accumulator grids, or deep learning models.
Design Philosophy
The library is built around four principles:
- Composable stages. Every algorithm is a standalone function with explicit inputs and outputs. You can use the full propose-score-refine pipeline, or pick individual stages and wire them into your own workflow.
- CPU-first. All computation runs on the CPU with no GPU or OpenCL
dependency. The optional
rayonfeature flag enables data-parallel execution across multiple cores. - Deterministic. Given the same input and configuration, radsym produces bit-identical output. There is no internal randomness and no ordering-dependent accumulation.
f32precision. Pixel coordinates, gradient values, scores, and geometric parameters are all single-precision floats. This matches the accuracy regime of typical machine-vision sensors and avoids unnecessary bandwidth and cache pressure fromf64.
Who Is This For?
radsym targets developers working on computer vision tasks that involve circular or elliptical features: industrial inspection (O-rings, bearings, nozzles), metrology (fiducial rings, calibration targets), microscopy (particle tracking), and autonomous systems (traffic sign detection, wheel localization).
The Three-Stage Pipeline
Detection follows a propose-score-refine architecture:
- Propose. Gradient voting (FRST or RSD) builds a response map that peaks at likely centers of radial symmetry. Non-maximum suppression extracts a sparse set of candidate locations.
- Score. Each proposal is evaluated by sampling gradients in an annulus
around the hypothesized circle or ellipse. The resulting
SupportScorecombines a ringness metric (gradient alignment) with angular coverage (how much of the circumference has evidence). - Refine. Surviving hypotheses are refined to subpixel accuracy using the Parthasarathy radial center algorithm for center position and iterative least-squares fitting for radius and ellipse parameters.
For the common case of detecting circles in a single image, the convenience
function detect_circles wraps all three stages into one call, configurable
through DetectCirclesConfig.
Installation and Quick Start
Adding radsym to Your Project
Add the dependency to your Cargo.toml:
[dependencies]
radsym = "0.1"
By default, no optional features are enabled. The core detection pipeline works
out of the box with zero additional dependencies beyond nalgebra and
thiserror.
Feature Flags
| Flag | What it enables |
|---|---|
rayon | Parallel multi-radius FRST voting and batch scoring via Rayon |
image-io | load_grayscale file I/O and save_diagnostic export via the image crate |
tracing | Structured log spans and events via the tracing crate |
affine | Experimental affine-aware extensions (GFRS, Ni et al. CVPR 2012) |
serde | Serialize / Deserialize derives on all config and result types |
Enable features in Cargo.toml as needed:
[dependencies]
radsym = { version = "0.1", features = ["rayon", "image-io"] }
Quick Start: the Composable Pipeline
The standard workflow uses root-level imports and explicit function calls for each stage:
#![allow(unused)]
fn main() {
use radsym::{
ImageView, FrstConfig, Circle, Polarity, ScoringConfig, NmsConfig,
sobel_gradient, frst_response, extract_proposals, score_circle_support,
refine_circle, CircleRefineConfig,
};
// 1. Build an ImageView from your pixel buffer (row-major, single-channel u8).
let image = ImageView::from_slice(&pixel_data, width, height).unwrap();
// 2. Compute the Sobel gradient field.
let gradient = sobel_gradient(&image).unwrap();
// 3. Run FRST voting across a range of candidate radii.
let frst_cfg = FrstConfig {
radii: vec![15, 16, 17, 18, 19, 20],
..FrstConfig::default()
};
let response = frst_response(&gradient, &frst_cfg).unwrap();
// 4. Extract proposals via non-maximum suppression.
let nms = NmsConfig { radius: 5, threshold: 0.0, max_detections: 10 };
let proposals = extract_proposals(&response, &nms, Polarity::Bright);
// 5. Score and refine each proposal.
for proposal in &proposals {
let circle = Circle::new(proposal.seed.position, 18.0);
let score = score_circle_support(&gradient, &circle, &ScoringConfig::default());
if score.total > 0.3 {
let result = refine_circle(&gradient, &circle, &CircleRefineConfig::default());
// result.hypothesis contains the refined Circle
}
}
}
Quick Start: the One-Call API
If you do not need per-stage control, detect_circles runs the entire
pipeline in a single call:
#![allow(unused)]
fn main() {
use radsym::{ImageView, FrstConfig, Polarity, detect_circles, DetectCirclesConfig};
let image = ImageView::from_slice(&pixel_data, width, height).unwrap();
let config = DetectCirclesConfig {
frst: FrstConfig { radii: vec![15, 16, 17, 18, 19, 20], ..FrstConfig::default() },
polarity: Polarity::Bright,
radius_hint: 18.0,
min_score: 0.3,
..DetectCirclesConfig::default()
};
let detections = detect_circles(&image, &config).unwrap();
for det in &detections {
println!("center=({:.1}, {:.1}) r={:.1} score={:.3}",
det.hypothesis.center.x, det.hypothesis.center.y,
det.hypothesis.radius, det.score.total);
}
}
detect_circles returns a Vec<Detection<Circle>> sorted by descending
support score. Each Detection carries the refined Circle, a SupportScore,
and a RefinementStatus indicating convergence.
Coordinate Convention
radsym uses (x = column, y = row) everywhere.
$x$ increases rightward, $y$ increases downward — consistent with
nalgebra::Point2<f32>, which is type-aliased as PixelCoord. All public API
functions expect and return coordinates in this convention.
Pipeline Overview
Architecture
The radsym detection pipeline has three stages, each independently usable:
┌──────────┐ ┌───────┐ ┌────────┐
Image ──Gradient──▶ Propose ├───▶│ Score ├───▶│ Refine ├──▶ Detections
└──────────┘ └───────┘ └────────┘
The design deliberately avoids a monolithic detector. Each stage is a pure function: it reads immutable inputs and returns a new value. You can swap, skip, or repeat stages as your application requires.
Stage 1: Propose
Goal. Convert the gradient field into a small set of candidate center locations.
The primary algorithm is the Fast Radial Symmetry Transform (FRST) (Loy & Zelinsky, ECCV 2002). For every pixel whose gradient magnitude exceeds a threshold, FRST casts a vote at the position offset by $\pm n$ pixels along the gradient direction, where $n$ is a candidate radius. Votes accumulate into an orientation image $O_n$ and a magnitude image $M_n$, which are combined as:
$$F_n = \lvert \tilde{O}_n \rvert^{\alpha} \cdot \tilde{M}_n$$
where $\alpha$ controls radial strictness and $\tilde{\cdot}$ denotes
Gaussian-smoothed, clamped accumulators. The per-radius responses are summed
into a final ResponseMap.
An alternative proposer, RSD (Barnes et al. 2008), uses a faster single-radius voting scheme optimized for real-time applications.
Non-maximum suppression (extract_proposals) then picks local peaks from
the response map, returning a ranked list of Proposal values.
Key types
| Input | Output |
|---|---|
GradientField | ResponseMap |
ResponseMap + NmsConfig + Polarity | Vec<Proposal> |
Stage 2: Score
Goal. Quantify how strongly image evidence supports each proposed circle or ellipse.
Scoring samples the gradient field inside an annular region around the hypothesis. For a circle of radius $r$, the annulus spans $[(1 - m) \cdot r,; (1 + m) \cdot r]$, where $m$ is the configurable margin.
Two complementary signals are extracted:
- Ringness — the mean alignment between each sampled gradient and the radial direction from the hypothesized center. A perfect circle gives ringness $\approx 1$.
- Angular coverage — the fraction of angular bins (around the center) that contain at least one supporting gradient sample. Full coverage means evidence is spread around the entire circumference, not concentrated in one arc.
These are combined into a single SupportScore:
$$\text{total} = w_r \cdot \text{ringness} + w_c \cdot \text{coverage}$$
where the weights $w_r$ and $w_c$ are set in ScoringConfig. Proposals whose
total falls below a threshold are discarded.
Key types
| Input | Output |
|---|---|
GradientField + Circle + ScoringConfig | SupportScore |
GradientField + Ellipse + ScoringConfig | SupportScore |
Stage 3: Refine
Goal. Improve center position and shape parameters to subpixel accuracy.
Refinement operates in two layers:
-
Center refinement via the Parthasarathy radial center algorithm (Nature Methods, 2012). Each pixel in a local patch defines a line through its position along its gradient direction. The subpixel center is the least-squares intersection of all such lines — a closed-form weighted linear solve, non-iterative and fast.
-
Shape refinement via iterative least-squares. Given the refined center, gradient-weighted points on the annulus are fit to a circle (Kasa, 1976) or an ellipse (Fitzgibbon et al., 1999). The fit updates the shape parameters, a new annulus is sampled, and the process repeats until convergence or the iteration limit.
The output is a RefinementResult<H> parameterized by the hypothesis type
(Circle or Ellipse). It contains the refined hypothesis, an RMS residual,
the iteration count, and a RefinementStatus flag (Converged,
MaxIterations, Degenerate, or OutOfBounds).
Key types
| Input | Output |
|---|---|
GradientField + Circle + CircleRefineConfig | RefinementResult<Circle> |
GradientField + Ellipse + EllipseRefineConfig | RefinementResult<Ellipse> |
Using Stages Independently
Each stage is a free function with no hidden state. Common patterns:
- Skip proposal generation when centers are known from an external source
(e.g., a coarse detector or user annotation). Jump straight to scoring or
refinement with a manually constructed
CircleorEllipse. - Use scoring without refinement for fast accept/reject decisions on a large batch of candidates.
- Run refinement without scoring when you trust the initial hypotheses and only need subpixel geometry.
Homography-Aware Variants
When the target circle is viewed under perspective, it projects to an ellipse in the image. radsym provides homography-aware versions of the propose and refine stages:
frst_response_homographyruns FRST voting in a rectified coordinate frame, so that circles remain circular during voting even when the original image shows a perspective-distorted view.refine_ellipse_homographyfits a circle in rectified space and maps the result back to an image-space ellipse via the homography.
These functions accept a Homography (a 3x3 projective transform) and a
RectifiedGrid that defines the resampled workspace.
FRST: Fast Radial Symmetry Transform
Problem
Detecting the centers of radially symmetric structures (circles, rings, bullseyes) in a grayscale image is a recurring primitive in computer vision – from traffic-sign detection to particle tracking. Classical approaches such as the Hough transform for circles are expensive: they accumulate votes in a three-dimensional $(x, y, r)$ parameter space, scaling poorly with image size and radius range.
FRST sidesteps the cubic cost by exploiting gradient orientation. Instead of testing every possible circle, each pixel votes for a single center location at each radius, using its gradient direction to determine where to vote. The result is a per-pixel response map that peaks at radial symmetry centers.
Algorithm
Gradient and affected pixels
Let $I$ be a grayscale image and let $\mathbf{g}(p) = (\partial_x I,, \partial_y I)$ be the image gradient at pixel $p = (x, y)$. The gradient magnitude is $|\mathbf{g}(p)| = \sqrt{g_x^2 + g_y^2}$, and the unit gradient direction is
$$\hat{\mathbf{g}}(p) = \frac{\mathbf{g}(p)}{|\mathbf{g}(p)|}.$$
For a given test radius $n$, each pixel $p$ with nonzero gradient defines two affected pixels by displacing along the gradient direction:
$$p_{+\text{ve}}(n) = p + \operatorname{round}!\bigl(\hat{\mathbf{g}}(p) \cdot n\bigr),$$
$$p_{-\text{ve}}(n) = p - \operatorname{round}!\bigl(\hat{\mathbf{g}}(p) \cdot n\bigr).$$
The positive-affected pixel $p_{+\text{ve}}$ lies downstream in the gradient direction – toward a brighter center. The negative-affected pixel $p_{-\text{ve}}$ lies upstream – toward a darker center. Rounding to integer coordinates maps each vote onto the discrete pixel grid.
Accumulator images
For each radius $n$, FRST maintains two accumulator images:
- Orientation projection $O_n$: records the direction of votes. At the positive-affected pixel, $O_n$ is incremented by $+1$; at the negative-affected pixel, it is decremented by $-1$:
$$O_n(q) = \sum_{{p ,:, p_{+\text{ve}} = q}} (+1) ;+; \sum_{{p ,:, p_{-\text{ve}} = q}} (-1).$$
- Magnitude projection $M_n$: records the strength of votes. At both affected pixels, $M_n$ is incremented by $|\mathbf{g}(p)|$:
$$M_n(q) = \sum_{{p ,:, p_{+\text{ve}} = q}} |\mathbf{g}(p)| ;+; \sum_{{p ,:, p_{-\text{ve}} = q}} |\mathbf{g}(p)|.$$
The orientation accumulator distinguishes genuine radial symmetry (where gradients converge consistently toward a single point) from accidental magnitude pile-ups.
Normalization and combination
The raw accumulators are normalized by their respective maxima to produce dimensionless quantities in $[-1, 1]$ and $[0, 1]$:
$$\tilde{O}_n = \frac{O_n}{\max!\bigl(1,; \max_q |O_n(q)|\bigr)}, \qquad \tilde{M}_n = \frac{M_n}{\max!\bigl(1,; \max_q M_n(q)\bigr)}.$$
These are combined into the per-radius contribution
$$F_n = \bigl|\tilde{O}_n\bigr|^{\alpha} \cdot \tilde{M}_n,$$
where $\alpha \geq 1$ is the radial strictness exponent. At $\alpha = 1$, the orientation term acts as a linear gate; at $\alpha = 2$ (the default), pixels that receive orientation-inconsistent votes are suppressed quadratically. Increasing $\alpha$ improves selectivity for true radial symmetry at the expense of weaker response to imperfect targets.
Gaussian smoothing
Each $F_n$ is convolved with a Gaussian kernel whose standard deviation scales with the radius:
$$S_n = G_{\sigma_n} * F_n, \qquad \sigma_n = k_n \cdot n,$$
where $k_n$ is the smoothing factor (default 0.5). Smoothing merges nearby votes that would otherwise form a speckled peak, particularly important for large radii where the ring of voting pixels is thin.
Multi-radius fusion
The full FRST response is the sum across all tested radii:
$$S = \sum_{n \in \mathcal{N}} S_n,$$
where $\mathcal{N}$ is the configured set of discrete radii. Peaks of $S$ are candidate centers of radially symmetric features. Non-maximum suppression is applied downstream to extract discrete proposals.
Polarity modes
Which affected pixels participate depends on the target polarity:
| Mode | Votes at $p_{+\text{ve}}$ | Votes at $p_{-\text{ve}}$ | Detects |
|---|---|---|---|
| Bright | yes | no | bright centers (gradient points inward) |
| Dark | no | yes | dark centers (gradient points outward) |
| Both | yes | yes | either polarity |
Implementation notes
- Gradient thresholding: pixels with $|\mathbf{g}| < t$ (the
gradient_thresholdparameter) are skipped entirely. This eliminates votes from flat regions and sensor noise, reducing computation and background clutter. - Rayon parallelism: when the
rayonfeature is enabled, per-radius computation runs in parallel. The voting pass within each radius is sequential (accumulator writes are not atomic), but radius-level parallelism provides near-linear speedup for large radius sets. - Gaussian blur: smoothing is skipped when $\sigma_n < 0.5$ pixels, since at that width the kernel has negligible effect.
Configuration
#![allow(unused)]
fn main() {
pub struct FrstConfig {
/// Discrete radii to test (pixels). Default: [3, 5, 7, 9, 11].
pub radii: Vec<u32>,
/// Radial strictness exponent. Default: 2.0.
pub alpha: f32,
/// Minimum gradient magnitude for voting. Default: 0.0.
pub gradient_threshold: f32,
/// Which polarity to detect. Default: Both.
pub polarity: Polarity,
/// Gaussian sigma = smoothing_factor * n. Default: 0.5.
pub smoothing_factor: f32,
}
}
| Field | Effect of increasing |
|---|---|
alpha | Sharper discrimination, weaker response on imperfect targets |
gradient_threshold | Fewer votes, less noise, possible loss of weak edges |
smoothing_factor | Broader peaks, better tolerance to discretization |
API usage
#![allow(unused)]
fn main() {
use radsym::core::gradient::sobel_gradient;
use radsym::core::image_view::ImageView;
use radsym::propose::frst::{frst_response, FrstConfig};
use radsym::core::polarity::Polarity;
let image = ImageView::from_slice(&pixels, width, height)?;
let gradient = sobel_gradient(&image)?;
let config = FrstConfig {
radii: vec![8, 10, 12],
alpha: 2.0,
gradient_threshold: 2.0,
polarity: Polarity::Bright,
smoothing_factor: 0.5,
};
let response_map = frst_response(&gradient, &config)?;
// response_map.response() is an OwnedImage<f32> — apply NMS to extract peaks
}
Reference
Loy, G. and Zelinsky, A. “Fast radial symmetry for detecting points of interest.” IEEE Transactions on Pattern Analysis and Machine Intelligence 25(8), 959–973 (2003). doi:10.1109/TPAMI.2003.1217601
RSD: Radial Symmetry Detector
Problem
RSD addresses the same center-detection problem as FRST – finding centers of radially symmetric structures in grayscale images – but trades discrimination for speed. In time-critical pipelines (real-time video, large radius sweeps), FRST’s orientation accumulator and $\alpha$-exponent combination step become the bottleneck. RSD drops both, retaining only magnitude-based voting.
Algorithm
Magnitude-only voting
As in FRST, each pixel $p$ with gradient $\mathbf{g}(p)$ and unit direction $\hat{\mathbf{g}}(p)$ defines affected pixels at radius $n$:
$$p_{+\text{ve}}(n) = p + \operatorname{round}!\bigl(\hat{\mathbf{g}}(p) \cdot n\bigr),$$
$$p_{-\text{ve}}(n) = p - \operatorname{round}!\bigl(\hat{\mathbf{g}}(p) \cdot n\bigr).$$
RSD maintains a single accumulator $M_n$ per radius. Each voter adds its gradient magnitude at the affected pixel(s):
$$M_n(q) = \sum_{{p ,:, p_{+\text{ve}} = q}} |\mathbf{g}(p)| ;+; \sum_{{p ,:, p_{-\text{ve}} = q}} |\mathbf{g}(p)|.$$
There is no orientation accumulator $O_n$ and no $\alpha$-exponent step. The absence of $O_n$ means RSD cannot distinguish a location where gradient directions converge coherently from one where strong but randomly-oriented gradients happen to land. This is the source of the speed / discrimination trade-off.
Smoothing and fusion
The accumulator is Gaussian-smoothed exactly as in FRST:
$$S_n = G_{\sigma_n} * M_n, \qquad \sigma_n = k_n \cdot n,$$
and the multi-radius response is the sum over all tested radii:
$$S = \sum_{n \in \mathcal{N}} S_n.$$
Computational advantage
Per radius, FRST performs three operations per voting pixel (orientation increment, magnitude increment, combination via $\alpha$-power), plus normalizes $O_n$ globally. RSD performs one magnitude accumulation per voter. The elimination of $O_n$ and the power-law combination yields roughly a 2x wall-clock speedup with identical memory layout, making RSD the preferred proposer when many radii must be tested under a time budget.
Polarity handling
Polarity logic is identical to FRST: Bright votes only at $p_{+\text{ve}}$,
Dark only at $p_{-\text{ve}}$, and Both votes at both locations. Each
polarity branch is implemented as a separate tight loop to avoid per-pixel
branching.
When to use
- Real-time pipelines where proposal generation must finish within a fixed time budget.
- Large radius sweeps (many values of $n$): RSD’s per-radius cost is lower, so the total cost scales more favorably.
- Coarse proposal stage followed by a discriminative refinement step (e.g., radial center or ellipse fit) that will reject false positives anyway.
When orientation selectivity matters – for example, distinguishing a bullseye from a textured corner – prefer FRST.
Configuration
#![allow(unused)]
fn main() {
pub struct RsdConfig {
/// Discrete radii to test (pixels). Default: [3, 5, 7, 9, 11].
pub radii: Vec<u32>,
/// Minimum gradient magnitude for voting. Default: 0.0.
pub gradient_threshold: f32,
/// Which polarity to detect. Default: Both.
pub polarity: Polarity,
/// Gaussian sigma = smoothing_factor * n. Default: 0.5.
pub smoothing_factor: f32,
}
}
Note the absence of alpha – no strictness exponent is needed because there
is no orientation accumulator to normalize.
API usage
#![allow(unused)]
fn main() {
use radsym::core::gradient::sobel_gradient;
use radsym::core::image_view::ImageView;
use radsym::propose::rsd::{rsd_response, RsdConfig};
use radsym::core::polarity::Polarity;
let image = ImageView::from_slice(&pixels, width, height)?;
let gradient = sobel_gradient(&image)?;
let config = RsdConfig {
radii: vec![8, 10, 12],
gradient_threshold: 2.0,
polarity: Polarity::Bright,
smoothing_factor: 0.5,
};
let response_map = rsd_response(&gradient, &config)?;
// response_map.response() is an OwnedImage<f32> — apply NMS to extract peaks
}
Reference
Barnes, N., Zelinsky, A., Fletcher, L.S. “Real-time radial symmetry for speed sign detection.” IEEE Intelligent Vehicles Symposium, 566–571 (2008). doi:10.1109/IVS.2008.4621217
Radial Center Refinement
Problem
Given a coarse seed location (e.g., from FRST or RSD), find the subpixel center of a radially symmetric intensity pattern. The algorithm must be fast enough for real-time tracking of many particles and robust to noise and partial occlusion.
Algorithm
The radial center method, introduced by Parthasarathy (2012), exploits the fact that gradient vectors of a radially symmetric pattern all point toward (or away from) the center. The center is the point that minimizes the weighted sum of squared distances to the lines defined by all gradient vectors in a local patch.
Gradient lines
At each pixel $p_i = (x_i, y_i)$, compute the image gradient $\mathbf{g}_i = (g_x^{(i)},, g_y^{(i)})$ and its magnitude $m_i = |\mathbf{g}_i|$. The unit gradient direction is
$$\hat{\mathbf{n}}_i = \frac{1}{m_i}\begin{pmatrix} g_x^{(i)} \ g_y^{(i)} \end{pmatrix}.$$
This direction defines a line through $p_i$. A candidate center $\mathbf{c} = (c_x, c_y)$ has signed distance to this line equal to
$$d_i(\mathbf{c}) = \hat{\mathbf{n}}_i \cdot (\mathbf{c} - p_i) = \hat{n}_x^{(i)}(c_x - x_i) + \hat{n}_y^{(i)}(c_y - y_i).$$
Weighted least squares
The center is found by minimizing
$$E(\mathbf{c}) = \sum_i w_i \bigl[ d_i(\mathbf{c}) \bigr]^2$$
with weights $w_i = m_i^2$ (gradient magnitude squared). Weighting by $m_i^2$ down-weights pixels with weak gradients, which carry little directional information and would otherwise bias the estimate.
Expanding the objective and setting $\partial E / \partial \mathbf{c} = 0$ yields the $2 \times 2$ normal equations
$$\mathbf{H},\mathbf{c} = \mathbf{b}$$
where
$$H = \sum_i w_i \begin{pmatrix} \hat{n}_x^2 & \hat{n}_x \hat{n}_y \ \hat{n}_x \hat{n}_y & \hat{n}_y^2 \end{pmatrix}, \qquad b = \sum_i w_i \begin{pmatrix} \hat{n}_x^2, x_i + \hat{n}_x \hat{n}_y, y_i \ \hat{n}_x \hat{n}_y, x_i + \hat{n}_y^2, y_i \end{pmatrix}.$$
Note that $\mathbf{H}$ is the weighted sum of the outer products $\hat{\mathbf{n}}_i \hat{\mathbf{n}}_i^T$, and $\mathbf{b} = \sum_i w_i (\hat{\mathbf{n}}_i \hat{\mathbf{n}}_i^T) p_i$, so the normal equations can also be written compactly as
$$\biggl(\sum_i w_i, \hat{\mathbf{n}}_i \hat{\mathbf{n}}_i^T\biggr) \mathbf{c} = \sum_i w_i, (\hat{\mathbf{n}}_i \hat{\mathbf{n}}_i^T), p_i.$$
Closed-form solution
Since $\mathbf{H}$ is $2 \times 2$, the solution is obtained by direct inversion:
$$\det(\mathbf{H}) = H_{00}, H_{11} - H_{01}^2,$$
$$c_x = \frac{H_{11}, b_x - H_{01}, b_y}{\det(\mathbf{H})}, \qquad c_y = \frac{H_{00}, b_y - H_{01}, b_x}{\det(\mathbf{H})}.$$
The algorithm is non-iterative: a single pass over the patch pixels followed by one $2 \times 2$ linear solve.
Two variants in radsym
| Variant | Gradient | Grid | Function |
|---|---|---|---|
| Reference | Roberts cross | half-pixel $(i+0.5,, j+0.5)$ | radial_center_refine |
| Production | Precomputed Sobel | integer pixel | radial_center_refine_from_gradient |
The reference variant computes Roberts-cross gradients on the half-pixel grid, matching the original paper exactly:
$$g_x(i!+!\tfrac{1}{2},, j!+!\tfrac{1}{2}) = \tfrac{1}{2}\bigl[I(i!+!1,j) - I(i,j) + I(i!+!1,j!+!1) - I(i,j!+!1)\bigr],$$
$$g_y(i!+!\tfrac{1}{2},, j!+!\tfrac{1}{2}) = \tfrac{1}{2}\bigl[I(i,j!+!1) - I(i,j) + I(i!+!1,j!+!1) - I(i!+!1,j)\bigr].$$
The production variant accepts a precomputed GradientField (typically
Sobel), which avoids redundant gradient computation when the field is already
available from proposal generation.
Implementation notes
- Degenerate detection: if $|\det(\mathbf{H})| < 10^{-12}$, the system
is nearly singular (e.g., uniform patch or all gradients aligned). The
function returns
RefinementStatus::Degenerateand falls back to the original seed. - Gradient threshold: pixels with $m_i$ below
RadialCenterConfig::gradient_threshold(default $10^{-4}$) are excluded from the summation. - Patch radius: the fit uses a square patch of side $2 \times \texttt{patch_radius} + 1$ centered on the rounded seed. Default is 12, yielding a $25 \times 25$ patch.
- Accumulation precision: all sums are accumulated in
f64to avoid catastrophic cancellation, then the final center is cast back tof32.
Configuration
#![allow(unused)]
fn main() {
pub struct RadialCenterConfig {
pub patch_radius: usize, // default: 12
pub gradient_threshold: f32, // default: 1e-4
}
}
API usage
#![allow(unused)]
fn main() {
use radsym::refine::{radial_center_refine_from_gradient, RadialCenterConfig};
let config = RadialCenterConfig::default();
let result = radial_center_refine_from_gradient(&gradient_field, seed, &config)?;
if result.converged() {
let center = result.hypothesis; // PixelCoord (subpixel)
let shift = result.residual; // displacement from seed
}
}
Reference
Parthasarathy, R. “Rapid, accurate particle tracking by calculation of radial symmetry centers.” Nature Methods 9, 724–726 (2012). doi:10.1038/nmeth.2071
Kasa Circle Fit
Problem
Given a set of 2D points ${(x_i, y_i)}_{i=1}^{N}$ (possibly with associated weights $w_i$), fit a circle that best approximates the point set. This arises in radsym when refining ring-shaped support regions or when converting annulus edge samples into a circle hypothesis.
Algorithm
The Kasa method (1976) minimizes the algebraic distance to the implicit circle equation. While geometric (orthogonal) distance fitting is statistically superior, algebraic fitting reduces to a single linear system and is fast enough for use in inner loops.
Implicit circle equation
A circle with center $(a, b)$ and radius $r$ satisfies
$$ (x - a)^2 + (y - b)^2 = r^2. $$
Expanding and rearranging gives the implicit form
$$ x^2 + y^2 + Dx + Ey + F = 0 $$
where $D = -2a$, $E = -2b$, and $F = a^2 + b^2 - r^2$. The circle parameters are recovered as
$$ a = -\frac{D}{2}, \qquad b = -\frac{E}{2}, \qquad r = \sqrt{\frac{D^2}{4} + \frac{E^2}{4} - F}. $$
Least-squares formulation
For each point $(x_i, y_i)$, define the algebraic residual
$$ e_i = x_i^2 + y_i^2 + D x_i + E y_i + F. $$
The Kasa method minimizes the weighted sum of squared residuals:
$$ \min_{D,,E,,F} \sum_{i=1}^{N} w_i, e_i^2. $$
Letting $r_i = x_i^2 + y_i^2$ and $\mathbf{p} = (D,, E,, F)^T$, the residual for point $i$ is
$$ e_i = r_i + \mathbf{a}_i^T \mathbf{p}, \qquad \mathbf{a}_i = \begin{pmatrix} x_i \ y_i \ 1 \end{pmatrix}. $$
Normal equations
Setting $\partial / \partial \mathbf{p} \sum_i w_i e_i^2 = 0$ yields
$$ \mathbf{A}^T \mathbf{W} \mathbf{A}, \mathbf{p} = -\mathbf{A}^T \mathbf{W}, \mathbf{r} $$
where $\mathbf{A}$ is the $N \times 3$ matrix with rows $\mathbf{a}_i^T$, $\mathbf{W} = \mathrm{diag}(w_1, \ldots, w_N)$, and $\mathbf{r} = (r_1, \ldots, r_N)^T$.
In the implementation, the $3 \times 3$ matrix $\mathbf{H} = \mathbf{A}^T \mathbf{W} \mathbf{A}$ and the right-hand side $\mathbf{g} = -\mathbf{A}^T \mathbf{W}, \mathbf{r}$ are accumulated incrementally:
$$ \mathbf{H} = \sum_i w_i, \mathbf{a}_i \mathbf{a}_i^T, \qquad \mathbf{g} = -\sum_i w_i, r_i, \mathbf{a}_i. $$
The system $\mathbf{H},\mathbf{p} = \mathbf{g}$ is solved via LU decomposition (provided by nalgebra).
Recovering circle parameters
From the solution vector $\mathbf{p} = (D,, E,, F)^T$:
$$ \text{center} = \left(-\frac{D}{2},; -\frac{E}{2}\right), \qquad r^2 = \frac{D^2}{4} + \frac{E^2}{4} - F. $$
If $r^2 \le 10^{-6}$, the center coordinates are non-finite, or the LU
decomposition fails, the fit is rejected and the function returns None.
Implementation notes
- Minimum point count: at least 3 points are required (a circle has 3
degrees of freedom). Fewer points return
None. - Weight clamping: in
fit_circle_weighted, each weight is clamped to a minimum of $10^{-3}$ to prevent near-zero weights from creating ill-conditioned systems. - Validity checks: the solution is rejected if the center coordinates are not finite or $r^2 \le 10^{-6}$ (degenerate or imaginary radius).
- Bias note: the Kasa method is known to exhibit a systematic bias toward smaller radii when points cover only a short arc. For full or near-full arcs the bias is negligible.
Configuration
The circle fit functions take no configuration struct. All behavior is controlled through function parameters:
| Parameter | Description |
|---|---|
points: &[PixelCoord] | Slice of 2D points on or near the circle |
weights: &[Scalar] | Per-point weights (weighted variant only) |
API usage
#![allow(unused)]
fn main() {
use radsym::core::circle_fit::{fit_circle, fit_circle_weighted};
use radsym::core::coords::PixelCoord;
// Unweighted fit
let points: Vec<PixelCoord> = /* edge samples */;
if let Some(circle) = fit_circle(&points) {
println!("center=({}, {}), r={}", circle.center.x, circle.center.y, circle.radius);
}
// Weighted fit
let weights: Vec<f32> = /* per-point confidence */;
if let Some(circle) = fit_circle_weighted(&points, &weights) {
// ...
}
}
Reference
Kasa, I. “A circle fitting procedure and its error analysis.” IEEE Transactions on Instrumentation and Measurement, 25(1), 8–14 (1976). doi:10.1109/TIM.1976.6312298
Ellipse Refinement
Problem
After the proposal and scoring stages, the best circle hypotheses may not accurately describe the true feature boundary – perspective distortion, lens aberration, or an intrinsically elliptical target all produce non-circular edges. Ellipse refinement upgrades a circle seed into a five-parameter ellipse (center $c_x, c_y$; semi-axes $a, b$; orientation $\theta$) by alternating between edge detection and robust fitting.
Algorithm
Parameterization
An ellipse is represented by a center $(c_x, c_y)$, semi-major axis $a$, semi-minor axis $b$, and orientation angle $\theta$ (measured from the $x$-axis to the semi-major axis). A point $(x, y)$ is rotated into the ellipse frame:
$$q_x = \cos\theta \cdot (x - c_x) + \sin\theta \cdot (y - c_y),$$
$$q_y = -\sin\theta \cdot (x - c_x) + \cos\theta \cdot (y - c_y).$$
The normalized radial distance is
$$s = \frac{q_x^2}{a^2} + \frac{q_y^2}{b^2},$$
and the geometric residual for fitting is $r = \sqrt{s} - 1$. A point lying exactly on the ellipse gives $r = 0$.
Iterative loop
Starting from the seed circle (treated as a degenerate ellipse with $a = b$
and $\theta = 0$), the refiner executes up to max_iterations rounds:
-
Edge detection along normals. The angular range $[0, 2\pi)$ is divided into
ray_countsectors. For each sector, a search ray is cast along the outward normal of the current ellipse estimate. The gradient magnitude profile along this ray is scanned for peaks – the strongest gradient response withinnormal_search_half_widthpixels of the predicted boundary is accepted as an edge observation. Bilinear interpolation provides sub-pixel gradient sampling. -
Trimmed Gauss-Newton fitting. Given $N$ edge observations, sort by residual magnitude $|r_i|$ and retain only the best 75% (the
TRIM_KEEP_FRACTION). This trimming rejects outliers from texture, adjacent features, or missed edges.The Gauss-Newton update minimizes the sum of squared residuals over the five-dimensional parameter vector $\mathbf{p} = (c_x,, c_y,, \log a,, \log b,, \theta)^T$ (log-scale for the semi-axes ensures positivity):
$$\mathbf{J}^T \mathbf{J}, \Delta\mathbf{p} = -\mathbf{J}^T \mathbf{r},$$
where $\mathbf{J}$ is the $N \times 5$ Jacobian with rows
$$\frac{\partial r_i}{\partial \mathbf{p}} = \left( \frac{\partial r_i}{\partial c_x},; \frac{\partial r_i}{\partial c_y},; \frac{\partial r_i}{\partial \log a},; \frac{\partial r_i}{\partial \log b},; \frac{\partial r_i}{\partial \theta} \right).$$
The individual Jacobian components are:
$$\frac{\partial r}{\partial c_x} = \frac{-q_x \cos\theta / a^2 + q_y \sin\theta / b^2}{\sqrt{s}},$$
$$\frac{\partial r}{\partial c_y} = \frac{-q_x \sin\theta / a^2 - q_y \cos\theta / b^2}{\sqrt{s}},$$
$$\frac{\partial r}{\partial \log a} = \frac{-q_x^2 / a^2}{\sqrt{s}}, \qquad \frac{\partial r}{\partial \log b} = \frac{-q_y^2 / b^2}{\sqrt{s}},$$
$$\frac{\partial r}{\partial \theta} = \frac{q_x q_y (1/a^2 - 1/b^2)}{\sqrt{s}}.$$
The $5 \times 5$ normal equations are solved via Cholesky decomposition. Up to
SOLVER_MAX_STEPS(8) inner iterations are run per outer loop. -
Guard constraints. After each update the candidate ellipse is checked:
- Semi-minor axis must be at least $0.55 \times r_{\text{seed}}$.
- Semi-major axis must be at most $1.6 \times r_{\text{seed}}$.
- Axis ratio $a/b$ must not exceed
max_axis_ratio(default 1.8). - Center shift from the original seed must be within
max_center_shift_fraction$\times, r_{\text{seed}}$ (default 0.4). - The ellipse must remain within image bounds.
If any constraint fails, the update is rejected and the previous estimate is kept.
-
Convergence. The loop terminates early when the center shift and axis changes between successive iterations fall below
convergence_tol(default 0.1 pixels).
Initial edge search
Before entering the iterative loop, a radial search from
radial_search_inner to radial_search_outer times the seed radius gathers
the first set of boundary points. These are used to bootstrap an initial
ellipse via weighted covariance analysis of the edge locations, which provides
a better starting point than the seed circle alone.
Configuration
#![allow(unused)]
fn main() {
pub struct EllipseRefineConfig {
pub max_iterations: usize, // 5
pub convergence_tol: f32, // 0.1
pub annulus_margin: f32, // 0.3
pub radial_center: RadialCenterConfig,
pub sampling: AnnulusSamplingConfig,
pub min_alignment: f32, // 0.3
pub ray_count: usize, // 96
pub radial_search_inner: f32, // 0.6
pub radial_search_outer: f32, // 1.45
pub normal_search_half_width: f32, // 6.0
pub min_inlier_coverage: f32, // 0.6
pub max_center_shift_fraction: f32,// 0.4
pub max_axis_ratio: f32, // 1.8
}
}
| Field | Effect of increasing |
|---|---|
ray_count | Denser angular sampling, better coverage but slower |
normal_search_half_width | Wider search window, tolerates larger initial error |
max_axis_ratio | Permits more eccentric ellipses |
max_center_shift_fraction | Allows larger center correction from the seed |
Reference
Fitzgibbon, A., Pilu, M., Fisher, R. “Direct least squares fitting of ellipses.” IEEE Transactions on Pattern Analysis and Machine Intelligence 21(5), 476–480 (1999). doi:10.1109/34.765658
GFRS: Generalized Fast Radial Symmetry
Overview
GFRS extends FRST to detect elliptical symmetry under affine distortion. When a circular target is viewed under perspective, it projects to an ellipse. Standard FRST misses such targets because gradient directions no longer converge to a single center at a single radius. GFRS recovers the detection by warping gradient directions through a set of affine maps before voting.
Algorithm
For a sampled set of 2D affine transformations ${A_k}$:
- At each pixel $p$ with gradient direction $\hat{\mathbf{g}}(p)$, compute the warped direction $A_k, \hat{\mathbf{g}}(p)$.
- Compute the affected pixel offset using the warped direction and vote into a per-map accumulator.
- Smooth each accumulator and record its peak response.
The map $A_k$ that produces the strongest peak gives both the center location and an estimate of the affine distortion. The distortion matrix directly yields the ellipse semi-axes and orientation.
Configuration
#![allow(unused)]
fn main() {
pub struct AffineFrstConfig {
/// Single voting radius (pixels).
pub radius: u32,
/// Minimum gradient magnitude to vote.
pub gradient_threshold: f32,
/// Gaussian sigma = smoothing_factor * radius.
pub smoothing_factor: f32,
/// Set of affine maps to evaluate.
pub affine_maps: Vec<AffineMap>,
}
}
The number of affine maps controls the trade-off between angular/scale resolution and computation time. Typical usage samples 20–50 maps covering the expected range of perspective distortion.
Feature gate
GFRS is gated behind the affine feature flag:
radsym = { version = "...", features = ["affine"] }
Reference
Ni, K., Singh, M., Bahlmann, C. “Fast radial symmetry detection under affine transformations.” IEEE Conference on Computer Vision and Pattern Recognition (CVPR), 932–939 (2012). doi:10.1109/CVPR.2012.6247767
Homography-Aware Extensions
Motivation
When a known planar homography relates the image to a rectified frame – for example, a ground-plane homography from camera calibration – circular targets in the rectified frame appear as ellipses in the image. Rather than detecting ellipses directly (which is harder and less reliable), the homography-aware extensions perform detection in the rectified space where targets are circular, then transport results back to image coordinates.
Core types
Homography
The Homography struct wraps a $3 \times 3$ matrix $H$ that maps image
coordinates to rectified coordinates:
$$\mathbf{x}_R \sim H, \mathbf{x}_I$$
where $\mathbf{x}_I = (x, y, 1)^T$ is a point in the source image and $\mathbf{x}_R$ is the corresponding point in the rectified frame. The struct caches the inverse $H^{-1}$ at construction time, so forward and backward point transformations are both available without recomputation.
RectifiedGrid
RectifiedGrid defines a discrete pixel grid in rectified space. It stores
the grid dimensions and the Homography, providing the mapping between
rectified pixel indices and image coordinates. FRST voting and NMS operate on
this grid.
Proposal: FRST in rectified space
The propose::homography module runs FRST voting on a RectifiedGrid:
- For each pixel in the rectified grid, compute the corresponding image coordinate via $H^{-1}$ and sample the image gradient using bilinear interpolation.
- Transform the gradient direction into the rectified frame using the local Jacobian of $H$.
- Vote into the rectified accumulator as in standard FRST.
- Apply Gaussian smoothing and NMS in the rectified grid to extract peaks.
Each peak is a HomographyProposal containing the rectified-frame center,
a scale hint (rectified radius), and the corresponding image-space ellipse
obtained by projecting the rectified circle through $H^{-1}$.
Reranking
The HomographyRerankConfig provides a post-proposal reranking step. For each
proposal, it sweeps a range of rectified radii and scores edge evidence along
radial rays in image space (transformed through the homography). A Gaussian
size prior and center prior can be combined with the edge score to reorder
proposals. This is useful when FRST peaks are noisy and the expected target
size is approximately known.
Refinement: circle fit in rectified space
The refine::homography module refines a homography proposal by fitting a
circle in rectified space:
- Starting from the initial rectified circle, cast rays outward from the image-space ellipse along its normals.
- Detect edge observations via gradient peak search (same mechanism as ellipse refinement).
- Transform each image-space edge point into rectified coordinates via $H$.
- Fit a circle to the rectified edge points using trimmed Gauss-Newton (analogous to the ellipse fitter, but with 3 parameters: $c_x, c_y, r$).
- Project the refined rectified circle back to image space as an ellipse using the conic projection $C_I = H^{-T}, C_R, H^{-1}$, where $C_R$ is the conic matrix of the rectified circle.
Guard constraints (maximum center shift, maximum radius change) are enforced in rectified space, where the geometry is simpler.
When to use
Use the homography-aware path when:
- A calibrated homography is available (e.g., road-plane from vehicle calibration, table-plane from a fixed camera).
- Targets are known to be circular in the world plane.
- Perspective distortion is significant enough that standard FRST or direct ellipse fitting underperforms.
When no homography is available, use standard FRST or GFRS for proposal generation and ellipse refinement for shape fitting.
Configuration
#![allow(unused)]
fn main() {
pub struct HomographyEllipseRefineConfig {
pub max_iterations: usize, // 5
pub convergence_tol: f32, // 0.1
pub radial_center: RadialCenterConfig,
pub ray_count: usize, // 96
pub radial_search_inner: f32, // 0.6
pub radial_search_outer: f32, // 1.45
pub normal_search_half_width: f32, // 6.0
pub min_inlier_coverage: f32, // 0.45
pub max_center_shift_fraction: f32, // 0.4
pub max_radius_change_fraction: f32, // 0.6
}
}
#![allow(unused)]
fn main() {
pub struct HomographyRerankConfig {
pub min_radius: f32, // 6.0
pub max_radius: f32, // 0.0 (auto)
pub radius_step: f32, // 2.0
pub ray_count: usize, // 64
pub radial_search_inner: f32, // 0.6
pub radial_search_outer: f32, // 1.45
pub size_prior_sigma: f32, // 0.22
pub center_prior_sigma_fraction: f32, // 0.45
}
}
Annulus Sampling
Overview
Annulus sampling extracts local gradient evidence from a ring-shaped region around a hypothesis center. This evidence feeds into the support scoring stage, which determines how strongly the image supports the hypothesis.
Sampling strategy
The annulus is defined by an inner radius and an outer radius (typically
computed as $(1 - m) \cdot r$ and $(1 + m) \cdot r$, where $r$ is the
hypothesized radius and $m$ is the annulus_margin). Sample points are placed
on a regular grid in polar coordinates:
- Angular axis:
num_angular_samplesevenly spaced angles in $[0, 2\pi)$. - Radial axis:
num_radial_sampleslinearly interpolated offsets between the inner and outer radii.
For each sample at angle $\theta$ and radius $\rho$, the image-space coordinates are
$$x = c_x + \rho \cos\theta, \qquad y = c_y + \rho \sin\theta.$$
Gradient readout
At each sample point, the gradient $(g_x, g_y)$ is read from the precomputed
GradientField using bilinear interpolation, giving sub-pixel accuracy.
Samples that fall outside image bounds are silently skipped.
The radial alignment at each sample is
$$a = \frac{|g_x \cos\theta + g_y \sin\theta|}{|\mathbf{g}|},$$
measuring how well the gradient direction agrees with the radial direction from the center. A perfect ring gives $a = 1$ at every sample.
Circular and elliptical variants
Two sampling functions are provided:
sample_annulus– circular annulus, parameterized by center and inner/outer radii.sample_elliptical_annulus– elliptical annulus, parameterized by anEllipseand margin. The radial direction at each angle is computed from the ellipse geometry, stretching sample placement along the appropriate axes.
Both return a SupportEvidence struct containing the individual gradient
samples, the total sample count, and the mean gradient alignment.
Configuration
#![allow(unused)]
fn main() {
pub struct AnnulusSamplingConfig {
/// Number of angular samples around the annulus. Default: 64.
pub num_angular_samples: usize,
/// Number of radial samples across the annulus width. Default: 9.
pub num_radial_samples: usize,
}
}
The total number of sample points is num_angular_samples * num_radial_samples
(576 by default). Increasing angular samples improves coverage estimation;
increasing radial samples captures more of the annulus width, which is useful
for thick rings or uncertain radius estimates.
Validation requires num_angular_samples >= 4 and num_radial_samples >= 1.
Support Scoring
Overview
Support scoring quantifies how strongly local image evidence supports a
geometric hypothesis (circle or ellipse). The output is a SupportScore with
component breakdown, used to rank hypotheses and reject weak detections.
Score components
Ringness
The ringness component measures gradient alignment within the annulus. It is the mean absolute radial alignment across all valid gradient samples:
$$\text{ringness} = \frac{1}{N} \sum_{i=1}^{N} |a_i|,$$
where $a_i$ is the radial alignment of the $i$-th sample (see annulus sampling). A value near 1.0 indicates strong ring-like support; values near 0.0 indicate random or tangential gradients.
Angular coverage
The angular_coverage component measures what fraction of the angular range $[0, 2\pi)$ is covered by sufficiently strong gradient responses. The annulus is divided into angular bins, and each bin is considered “covered” if at least one sample in that bin has a gradient magnitude above a threshold. Coverage is the fraction of covered bins.
This component guards against the case where a few strong edge fragments dominate the ringness score despite most of the ring being absent.
Weighted total
The combined score is a weighted sum clamped to $[0, 1]$:
$$\text{total} = \text{clamp}\bigl(w_r \cdot \text{ringness} + w_c \cdot \text{coverage},; 0,; 1\bigr),$$
where $w_r$ = weight_ringness (default 0.6) and $w_c$ = weight_coverage
(default 0.4).
Degeneracy detection
If the number of valid gradient samples falls below min_samples (default 8),
the score is flagged as degenerate (is_degenerate = true) and the total is
set to 0.0. This prevents small hypotheses near image borders or in flat
regions from receiving artificially high scores.
Rectified variant
score_rectified_circle_support computes the support score for a circle
defined in rectified space (via a Homography). It transforms sample
positions from the rectified circle into image coordinates, reads the
gradient in image space, and pulls back the radial direction through the
homography Jacobian to compute alignment. This ensures correct scoring even
under perspective distortion.
Configuration
#![allow(unused)]
fn main() {
pub struct ScoringConfig {
/// Annulus sampling parameters.
pub sampling: AnnulusSamplingConfig,
/// Fractional margin around the radius. Default: 0.3.
pub annulus_margin: f32,
/// Minimum samples to avoid degeneracy. Default: 8.
pub min_samples: usize,
/// Weight for ringness in total score. Default: 0.6.
pub weight_ringness: f32,
/// Weight for angular coverage in total score. Default: 0.4.
pub weight_coverage: f32,
}
}
| Field | Effect of increasing |
|---|---|
annulus_margin | Wider sampling band, more tolerant of radius error |
min_samples | Stricter degeneracy threshold |
weight_ringness | Favors gradient alignment over coverage |
weight_coverage | Favors complete rings over strong partial arcs |
Gradient Computation
Overview
The gradient module provides Sobel-based image gradient computation, which is the first step in nearly every radsym pipeline. All proposal and refinement algorithms operate on the precomputed gradient field rather than the raw image.
Sobel 3x3 operator
Two functions are provided:
sobel_gradient– accepts anImageView<u8>(8-bit grayscale).sobel_gradient_f32– accepts anImageView<f32>(floating-point).
Both apply the standard 3x3 Sobel kernels:
$$G_x = \begin{bmatrix} -1 & 0 & +1 \ -2 & 0 & +2 \ -1 & 0 & +1 \end{bmatrix}, \qquad G_y = \begin{bmatrix} -1 & -2 & -1 \ 0 & 0 & 0 \ +1 & +2 & +1 \end{bmatrix},$$
producing horizontal ($\partial I / \partial x$) and vertical ($\partial I / \partial y$) derivatives respectively.
GradientField
The result is stored in a GradientField struct with two OwnedImage<f32>
buffers:
gx– horizontal gradient. Positive values indicate intensity increasing rightward.gy– vertical gradient. Positive values indicate intensity increasing downward.
Both buffers have the same dimensions as the input image. Accessor methods
gx() and gy() return borrowed ImageView slices for zero-copy downstream
use.
Border handling
Border pixels (the outermost 1-pixel ring) are set to zero, since the 3x3
kernel requires a 1-pixel neighborhood that is not available at the boundary.
This is consistent across both u8 and f32 variants.
Non-Maximum Suppression
Overview
Non-maximum suppression (NMS) extracts discrete peak locations from a continuous 2D response map (e.g., the output of FRST or RSD). It suppresses nearby weaker responses to produce a sparse set of well-separated candidate centers.
Algorithm
For each pixel in the response map:
- Check if the pixel value exceeds
threshold. Skip if not. - Compare against all pixels within a square window of half-size
radius. The pixel must be strictly greater than every neighbor in the window to qualify as a peak. - Collect all qualifying peaks and sort by descending score.
- Truncate to
max_detectionsentries.
The result is a Vec<Peak>, where each Peak carries a PixelCoord
(sub-pixel position at integer coordinates) and a score (the response
value at that location). Peaks are returned in descending score order,
providing a natural ranking for downstream stages.
Configuration
#![allow(unused)]
fn main() {
pub struct NmsConfig {
/// Suppression radius (half-window size in pixels). Default: 5.
pub radius: usize,
/// Minimum response to consider. Default: 0.0.
pub threshold: f32,
/// Maximum number of peaks to return. Default: 1000.
pub max_detections: usize,
}
}
| Field | Effect of increasing |
|---|---|
radius | Fewer, more spread-out peaks |
threshold | Eliminates weak candidates early |
max_detections | Higher budget cap, more proposals to evaluate |
Gaussian Blur
Overview
Gaussian blur is applied to FRST and RSD response maps before peak extraction. The blur module provides an in-place implementation with two execution paths selected automatically based on kernel size.
Dual-mode execution
-
Direct convolution (sigma <= 2.0): a separable 1D Gaussian kernel with radius $\lceil 3\sigma \rceil$ is applied horizontally then vertically. This is straightforward and efficient for small kernels.
-
Stacked box blur (sigma > 2.0): three successive passes of a uniform box filter approximate the Gaussian. Each pass is O(1) per pixel using a sliding-window accumulator, making the total cost independent of sigma. The box widths are chosen so that the cascade converges to the target Gaussian in the limit (Wells 1986; W3C Filter Effects Module Level 1, Section 12.2).
When sigma <= 0.5, the kernel radius would be zero, so the function is a no-op.
Boundary handling
Both paths use mirror-clamp boundary extension: samples outside the image are reflected back from the nearest edge. This avoids dark halos at image borders that would bias peak detection near the edges.
Usage
The blur function is pub(crate) and called internally by the FRST and RSD
response computation. It operates in-place on an OwnedImage<f32>, requiring
no additional output buffer.
Image Pyramids
Overview
The pyramid module provides multi-scale image access via the box-image-pyramid
crate. Downsampled images allow detection of large radial features without
requiring proportionally large voting radii.
PyramidWorkspace
PyramidWorkspace is a reusable buffer that avoids reallocating intermediate
downsample storage on every frame. Construct it once, then call
workspace.level(image, level) to extract a borrowed view at the requested
pyramid level. Level 0 is the original resolution; each subsequent level halves
both dimensions using a box filter.
#![allow(unused)]
fn main() {
let mut workspace = PyramidWorkspace::new();
let level2 = workspace.level(image, 2)?;
// level2.image() is an ImageView at 1/4 resolution
}
One-shot downsampling
For cases where workspace reuse is not needed, pyramid_level_owned extracts
a single pyramid level as an owned image:
#![allow(unused)]
fn main() {
let downsampled = pyramid_level_owned(&image, 2)?;
}
Coordinate remapping
Pyramid levels track the scale factor so that detections at a downsampled level
can be mapped back to the original image frame. OwnedPyramidLevel provides
methods to remap Circle and Ellipse geometries to and from the level
coordinate system.
Interactive Demo
The interactive demo runs the full radsym detection pipeline in your browser via WebAssembly. You can switch between proposal algorithms (FRST, RSD, and their fused variants), adjust all configuration parameters, and inspect individual detections.
Running locally
If the embedded demo above does not load, you can run it locally:
# Build the WASM package
wasm-pack build crates/radsym-wasm --target web --release
# Serve the repository root
python3 -m http.server 8080
# Open the demo
open http://localhost:8080/demo/
What the demo shows
| Panel | Description |
|---|---|
| Original | Source image (default: ringgrid.png calibration target) |
| Response Heatmap | Response map from the selected algorithm, colorized |
| Seed Proposals | NMS-extracted proposal locations (crosses) |
| Detected Circles | Full pipeline output with overlay annotations |
Available algorithms
| Algorithm | Method | Description |
|---|---|---|
| FRST | frst_response | Multi-radius voting with orientation accumulator |
| FRST (fused) | multiradius_response | Single-pass fused variant, faster |
| RSD | rsd_response | Magnitude-only voting, ~2x faster than FRST |
| RSD (fused) | rsd_response_fused | Single-pass fused RSD |
Configuration Guide
All radsym algorithms are controlled through plain structs with public fields and sensible defaults. This page collects every config struct, organized by pipeline stage.
Proposal stage
FrstConfig
| Field | Type | Default | Description |
|---|---|---|---|
radii | Vec<u32> | [3, 5, 7, 9, 11] | Discrete radii to test (pixels) |
alpha | f32 | 2.0 | Radial strictness exponent |
gradient_threshold | f32 | 0.0 | Minimum gradient magnitude for voting |
polarity | Polarity | Both | Target polarity (Bright, Dark, Both) |
smoothing_factor | f32 | 0.5 | Gaussian sigma = factor * radius |
RsdConfig
| Field | Type | Default | Description |
|---|---|---|---|
radii | Vec<u32> | [3, 5, 7, 9, 11] | Discrete radii to test (pixels) |
gradient_threshold | f32 | 0.0 | Minimum gradient magnitude for voting |
polarity | Polarity | Both | Target polarity |
smoothing_factor | f32 | 0.5 | Gaussian sigma = factor * radius |
NmsConfig
| Field | Type | Default | Description |
|---|---|---|---|
radius | usize | 5 | Suppression half-window size (pixels) |
threshold | f32 | 0.0 | Minimum response to consider |
max_detections | usize | 1000 | Budget cap on returned peaks |
HomographyRerankConfig
| Field | Type | Default | Description |
|---|---|---|---|
min_radius | f32 | 6.0 | Minimum image-space radius to evaluate |
max_radius | f32 | 0.0 | Maximum radius (0.0 = auto) |
radius_step | f32 | 2.0 | Coarse radius sweep step (pixels) |
ray_count | usize | 64 | Image-space rays for edge acquisition |
radial_search_inner | f32 | 0.6 | Inner factor for radial search |
radial_search_outer | f32 | 1.45 | Outer factor for radial search |
size_prior_sigma | f32 | 0.22 | Gaussian size prior width |
center_prior_sigma_fraction | f32 | 0.45 | Center prior sigma as fraction of image size |
Scoring stage
AnnulusSamplingConfig
| Field | Type | Default | Description |
|---|---|---|---|
num_angular_samples | usize | 64 | Angular sample count around annulus |
num_radial_samples | usize | 9 | Radial sample count across annulus width |
ScoringConfig
| Field | Type | Default | Description |
|---|---|---|---|
sampling | AnnulusSamplingConfig | (see above) | Annulus sampling parameters |
annulus_margin | f32 | 0.3 | Fractional width around hypothesized radius |
min_samples | usize | 8 | Minimum samples to avoid degeneracy |
weight_ringness | f32 | 0.6 | Weight for gradient alignment component |
weight_coverage | f32 | 0.4 | Weight for angular coverage component |
Refinement stage
RadialCenterConfig
| Field | Type | Default | Description |
|---|---|---|---|
patch_radius | usize | 12 | Half-width of the analysis patch (pixels) |
gradient_threshold | f32 | 1e-4 | Minimum gradient magnitude for fit |
CircleRefineConfig
| Field | Type | Default | Description |
|---|---|---|---|
max_iterations | usize | 10 | Maximum refinement iterations |
convergence_tol | f32 | 0.1 | Stop when center shift < this (pixels) |
annulus_margin | f32 | 0.3 | Annulus width as fraction of radius |
radial_center | RadialCenterConfig | (see above) | Sub-step center config |
sampling | AnnulusSamplingConfig | (see above) | Sub-step sampling config |
EllipseRefineConfig
| Field | Type | Default | Description |
|---|---|---|---|
max_iterations | usize | 5 | Maximum refinement iterations |
convergence_tol | f32 | 0.1 | Convergence threshold (pixels) |
annulus_margin | f32 | 0.3 | Annulus margin for diagnostics |
radial_center | RadialCenterConfig | (see above) | Seed stabilization config |
sampling | AnnulusSamplingConfig | (see above) | Legacy sampling config |
min_alignment | f32 | 0.3 | Minimum alignment for diagnostics |
ray_count | usize | 96 | Angular sectors for edge acquisition |
radial_search_inner | f32 | 0.6 | Inner radius factor for initial search |
radial_search_outer | f32 | 1.45 | Outer radius factor for initial search |
normal_search_half_width | f32 | 6.0 | Normal search window (pixels) |
min_inlier_coverage | f32 | 0.6 | Minimum sector coverage of inliers |
max_center_shift_fraction | f32 | 0.4 | Max center shift / seed radius |
max_axis_ratio | f32 | 1.8 | Maximum allowed a/b ratio |
HomographyEllipseRefineConfig
| Field | Type | Default | Description |
|---|---|---|---|
max_iterations | usize | 5 | Maximum refinement iterations |
convergence_tol | f32 | 0.1 | Convergence threshold (rectified pixels) |
radial_center | RadialCenterConfig | (see above) | Image-space seed stabilization |
ray_count | usize | 96 | Image-space rays for edge acquisition |
radial_search_inner | f32 | 0.6 | Inner radius factor |
radial_search_outer | f32 | 1.45 | Outer radius factor |
normal_search_half_width | f32 | 6.0 | Normal search window (pixels) |
min_inlier_coverage | f32 | 0.45 | Minimum rectified angular coverage |
max_center_shift_fraction | f32 | 0.4 | Max center shift / rectified radius |
max_radius_change_fraction | f32 | 0.6 | Max radius change / rectified radius |
Tuning tips
Small targets (radius < 10 px):
Use tight radii in FrstConfig (e.g., [3, 5, 7]), reduce patch_radius in
RadialCenterConfig, and lower normal_search_half_width in EllipseRefineConfig.
Noisy images:
Increase gradient_threshold in FrstConfig/RsdConfig to suppress votes from
noise. Increase smoothing_factor for broader peaks that survive noise.
High-precision localization:
Increase ray_count in EllipseRefineConfig for denser angular sampling.
Decrease convergence_tol to force more refinement iterations. Use a larger
patch_radius in RadialCenterConfig.
Real-time budgets:
Use RsdConfig instead of FrstConfig for faster proposal generation. Set
max_detections in NmsConfig to limit downstream work. Reduce ray_count
and max_iterations in refinement configs.
Strong perspective distortion:
Use the homography-aware path (HomographyEllipseRefineConfig) when a
calibrated homography is available. Otherwise, increase max_axis_ratio in
EllipseRefineConfig to permit more eccentric ellipses.
Bibliography
-
Loy, G. and Zelinsky, A. “Fast radial symmetry for detecting points of interest.” IEEE Transactions on Pattern Analysis and Machine Intelligence 25(8), 959–973 (2003). doi:10.1109/TPAMI.2003.1217601
-
Loy, G. and Zelinsky, A. “A fast radial symmetry transform for detecting points of interest.” European Conference on Computer Vision (ECCV), 358–368 (2002). doi:10.1007/3-540-47969-4_24
-
Barnes, N., Zelinsky, A., Fletcher, L.S. “Real-time radial symmetry for speed sign detection.” IEEE Intelligent Vehicles Symposium, 566–571 (2008). doi:10.1109/IVS.2008.4621217
-
Parthasarathy, R. “Rapid, accurate particle tracking by calculation of radial symmetry centers.” Nature Methods 9, 724–726 (2012). doi:10.1038/nmeth.2071
-
Kasa, I. “A circle fitting procedure and its error analysis.” IEEE Transactions on Instrumentation and Measurement 25(1), 8–14 (1976). doi:10.1109/TIM.1976.6312298
-
Fitzgibbon, A., Pilu, M., Fisher, R. “Direct least squares fitting of ellipses.” IEEE Transactions on Pattern Analysis and Machine Intelligence 21(5), 476–480 (1999). doi:10.1109/34.765658
-
Ni, K., Singh, M., Bahlmann, C. “Fast radial symmetry detection under affine transformations.” IEEE Conference on Computer Vision and Pattern Recognition (CVPR), 932–939 (2012). doi:10.1109/CVPR.2012.6247767
-
Wells, W.M. “Efficient synthesis of Gaussian filters by cascaded uniform filters.” IEEE Transactions on Pattern Analysis and Machine Intelligence 8(2), 234–239 (1986). doi:10.1109/TPAMI.1986.4767776
API Documentation (rustdoc)
<!-- Custom HTML head -->
<meta name="description" content="Algorithms and API guide for the radsym Rust library">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="../../favicon.svg">
<link rel="shortcut icon" href="../../favicon.png">
<link rel="stylesheet" href="../../css/variables.css">
<link rel="stylesheet" href="../../css/general.css">
<link rel="stylesheet" href="../../css/chrome.css">
<link rel="stylesheet" href="../../css/print.css" media="print">
<!-- Fonts -->
<link rel="stylesheet" href="../../FontAwesome/css/font-awesome.css">
<link rel="stylesheet" href="../../fonts/fonts.css">
<!-- Highlight.js Stylesheets -->
<link rel="stylesheet" id="highlight-css" href="../../highlight.css">
<link rel="stylesheet" id="tomorrow-night-css" href="../../tomorrow-night.css">
<link rel="stylesheet" id="ayu-highlight-css" href="../../ayu-highlight.css">
<!-- Custom theme stylesheets -->
<!-- Provide site root and default themes to javascript -->
<script>
const path_to_root = "../../";
const default_light_theme = "light";
const default_dark_theme = "navy";
window.path_to_searchindex_js = "../../searchindex.js";
</script>
<!-- Start loading toc.js asap -->
<script src="../../toc.js"></script>
</head>
<body>
<div id="mdbook-help-container">
<div id="mdbook-help-popup">
<h2 class="mdbook-help-title">Keyboard shortcuts</h2>
<div>
<p>Press <kbd>←</kbd> or <kbd>→</kbd> to navigate between chapters</p>
<p>Press <kbd>S</kbd> or <kbd>/</kbd> to search in the book</p>
<p>Press <kbd>?</kbd> to show this help</p>
<p>Press <kbd>Esc</kbd> to hide this help</p>
</div>
</div>
</div>
<div id="body-container">
<!-- Work around some values being stored in localStorage wrapped in quotes -->
<script>
try {
let theme = localStorage.getItem('mdbook-theme');
let sidebar = localStorage.getItem('mdbook-sidebar');
if (theme.startsWith('"') && theme.endsWith('"')) {
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
}
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
}
} catch (e) { }
</script>
<!-- Set the theme before any content is loaded, prevents flash -->
<script>
const default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? default_dark_theme : default_light_theme;
let theme;
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
if (theme === null || theme === undefined) { theme = default_theme; }
const html = document.documentElement;
html.classList.remove('light')
html.classList.add(theme);
html.classList.add("js");
</script>
<input type="checkbox" id="sidebar-toggle-anchor" class="hidden">
<!-- Hide / unhide sidebar before it is displayed -->
<script>
let sidebar = null;
const sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
if (document.body.clientWidth >= 1080) {
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
sidebar = sidebar || 'visible';
} else {
sidebar = 'hidden';
sidebar_toggle.checked = false;
}
if (sidebar === 'visible') {
sidebar_toggle.checked = true;
} else {
html.classList.remove('sidebar-visible');
}
</script>
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
<!-- populated by js -->
<mdbook-sidebar-scrollbox class="sidebar-scrollbox"></mdbook-sidebar-scrollbox>
<noscript>
<iframe class="sidebar-iframe-outer" src="../../toc.html"></iframe>
</noscript>
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
<div class="sidebar-resize-indicator"></div>
</div>
</nav>
<div id="page-wrapper" class="page-wrapper">
<div class="page">
<div id="menu-bar-hover-placeholder"></div>
<div id="menu-bar" class="menu-bar sticky">
<div class="left-buttons">
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
<i class="fa fa-bars"></i>
</label>
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
<i class="fa fa-paint-brush"></i>
</button>
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
<li role="none"><button role="menuitem" class="theme" id="default_theme">Auto</button></li>
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
</ul>
<button id="search-toggle" class="icon-button" type="button" title="Search (`/`)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="/ s" aria-controls="searchbar">
<i class="fa fa-search"></i>
</button>
</div>
<h1 class="menu-title">radsym: Radial Symmetry Detection</h1>
<div class="right-buttons">
<a href="../../print.html" title="Print this book" aria-label="Print this book">
<i id="print-button" class="fa fa-print"></i>
</a>
<a href="https://github.com/VitalyVorobyev/radsym" title="Git repository" aria-label="Git repository">
<i id="git-repository-button" class="fa fa-github"></i>
</a>
<a href="https://github.com/VitalyVorobyev/radsym/edit/main/book/src/../api/radsym/index.html" title="Suggest an edit" aria-label="Suggest an edit" rel="edit">
<i id="git-edit-button" class="fa fa-edit"></i>
</a>
</div>
</div>
<div id="search-wrapper" class="hidden">
<form id="searchbar-outer" class="searchbar-outer">
<div class="search-wrapper">
<input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header">
<div class="spinner-wrapper">
<i class="fa fa-spinner fa-spin"></i>
</div>
</div>
</form>
<div id="searchresults-outer" class="searchresults-outer hidden">
<div id="searchresults-header" class="searchresults-header"></div>
<ul id="searchresults">
</ul>
</div>
</div>
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
<script>
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) {
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
});
</script>
<div id="content" class="content">
<main>
<!DOCTYPE HTML>
<!-- Custom HTML head -->
<meta name="description" content="Algorithms and API guide for the radsym Rust library">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="../../favicon.svg">
<link rel="shortcut icon" href="../../favicon.png">
<link rel="stylesheet" href="../../css/variables.css">
<link rel="stylesheet" href="../../css/general.css">
<link rel="stylesheet" href="../../css/chrome.css">
<link rel="stylesheet" href="../../css/print.css" media="print">
<!-- Fonts -->
<link rel="stylesheet" href="../../FontAwesome/css/font-awesome.css">
<link rel="stylesheet" href="../../fonts/fonts.css">
<!-- Highlight.js Stylesheets -->
<link rel="stylesheet" id="highlight-css" href="../../highlight.css">
<link rel="stylesheet" id="tomorrow-night-css" href="../../tomorrow-night.css">
<link rel="stylesheet" id="ayu-highlight-css" href="../../ayu-highlight.css">
<!-- Custom theme stylesheets -->
<!-- Provide site root and default themes to javascript -->
<script>
const path_to_root = "../../";
const default_light_theme = "light";
const default_dark_theme = "navy";
window.path_to_searchindex_js = "../../searchindex.js";
</script>
<!-- Start loading toc.js asap -->
<script src="../../toc.js"></script>
</head>
<body>
<div id=“mdbook-help-container”>
<div id=“mdbook-help-popup”>
<h2 class=“mdbook-help-title”>Keyboard shortcuts</h2>
<div>
<p>Press <kbd>←</kbd> or <kbd>→</kbd> to navigate between chapters</p>
<p>Press <kbd>S</kbd> or <kbd>/</kbd> to search in the book</p>
<p>Press <kbd>?</kbd> to show this help</p>
<p>Press <kbd>Esc</kbd> to hide this help</p>
</div>
</div>
</div>
<div id=“body-container”>
<!– Work around some values being stored in localStorage wrapped in quotes –>
<script>
try {
let theme = localStorage.getItem(‘mdbook-theme’);
let sidebar = localStorage.getItem(‘mdbook-sidebar’);
if (theme.startsWith('"') && theme.endsWith('"')) {
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
}
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
}
} catch (e) { }
</script>
<!-- Set the theme before any content is loaded, prevents flash -->
<script>
const default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? default_dark_theme : default_light_theme;
let theme;
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
if (theme === null || theme === undefined) { theme = default_theme; }
const html = document.documentElement;
html.classList.remove('light')
html.classList.add(theme);
html.classList.add("js");
</script>
<input type="checkbox" id="sidebar-toggle-anchor" class="hidden">
<!-- Hide / unhide sidebar before it is displayed -->
<script>
let sidebar = null;
const sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
if (document.body.clientWidth >= 1080) {
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
sidebar = sidebar || 'visible';
} else {
sidebar = 'hidden';
sidebar_toggle.checked = false;
}
if (sidebar === 'visible') {
sidebar_toggle.checked = true;
} else {
html.classList.remove('sidebar-visible');
}
</script>
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
<!-- populated by js -->
<mdbook-sidebar-scrollbox class="sidebar-scrollbox"></mdbook-sidebar-scrollbox>
<noscript>
<iframe class="sidebar-iframe-outer" src="../../toc.html"></iframe>
</noscript>
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
<div class="sidebar-resize-indicator"></div>
</div>
</nav>
<div id="page-wrapper" class="page-wrapper">
<div class="page">
<div id="menu-bar-hover-placeholder"></div>
<div id="menu-bar" class="menu-bar sticky">
<div class="left-buttons">
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
<i class="fa fa-bars"></i>
</label>
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
<i class="fa fa-paint-brush"></i>
</button>
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
<li role="none"><button role="menuitem" class="theme" id="default_theme">Auto</button></li>
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
</ul>
<button id="search-toggle" class="icon-button" type="button" title="Search (`/`)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="/ s" aria-controls="searchbar">
<i class="fa fa-search"></i>
</button>
</div>
<h1 class="menu-title">radsym: Radial Symmetry Detection</h1>
<div class="right-buttons">
<a href="../../print.html" title="Print this book" aria-label="Print this book">
<i id="print-button" class="fa fa-print"></i>
</a>
<a href="https://github.com/VitalyVorobyev/radsym" title="Git repository" aria-label="Git repository">
<i id="git-repository-button" class="fa fa-github"></i>
</a>
<a href="https://github.com/VitalyVorobyev/radsym/edit/main/book/src/../api/radsym/index.html" title="Suggest an edit" aria-label="Suggest an edit" rel="edit">
<i id="git-edit-button" class="fa fa-edit"></i>
</a>
</div>
</div>
<div id="search-wrapper" class="hidden">
<form id="searchbar-outer" class="searchbar-outer">
<div class="search-wrapper">
<input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header">
<div class="spinner-wrapper">
<i class="fa fa-spinner fa-spin"></i>
</div>
</div>
</form>
<div id="searchresults-outer" class="searchresults-outer hidden">
<div id="searchresults-header" class="searchresults-header"></div>
<ul id="searchresults">
</ul>
</div>
</div>
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
<script>
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) {
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
});
</script>
<div id="content" class="content">
<main>
<h1 id="api-documentation-rustdoc"><a class="header" href="#api-documentation-rustdoc">API Documentation (rustdoc)</a></h1>
</main>
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
<a rel="prev" href="../../references.html" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
<div style="clear: both"></div>
</nav>
</div>
</div>
<nav class="nav-wide-wrapper" aria-label="Page navigation">
<a rel="prev" href="../../references.html" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
</nav>
</div>
<script>
window.playground_copyable = true;
</script>
<script src="../../elasticlunr.min.js"></script>
<script src="../../mark.min.js"></script>
<script src="../../searcher.js"></script>
<script src="../../clipboard.min.js"></script>
<script src="../../highlight.js"></script>
<script src="../../book.js"></script>
<!-- Custom JS scripts -->
</div>
</body>
</main>
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
<a rel="prev" href="../../references.html" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
<div style="clear: both"></div>
</nav>
</div>
</div>
<nav class="nav-wide-wrapper" aria-label="Page navigation">
<a rel="prev" href="../../references.html" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
</nav>
</div>
<script>
window.playground_copyable = true;
</script>
<script src="../../elasticlunr.min.js"></script>
<script src="../../mark.min.js"></script>
<script src="../../searcher.js"></script>
<script src="../../clipboard.min.js"></script>
<script src="../../highlight.js"></script>
<script src="../../book.js"></script>
<!-- Custom JS scripts -->
</div>
</body>