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

Part II: Using the library

This chapter is a walk through the public API on every binding target. Code-first; algorithms are covered in Part III (ChESS), Part IV (Radon), and Part V (refiners).

2.1 Configuration shape

DetectorConfig has one place for every knob. Cross-cutting fields sit at the top level; detector-specific fields are nested inside the active strategy variant.

Top-level fieldType
strategyDetectionStrategy::Chess(ChessConfig) or DetectionStrategy::Radon(RadonConfig) — selects the detector and carries its tuning.
thresholdA single f32. ChESS reads it as an absolute floor on the raw response (default 30); Radon reads it as a fraction in [0, 1] of the per-frame maximum (default 0.28). See Part III §3.3.1 and Part IV §4.4.
detectionDetectionParams { nms_radius, min_cluster_size } — shared NMS and cluster-filter knobs honoured by both detectors.
multiscaleMultiscaleConfig::SingleScale or MultiscaleConfig::Pyramid { levels, min_size, refinement_radius }. Honoured by both detectors.
upscaleUpscaleConfig::Disabled or UpscaleConfig::Fixed(factor) (factor ∈ {2, 3, 4}). Pre-pipeline bilinear upscaling for low-resolution inputs.
orientation_methodOrientationMethod::RingFit (default) or DiskFit. Drives the two-axis descriptor fit on both detectors; without_orientation() skips the fit and leaves each descriptor’s axes as None.
merge_radiusDuplicate-suppression distance (base-image pixels) for the final cross-scale merge step.

Inside DetectionParams (set via DetectorConfig.detection):

FieldMeaning
nms_radiusNon-maximum-suppression window half-radius, in input-image pixels. ChESS preset default: 2; Radon preset default: 4.
min_cluster_sizeMinimum positive-response neighbours inside the NMS window. Default: 2 for both presets.

Inside ChessConfig:

FieldMeaning
ringChessRing::Canonical (r=5, paper default) or ChessRing::Broad (r=10, wider support window). Descriptors always sample at this same radius.
refinerChessRefiner::CenterOfMass(_), Forstner(_), SaddlePoint(_), or Ml (with ml-refiner). Each variant carries its own tuning struct.

Inside RadonConfig:

FieldMeaning
ray_radiusHalf-length of each ray (working-resolution pixels). Paper default at image_upsample = 2 is 4.
image_upsample1 (no supersample) or 2 (paper default). Values ≥ 3 are clamped to 2.
response_blur_radiusHalf-size of the box blur applied to the response map. 0 disables blurring.
peak_fitPeakFitMode::Parabolic or Gaussian for the 3-point subpixel refinement.

Four presets cover the common cases:

PresetDetectorScale
DetectorConfig::chess()ChESSSingle-scale
DetectorConfig::chess_multiscale()ChESS3-level pyramid
DetectorConfig::radon()RadonSingle-scale
DetectorConfig::radon_multiscale()Radon3-level pyramid

Three guarantees follow from this shape:

  1. One place per knob. cfg.strategy.chess.ring = ChessRing::Broad is the only way to request the wider ChESS sampling ring. There is no parallel top-level “broad” flag.
  2. Per-detector refiners. ChessRefiner lists the refiners that operate on ChESS output (CenterOfMass, Forstner, SaddlePoint, Ml). The Radon detector does not have a pluggable refiner; its subpixel step is the built-in Gaussian peak fit (peak_fit).
  3. Enum-with-payload everywhere a knob has an “on/off + tuning” shape. MultiscaleConfig, UpscaleConfig, and ChessRefiner share the same encoding, so the JSON shape and the binding surface stay symmetric across all of them. (threshold is a plain number — a single scalar carries no tuning, so it needs no tag.)

2.2 Rust

Add the facade crate:

[dependencies]
chess-corners = "1.0"
image = "0.25"          # optional, for GrayImage integration

2.2.1 Single-scale ChESS detection from an image file

use chess_corners::{Detector, DetectorConfig};
use image::io::Reader as ImageReader;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let img = ImageReader::open("board.png")?.decode()?.to_luma8();

    let cfg = DetectorConfig::chess();  // ChESS detector, defaults
    let mut detector = Detector::new(cfg)?;
    let corners = detector.detect(&img)?;

    println!("found {} corners", corners.len());
    Ok(())
}

corners is a Vec<CornerDescriptor> with subpixel positions and per-corner intensity-fit metadata (Part I §1.4).

2.2.2 Radon detector instead of ChESS

#![allow(unused)]
fn main() {
use chess_corners::{Detector, DetectorConfig};

let cfg = DetectorConfig::radon();           // Radon detector, paper defaults
let mut detector = Detector::new(cfg)?;
let corners = detector.detect(&img)?;
}

DetectorConfig::radon() builds a DetectionStrategy::Radon(RadonConfig) with the paper’s published defaults. The output type is the same Vec<CornerDescriptor>.

Try Radon when ChESS’s 16-sample ring does not seed enough corners, especially on the small-cell, blur, and low-contrast cases covered by the repository tests. For throughput, ChESS is faster in the measured fixtures; see Part IV §4.5.

2.2.3 Swapping the subpixel refiner

#![allow(unused)]
fn main() {
use chess_corners::{ChessRefiner, DetectorConfig};

let cfg = DetectorConfig::chess_multiscale()
    .with_chess(|c| c.refiner = ChessRefiner::forstner());
}

Each refiner variant carries its tuning struct inline:

#![allow(unused)]
fn main() {
use chess_corners::{ChessRefiner, ForstnerConfig};

// The per-refiner config structs are `#[non_exhaustive]`: start from
// `Default` and set only the fields you care about.
let mut f = ForstnerConfig::default();
f.max_offset = 2.0;
let refiner = ChessRefiner::Forstner(f);
}

The Radon detector does not have a pluggable refiner; its subpixel step is the built-in Gaussian peak fit, configured via RadonConfig.peak_fit. See Part V.

2.2.4 Raw buffer API

If your pixels come from a camera SDK, FFI, or GPU pipeline, skip the image crate and feed a packed &[u8]:

#![allow(unused)]
fn main() {
use chess_corners::{Detector, DetectorConfig};

fn detect(img: &[u8], width: u32, height: u32) -> Result<(), chess_corners::ChessError> {
    // ChESS reads `threshold` as an absolute floor on the raw response;
    // the default is 30, raised here to suppress more textured-region noise.
    let cfg = DetectorConfig::chess()
        .with_threshold(60.0);

    let mut detector = Detector::new(cfg)?;
    let corners = detector.detect_u8(img, width, height)?;
    println!("found {} corners", corners.len());
    Ok(())
}
}

Requirements:

  • img is width * height bytes, row-major.
  • 0 is black, 255 is white.

If your buffer has a stride or is interleaved RGB, copy the luminance channel to a packed buffer first. The facade does not resample stride; the only supported layout is tightly packed.

2.2.5 Inspecting corners

#![allow(unused)]
fn main() {
for c in &corners {
    match &c.axes {
        Some([a0, a1]) => println!(
            "({:.2}, {:.2})  response={:.1}  axes=({:.2}, {:.2}) rad",
            c.x, c.y, c.response, a0.angle, a1.angle,
        ),
        None => println!(
            "({:.2}, {:.2})  response={:.1}  (orientation skipped)",
            c.x, c.y, c.response,
        ),
    }
}
}

The axes field is Option<[AxisEstimate; 2]>. Some([a0, a1]) gives two directions, both in radians and not assumed orthogonal, with a0.sigma / a1.sigma their 1σ angular uncertainties. It is None when the orientation fit was disabled with without_orientation() (see Part VI §6.5). See Part III §3.4 for the fit and the polarity convention.

2.3 Python

Install from PyPI:

python -m pip install chess-corners
import numpy as np
import chess_corners

img = np.zeros((128, 128), dtype=np.uint8)

cfg = chess_corners.DetectorConfig.chess_multiscale()
cfg.threshold = 60.0  # ChESS: absolute floor on the raw response (default 30)

# Nested getters return the live shared object, so direct attribute
# assignment propagates back to `cfg` — no rebuild needed:
cfg.strategy.chess.refiner = chess_corners.ChessRefiner.forstner()

detector = chess_corners.Detector(cfg)
det = detector.detect(img)
print(det.xy.shape)       # (N, 2) float32
print(det.response.shape) # (N,)  float32

Detector(cfg).detect(image) accepts a 2D uint8 array shaped (H, W) and returns a Detections object with named arrays:

  • det.xy(N, 2) float32, subpixel corner positions (x, y) in input pixels
  • det.response(N,) float32, raw detector response at each peak
  • det.angles(N, 2) float32, [axis0_angle, axis1_angle] in radians [0, π), or None when orientation is disabled
  • det.sigmas(N, 2) float32, 1σ uncertainty per axis in radians, or None when orientation is disabled

When the orientation fit is skipped (cfg.without_orientation()), det.angles and det.sigmas are None.

The Python DetectorConfig mirrors the Rust type field-for-field and supports to_dict(), from_dict(), to_json(), from_json(), pretty(), and print(). The factory methods are chess(), chess_multiscale(), radon(), and radon_multiscale().

cfg.threshold is a plain float — assign it directly. The tagged enum classes follow a common idiom: MultiscaleConfig.single_scale() / .pyramid(...), UpscaleConfig.disabled() / .fixed(k), ChessRefiner.center_of_mass(...) / .forstner(...) / .saddle_point(...) / .ml(). Orientation can be turned off entirely with cfg.without_orientation(), after which det.angles and det.sigmas are None.

Nested getters (cfg.strategy, cfg.strategy.chess, cfg.threshold, cfg.multiscale, …) all return the live shared object held by the parent — direct attribute assignment is enough:

cfg.strategy.chess.refiner = chess_corners.ChessRefiner.forstner()
cfg.strategy.chess.ring = chess_corners.ChessRing.BROAD

For chainable single-expression edits, use the with_chess(**kwargs) / with_radon(**kwargs) builders, which return a new config with only the named fields replaced:

cfg = cfg.with_chess(
    refiner=chess_corners.ChessRefiner.forstner(),
    ring=chess_corners.ChessRing.BROAD,
)

The Radon strategy is selected the same way:

cfg = chess_corners.DetectorConfig.radon()

If the wheel was built with ml-refiner, the ML pipeline is reached through the same Detector(cfg).detect(img) call once the active ChESS refiner is the ml variant:

cfg.strategy.chess.refiner = chess_corners.ChessRefiner.ml()

2.4 JavaScript / WebAssembly

Build the wasm package from source:

wasm-pack build crates/chess-corners-wasm --target web

Or consume the published npm package @vitavision/chess-corners. Usage from a web app:

import init, {
  ChessDetector,
  ChessConfig,
  ChessRefiner,
  ChessRing,
  DetectionStrategy,
  DetectorConfig,
  ForstnerConfig,
  MultiscaleConfig,
} from '@vitavision/chess-corners';

await init();

// Build a typed configuration tree.
const cfg = DetectorConfig.chessMultiscale();
cfg.threshold = 60.0; // ChESS: absolute floor on the raw response (default 30)
cfg.multiscale = MultiscaleConfig.pyramid(3, 128, 3); // levels, minSize, refinementRadius

const chess = new ChessConfig();
chess.ring = ChessRing.Broad;
chess.refiner = ChessRefiner.withForstner(new ForstnerConfig());
cfg.strategy = DetectionStrategy.fromChess(chess);

const detector = ChessDetector.withConfig(cfg);

// From a canvas (webcam frame, loaded image, etc.)
const imageData = ctx.getImageData(0, 0, width, height);
const corners = detector.detect_rgba(imageData.data, width, height);

// corners is Float32Array, stride 7 per corner — same layout as Python.
// (After cfg.withoutOrientation(), the four axis values are NaN per corner.)
for (let i = 0; i < corners.length; i += 7) {
    console.log(`(${corners[i].toFixed(2)}, ${corners[i + 1].toFixed(2)})`);
}

// Raw response map, for heatmap visualisation (opt-in diagnostic).
const response = detector.diagnostics_response_rgba(imageData.data, width, height);

ChessDetector reads and writes its full configuration through the typed tree — detector.getConfig() returns an independent DetectorConfig snapshot and detector.applyConfig(cfg) commits edits. The factory functions on the tagged classes follow the same with* idiom: ChessRefiner.withForstner(cfg), ChessRefiner.withCenterOfMass(cfg), ChessRefiner.withSaddlePoint(cfg), MultiscaleConfig.singleScale(), MultiscaleConfig.pyramid(levels, minSize, refinementRadius), UpscaleConfig.disabled(), UpscaleConfig.fixed(factor).

2.5 CLI

cargo run -p chess-corners --release --bin chess-corners -- \
    run config/chess_cli_config_example.json

The CLI:

  • Loads the image at the config’s image field.
  • Picks single-scale or multiscale from the top-level multiscale field.
  • Picks ChESS or Radon from strategy (the top-level variant).
  • Picks the refiner from the strategy’s nested refiner block.
  • Writes a JSON summary and a PNG overlay with one mark per corner.

The JSON config is the same DetectorConfig schema as the Rust and Python APIs, wrapped in a CLI envelope that adds image, output_json, output_png, log_level, and ml:

{
  "image": "testimages/mid.png",
  "strategy": {
    "chess": {
      "ring": "canonical",
      "refiner": { "center_of_mass": { "radius": 2 } }
    }
  },
  "threshold": 30.0,
  "detection": { "nms_radius": 2, "min_cluster_size": 2 },
  "multiscale": "single_scale",
  "upscale": "disabled",
  "orientation_method": "ring_fit",
  "merge_radius": 3.0,
  "output_json": null,
  "output_png": null,
  "log_level": "info"
}

Example configs under config/:

  • chess_algorithm_config_example.json — just the algorithm fields, the pure DetectorConfig shape shared with the Rust and Python APIs.
  • chess_cli_config_example.json — algorithm fields plus CLI I/O envelope.
  • chess_cli_config_example_ml.json — same, with the ML refiner enabled. Requires a binary built with --features ml-refiner.

In JSON, threshold is written as a bare number (ChESS: absolute response floor; Radon: fraction of the per-frame maximum), and orientation_method may be null to skip the orientation fit — in which case each corner record in the output JSON carries axes: null.

Per-flag overrides (applied on top of the JSON):

  • --threshold <v> (ChESS: absolute response floor; Radon: fraction of the per-frame max)
  • --chess-ring canonical|broad
  • --chess-refiner center_of_mass|forstner|saddle_point
  • --pyramid-levels <n> (1 = single-scale)
  • --upscale-factor 0|2|3|4

Overlay examples on the sample images in testdata/:

2.6 ML refiner

The ML refiner is a separate, optional code path. Enable it by building with --features ml-refiner (Rust) or by installing a wheel built with the same feature (Python), then pick the Ml variant on the active ChESS refiner:

#![allow(unused)]
fn main() {
#[cfg(feature = "ml-refiner")]
{
use chess_corners::{ChessRefiner, Detector, DetectorConfig};

let cfg = DetectorConfig::chess_multiscale()
    .with_chess(|c| c.refiner = ChessRefiner::Ml);

let mut detector = Detector::new(cfg).unwrap();
let corners = detector.detect(&img).unwrap();
}
}

The ML path:

  • Runs the ChESS detector to produce seeds.
  • Feeds each seed’s 21×21 neighborhood through the embedded ONNX model (chess_refiner_v4.onnx, ~180 K params).
  • Replaces the seed position with the model’s predicted (dx, dy) offset.
  • Falls back to the configured classical refiner if the ML path rejects or times out.

The algorithm and its limits are covered in Part V §5.5. Compared to the geometric refiners: SaddlePoint has lower error on blurred data while ML leads on the heaviest noise row (see the comparison in Part VIII).

The ML refiner is available on the ChESS strategy only. The Radon detector does not use a pluggable refiner.

2.7 Radon heatmap (visualization)

The Radon detector computes a dense (max_α S_α − min_α S_α)² response heatmap as an intermediate step. The heatmap is exposed across all wrappers for visualization, debugging, and downstream tooling — useful when tuning ray_radius, image_upsample, or the threshold floor.

The heatmap is returned at working resolution: the input is optionally upscaled (DetectorConfig.upscale) and then internally supersampled by the Radon detector (RadonConfig.image_upsample, default 2). The working-to-input scale factor is therefore upscale_factor * image_upsample. Multiply input-pixel coordinates by this factor to land on heatmap pixels.

Rust:

#![allow(unused)]
fn main() {
use chess_corners::diagnostics::radon_heatmap_u8;
use chess_corners::DetectorConfig;

fn run(img: &[u8], width: u32, height: u32) {
let cfg = DetectorConfig::radon();
let map = radon_heatmap_u8(img, width, height, &cfg);
println!("heatmap {}×{}, max = {:.1}",
    map.width(), map.height(),
    map.data().iter().copied().fold(f32::NEG_INFINITY, f32::max));
}
}

Python:

import chess_corners
import numpy as np

cfg = chess_corners.DetectorConfig.radon()
detector = chess_corners.Detector(cfg)
heatmap = detector.radon_heatmap(img)  # (H', W') float32
print(heatmap.shape, heatmap.dtype, float(heatmap.max()))

WebAssembly (JS):

import init, { ChessDetector, DetectorConfig } from '@vitavision/chess-corners';

await init();
const det = ChessDetector.withConfig(DetectorConfig.radon());

const heatmap = det.diagnostics_radon_heatmap(grayPixels, width, height);
const w = det.diagnostics_radon_heatmap_width();
const h = det.diagnostics_radon_heatmap_height();
const scale = det.diagnostics_radon_heatmap_scale();  // working-to-input factor
console.log('heatmap', w, 'x', h, 'scale', scale);

The heatmap is independent from corner detection: calling it does not require the active strategy to be Radon, and it does not return corners.


In this part we focused on the public faces of the detector: the image helper, the raw buffer API, the CLI, and the Python and JS bindings. In the next parts we will look under the hood at how the ChESS response is computed, how the detector turns responses into subpixel corners, and how the multiscale pipeline is structured.


Next: Part III describes the ChESS response kernel, the detection pipeline, and the corner descriptor fit. Part IV covers the Radon detector.