Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 rayon feature 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.
  • f32 precision. 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 from f64.

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:

  1. 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.
  2. Score. Each proposal is evaluated by sampling gradients in an annulus around the hypothesized circle or ellipse. The resulting SupportScore combines a ringness metric (gradient alignment) with angular coverage (how much of the circumference has evidence).
  3. 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

FlagWhat it enables
rayonParallel multi-radius FRST voting and batch scoring via Rayon
image-ioload_grayscale file I/O and save_diagnostic export via the image crate
tracingStructured log spans and events via the tracing crate
affineExperimental affine-aware extensions (GFRS, Ni et al. CVPR 2012)
serdeSerialize / 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

InputOutput
GradientFieldResponseMap
ResponseMap + NmsConfig + PolarityVec<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

InputOutput
GradientField + Circle + ScoringConfigSupportScore
GradientField + Ellipse + ScoringConfigSupportScore

Stage 3: Refine

Goal. Improve center position and shape parameters to subpixel accuracy.

Refinement operates in two layers:

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

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

InputOutput
GradientField + Circle + CircleRefineConfigRefinementResult<Circle>
GradientField + Ellipse + EllipseRefineConfigRefinementResult<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 Circle or Ellipse.
  • 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_homography runs 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_homography fits 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:

  1. 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).$$

  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:

ModeVotes at $p_{+\text{ve}}$Votes at $p_{-\text{ve}}$Detects
Brightyesnobright centers (gradient points inward)
Darknoyesdark centers (gradient points outward)
Bothyesyeseither polarity

Implementation notes

  • Gradient thresholding: pixels with $|\mathbf{g}| < t$ (the gradient_threshold parameter) are skipped entirely. This eliminates votes from flat regions and sensor noise, reducing computation and background clutter.
  • Rayon parallelism: when the rayon feature 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,
}
}
FieldEffect of increasing
alphaSharper discrimination, weaker response on imperfect targets
gradient_thresholdFewer votes, less noise, possible loss of weak edges
smoothing_factorBroader 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

VariantGradientGridFunction
ReferenceRoberts crosshalf-pixel $(i+0.5,, j+0.5)$radial_center_refine
ProductionPrecomputed Sobelinteger pixelradial_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::Degenerate and 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 f64 to avoid catastrophic cancellation, then the final center is cast back to f32.

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:

ParameterDescription
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:

  1. Edge detection along normals. The angular range $[0, 2\pi)$ is divided into ray_count sectors. 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 within normal_search_half_width pixels of the predicted boundary is accepted as an edge observation. Bilinear interpolation provides sub-pixel gradient sampling.

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

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

  4. Convergence. The loop terminates early when the center shift and axis changes between successive iterations fall below convergence_tol (default 0.1 pixels).

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
}
}
FieldEffect of increasing
ray_countDenser angular sampling, better coverage but slower
normal_search_half_widthWider search window, tolerates larger initial error
max_axis_ratioPermits more eccentric ellipses
max_center_shift_fractionAllows 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}$:

  1. At each pixel $p$ with gradient direction $\hat{\mathbf{g}}(p)$, compute the warped direction $A_k, \hat{\mathbf{g}}(p)$.
  2. Compute the affected pixel offset using the warped direction and vote into a per-map accumulator.
  3. 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:

  1. For each pixel in the rectified grid, compute the corresponding image coordinate via $H^{-1}$ and sample the image gradient using bilinear interpolation.
  2. Transform the gradient direction into the rectified frame using the local Jacobian of $H$.
  3. Vote into the rectified accumulator as in standard FRST.
  4. 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:

  1. Starting from the initial rectified circle, cast rays outward from the image-space ellipse along its normals.
  2. Detect edge observations via gradient peak search (same mechanism as ellipse refinement).
  3. Transform each image-space edge point into rectified coordinates via $H$.
  4. 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$).
  5. 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_samples evenly spaced angles in $[0, 2\pi)$.
  • Radial axis: num_radial_samples linearly 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 an Ellipse and 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,
}
}
FieldEffect of increasing
annulus_marginWider sampling band, more tolerant of radius error
min_samplesStricter degeneracy threshold
weight_ringnessFavors gradient alignment over coverage
weight_coverageFavors 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 an ImageView<u8> (8-bit grayscale).
  • sobel_gradient_f32 – accepts an ImageView<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:

  1. Check if the pixel value exceeds threshold. Skip if not.
  2. 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.
  3. Collect all qualifying peaks and sort by descending score.
  4. Truncate to max_detections entries.

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,
}
}
FieldEffect of increasing
radiusFewer, more spread-out peaks
thresholdEliminates weak candidates early
max_detectionsHigher 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

PanelDescription
OriginalSource image (default: ringgrid.png calibration target)
Response HeatmapResponse map from the selected algorithm, colorized
Seed ProposalsNMS-extracted proposal locations (crosses)
Detected CirclesFull pipeline output with overlay annotations

Available algorithms

AlgorithmMethodDescription
FRSTfrst_responseMulti-radius voting with orientation accumulator
FRST (fused)multiradius_responseSingle-pass fused variant, faster
RSDrsd_responseMagnitude-only voting, ~2x faster than FRST
RSD (fused)rsd_response_fusedSingle-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

FieldTypeDefaultDescription
radiiVec<u32>[3, 5, 7, 9, 11]Discrete radii to test (pixels)
alphaf322.0Radial strictness exponent
gradient_thresholdf320.0Minimum gradient magnitude for voting
polarityPolarityBothTarget polarity (Bright, Dark, Both)
smoothing_factorf320.5Gaussian sigma = factor * radius

RsdConfig

FieldTypeDefaultDescription
radiiVec<u32>[3, 5, 7, 9, 11]Discrete radii to test (pixels)
gradient_thresholdf320.0Minimum gradient magnitude for voting
polarityPolarityBothTarget polarity
smoothing_factorf320.5Gaussian sigma = factor * radius

NmsConfig

FieldTypeDefaultDescription
radiususize5Suppression half-window size (pixels)
thresholdf320.0Minimum response to consider
max_detectionsusize1000Budget cap on returned peaks

HomographyRerankConfig

FieldTypeDefaultDescription
min_radiusf326.0Minimum image-space radius to evaluate
max_radiusf320.0Maximum radius (0.0 = auto)
radius_stepf322.0Coarse radius sweep step (pixels)
ray_countusize64Image-space rays for edge acquisition
radial_search_innerf320.6Inner factor for radial search
radial_search_outerf321.45Outer factor for radial search
size_prior_sigmaf320.22Gaussian size prior width
center_prior_sigma_fractionf320.45Center prior sigma as fraction of image size

Scoring stage

AnnulusSamplingConfig

FieldTypeDefaultDescription
num_angular_samplesusize64Angular sample count around annulus
num_radial_samplesusize9Radial sample count across annulus width

ScoringConfig

FieldTypeDefaultDescription
samplingAnnulusSamplingConfig(see above)Annulus sampling parameters
annulus_marginf320.3Fractional width around hypothesized radius
min_samplesusize8Minimum samples to avoid degeneracy
weight_ringnessf320.6Weight for gradient alignment component
weight_coveragef320.4Weight for angular coverage component

Refinement stage

RadialCenterConfig

FieldTypeDefaultDescription
patch_radiususize12Half-width of the analysis patch (pixels)
gradient_thresholdf321e-4Minimum gradient magnitude for fit

CircleRefineConfig

FieldTypeDefaultDescription
max_iterationsusize10Maximum refinement iterations
convergence_tolf320.1Stop when center shift < this (pixels)
annulus_marginf320.3Annulus width as fraction of radius
radial_centerRadialCenterConfig(see above)Sub-step center config
samplingAnnulusSamplingConfig(see above)Sub-step sampling config

EllipseRefineConfig

FieldTypeDefaultDescription
max_iterationsusize5Maximum refinement iterations
convergence_tolf320.1Convergence threshold (pixels)
annulus_marginf320.3Annulus margin for diagnostics
radial_centerRadialCenterConfig(see above)Seed stabilization config
samplingAnnulusSamplingConfig(see above)Legacy sampling config
min_alignmentf320.3Minimum alignment for diagnostics
ray_countusize96Angular sectors for edge acquisition
radial_search_innerf320.6Inner radius factor for initial search
radial_search_outerf321.45Outer radius factor for initial search
normal_search_half_widthf326.0Normal search window (pixels)
min_inlier_coveragef320.6Minimum sector coverage of inliers
max_center_shift_fractionf320.4Max center shift / seed radius
max_axis_ratiof321.8Maximum allowed a/b ratio

HomographyEllipseRefineConfig

FieldTypeDefaultDescription
max_iterationsusize5Maximum refinement iterations
convergence_tolf320.1Convergence threshold (rectified pixels)
radial_centerRadialCenterConfig(see above)Image-space seed stabilization
ray_countusize96Image-space rays for edge acquisition
radial_search_innerf320.6Inner radius factor
radial_search_outerf321.45Outer radius factor
normal_search_half_widthf326.0Normal search window (pixels)
min_inlier_coveragef320.45Minimum rectified angular coverage
max_center_shift_fractionf320.4Max center shift / rectified radius
max_radius_change_fractionf320.6Max 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

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

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

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

  4. Parthasarathy, R. “Rapid, accurate particle tracking by calculation of radial symmetry centers.” Nature Methods 9, 724–726 (2012). doi:10.1038/nmeth.2071

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

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

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

  8. 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)

API Documentation (rustdoc) - radsym: Radial Symmetry Detection
    <!-- 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>
API Documentation (rustdoc) - radsym: Radial Symmetry Detection
    <!-- Custom HTML head -->
&lt;meta name="description" content="Algorithms and API guide for the radsym Rust library"&gt;
&lt;meta name="viewport" content="width=device-width, initial-scale=1"&gt;
&lt;meta name="theme-color" content="#ffffff"&gt;

&lt;link rel="icon" href="../../favicon.svg"&gt;
&lt;link rel="shortcut icon" href="../../favicon.png"&gt;
&lt;link rel="stylesheet" href="../../css/variables.css"&gt;
&lt;link rel="stylesheet" href="../../css/general.css"&gt;
&lt;link rel="stylesheet" href="../../css/chrome.css"&gt;
&lt;link rel="stylesheet" href="../../css/print.css" media="print"&gt;

&lt;!-- Fonts --&gt;
&lt;link rel="stylesheet" href="../../FontAwesome/css/font-awesome.css"&gt;
&lt;link rel="stylesheet" href="../../fonts/fonts.css"&gt;

&lt;!-- Highlight.js Stylesheets --&gt;
&lt;link rel="stylesheet" id="highlight-css" href="../../highlight.css"&gt;
&lt;link rel="stylesheet" id="tomorrow-night-css" href="../../tomorrow-night.css"&gt;
&lt;link rel="stylesheet" id="ayu-highlight-css" href="../../ayu-highlight.css"&gt;

&lt;!-- Custom theme stylesheets --&gt;


&lt;!-- Provide site root and default themes to javascript --&gt;
&lt;script&gt;
    const path_to_root = "../../";
    const default_light_theme = "light";
    const default_dark_theme = "navy";
    window.path_to_searchindex_js = "../../searchindex.js";
&lt;/script&gt;
&lt;!-- Start loading toc.js asap --&gt;
&lt;script src="../../toc.js"&gt;&lt;/script&gt;

</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('"') &amp;&amp; theme.endsWith('"')) {
            localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
        }

        if (sidebar.startsWith('"') &amp;&amp; sidebar.endsWith('"')) {
            localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
        }
    } catch (e) { }
&lt;/script&gt;

&lt;!-- Set the theme before any content is loaded, prevents flash --&gt;
&lt;script&gt;
    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");
&lt;/script&gt;

&lt;input type="checkbox" id="sidebar-toggle-anchor" class="hidden"&gt;

&lt;!-- Hide / unhide sidebar before it is displayed --&gt;
&lt;script&gt;
    let sidebar = null;
    const sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
    if (document.body.clientWidth &gt;= 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');
    }
&lt;/script&gt;

&lt;nav id="sidebar" class="sidebar" aria-label="Table of contents"&gt;
    &lt;!-- populated by js --&gt;
    &lt;mdbook-sidebar-scrollbox class="sidebar-scrollbox"&gt;&lt;/mdbook-sidebar-scrollbox&gt;
    &lt;noscript&gt;
        &lt;iframe class="sidebar-iframe-outer" src="../../toc.html"&gt;&lt;/iframe&gt;
    &lt;/noscript&gt;
    &lt;div id="sidebar-resize-handle" class="sidebar-resize-handle"&gt;
        &lt;div class="sidebar-resize-indicator"&gt;&lt;/div&gt;
    &lt;/div&gt;
&lt;/nav&gt;

&lt;div id="page-wrapper" class="page-wrapper"&gt;

    &lt;div class="page"&gt;
        &lt;div id="menu-bar-hover-placeholder"&gt;&lt;/div&gt;
        &lt;div id="menu-bar" class="menu-bar sticky"&gt;
            &lt;div class="left-buttons"&gt;
                &lt;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"&gt;
                    &lt;i class="fa fa-bars"&gt;&lt;/i&gt;
                &lt;/label&gt;
                &lt;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"&gt;
                    &lt;i class="fa fa-paint-brush"&gt;&lt;/i&gt;
                &lt;/button&gt;
                &lt;ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu"&gt;
                    &lt;li role="none"&gt;&lt;button role="menuitem" class="theme" id="default_theme"&gt;Auto&lt;/button&gt;&lt;/li&gt;
                    &lt;li role="none"&gt;&lt;button role="menuitem" class="theme" id="light"&gt;Light&lt;/button&gt;&lt;/li&gt;
                    &lt;li role="none"&gt;&lt;button role="menuitem" class="theme" id="rust"&gt;Rust&lt;/button&gt;&lt;/li&gt;
                    &lt;li role="none"&gt;&lt;button role="menuitem" class="theme" id="coal"&gt;Coal&lt;/button&gt;&lt;/li&gt;
                    &lt;li role="none"&gt;&lt;button role="menuitem" class="theme" id="navy"&gt;Navy&lt;/button&gt;&lt;/li&gt;
                    &lt;li role="none"&gt;&lt;button role="menuitem" class="theme" id="ayu"&gt;Ayu&lt;/button&gt;&lt;/li&gt;
                &lt;/ul&gt;
                &lt;button id="search-toggle" class="icon-button" type="button" title="Search (`/`)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="/ s" aria-controls="searchbar"&gt;
                    &lt;i class="fa fa-search"&gt;&lt;/i&gt;
                &lt;/button&gt;
            &lt;/div&gt;

            &lt;h1 class="menu-title"&gt;radsym: Radial Symmetry Detection&lt;/h1&gt;

            &lt;div class="right-buttons"&gt;
                &lt;a href="../../print.html" title="Print this book" aria-label="Print this book"&gt;
                    &lt;i id="print-button" class="fa fa-print"&gt;&lt;/i&gt;
                &lt;/a&gt;
                &lt;a href="https://github.com/VitalyVorobyev/radsym" title="Git repository" aria-label="Git repository"&gt;
                    &lt;i id="git-repository-button" class="fa fa-github"&gt;&lt;/i&gt;
                &lt;/a&gt;
                &lt;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"&gt;
                    &lt;i id="git-edit-button" class="fa fa-edit"&gt;&lt;/i&gt;
                &lt;/a&gt;

            &lt;/div&gt;
        &lt;/div&gt;

        &lt;div id="search-wrapper" class="hidden"&gt;
            &lt;form id="searchbar-outer" class="searchbar-outer"&gt;
                &lt;div class="search-wrapper"&gt;
                    &lt;input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header"&gt;
                    &lt;div class="spinner-wrapper"&gt;
                        &lt;i class="fa fa-spinner fa-spin"&gt;&lt;/i&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
            &lt;/form&gt;
            &lt;div id="searchresults-outer" class="searchresults-outer hidden"&gt;
                &lt;div id="searchresults-header" class="searchresults-header"&gt;&lt;/div&gt;
                &lt;ul id="searchresults"&gt;
                &lt;/ul&gt;
            &lt;/div&gt;
        &lt;/div&gt;

        &lt;!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM --&gt;
        &lt;script&gt;
            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);
            });
        &lt;/script&gt;

        &lt;div id="content" class="content"&gt;
            &lt;main&gt;
                &lt;h1 id="api-documentation-rustdoc"&gt;&lt;a class="header" href="#api-documentation-rustdoc"&gt;API Documentation (rustdoc)&lt;/a&gt;&lt;/h1&gt;

            &lt;/main&gt;

            &lt;nav class="nav-wrapper" aria-label="Page navigation"&gt;
                &lt;!-- Mobile navigation buttons --&gt;
                    &lt;a rel="prev" href="../../references.html" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left"&gt;
                        &lt;i class="fa fa-angle-left"&gt;&lt;/i&gt;
                    &lt;/a&gt;


                &lt;div style="clear: both"&gt;&lt;/div&gt;
            &lt;/nav&gt;
        &lt;/div&gt;
    &lt;/div&gt;

    &lt;nav class="nav-wide-wrapper" aria-label="Page navigation"&gt;
            &lt;a rel="prev" href="../../references.html" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left"&gt;
                &lt;i class="fa fa-angle-left"&gt;&lt;/i&gt;
            &lt;/a&gt;

    &lt;/nav&gt;

&lt;/div&gt;




&lt;script&gt;
    window.playground_copyable = true;
&lt;/script&gt;


&lt;script src="../../elasticlunr.min.js"&gt;&lt;/script&gt;
&lt;script src="../../mark.min.js"&gt;&lt;/script&gt;
&lt;script src="../../searcher.js"&gt;&lt;/script&gt;

&lt;script src="../../clipboard.min.js"&gt;&lt;/script&gt;
&lt;script src="../../highlight.js"&gt;&lt;/script&gt;
&lt;script src="../../book.js"&gt;&lt;/script&gt;

&lt;!-- Custom JS scripts --&gt;

</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>