use image::GrayImage;
#[cfg(feature = "std")]
use std::path::Path;
use crate::detector::config::{ScaleTiers, derive_proposal_config};
use crate::detector::{DetectConfig, MarkerScalePrior};
use crate::pipeline;
use crate::pipeline::DetectionDiagnostics;
use crate::pixelmap::PixelMapper;
use crate::proposal::{find_ellipse_centers, find_ellipse_centers_with_heatmap};
use crate::target::TargetLayout;
#[cfg(feature = "std")]
use crate::target::TargetLoadError;
use crate::{DetectionResult, Proposal, ProposalResult};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum DetectError {
#[non_exhaustive]
UnsupportedTarget {
target_name: String,
lattice: &'static str,
coding: &'static str,
},
}
impl std::fmt::Display for DetectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnsupportedTarget {
target_name,
lattice,
coding,
} => write!(
f,
"target '{target_name}' ({lattice} lattice, {coding} coding) is not supported by \
the detection pipeline in this release"
),
}
}
}
impl std::error::Error for DetectError {}
pub struct Detector {
config: DetectConfig,
}
impl Detector {
pub fn new(target: impl Into<TargetLayout>) -> Self {
Self {
config: DetectConfig::from_target(target),
}
}
pub fn with_marker_scale(
target: impl Into<TargetLayout>,
marker_scale: MarkerScalePrior,
) -> Self {
Self {
config: DetectConfig::from_target_and_scale_prior(target, marker_scale),
}
}
pub fn with_marker_diameter_hint(
target: impl Into<TargetLayout>,
marker_diameter_px: f32,
) -> Self {
Self::with_marker_scale(
target,
MarkerScalePrior::from_nominal_diameter_px(marker_diameter_px),
)
}
#[cfg(feature = "std")]
pub fn from_target_json_file(path: &Path) -> Result<Self, TargetLoadError> {
Ok(Self::new(TargetLayout::from_json_file(path)?))
}
#[cfg(feature = "std")]
pub fn from_target_json_file_with_scale(
path: &Path,
marker_scale: MarkerScalePrior,
) -> Result<Self, TargetLoadError> {
Ok(Self::with_marker_scale(
TargetLayout::from_json_file(path)?,
marker_scale,
))
}
#[cfg(feature = "std")]
pub fn from_target_json_file_with_marker_diameter(
path: &Path,
marker_diameter_px: f32,
) -> Result<Self, TargetLoadError> {
Ok(Self::with_marker_diameter_hint(
TargetLayout::from_json_file(path)?,
marker_diameter_px,
))
}
pub fn with_config(config: DetectConfig) -> Self {
Self { config }
}
pub fn config(&self) -> &DetectConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut DetectConfig {
&mut self.config
}
fn run_detect(&self, image: &GrayImage) -> pipeline::PipelineResult {
if self.config.self_undistort.enable {
pipeline::detect_with_self_undistort(image, &self.config)
} else {
pipeline::detect_single_pass(image, &self.config)
}
}
pub fn detect(&self, image: &GrayImage) -> Result<DetectionResult, DetectError> {
Ok(self.run_detect(image).split().0)
}
pub fn detect_with_diagnostics(
&self,
image: &GrayImage,
) -> Result<(DetectionResult, DetectionDiagnostics), DetectError> {
Ok(self.run_detect(image).split())
}
pub fn propose(&self, image: &GrayImage) -> Vec<Proposal> {
pipeline::proposal_seeds_for_config(image, &self.config)
}
pub fn propose_with_heatmap(&self, image: &GrayImage) -> ProposalResult {
pipeline::proposal_result_for_config(image, &self.config)
}
pub fn detect_adaptive(&self, image: &GrayImage) -> Result<DetectionResult, DetectError> {
Ok(pipeline::detect_adaptive(image, &self.config).split().0)
}
pub fn adaptive_tiers(
&self,
image: &GrayImage,
nominal_diameter_px: Option<f32>,
) -> ScaleTiers {
pipeline::select_adaptive_tiers(image, nominal_diameter_px)
}
pub fn detect_adaptive_with_hint(
&self,
image: &GrayImage,
nominal_diameter_px: Option<f32>,
) -> Result<DetectionResult, DetectError> {
Ok(
pipeline::detect_adaptive_with_hint(image, &self.config, nominal_diameter_px)
.split()
.0,
)
}
pub fn detect_multiscale(
&self,
image: &GrayImage,
tiers: &ScaleTiers,
) -> Result<DetectionResult, DetectError> {
Ok(pipeline::detect_multiscale(image, &self.config, tiers)
.split()
.0)
}
pub fn detect_with_mapper(
&self,
image: &GrayImage,
mapper: &dyn PixelMapper,
) -> Result<DetectionResult, DetectError> {
Ok(pipeline::detect_with_mapper(image, &self.config, mapper)
.split()
.0)
}
pub fn detect_with_mapper_diagnostics(
&self,
image: &GrayImage,
mapper: &dyn PixelMapper,
) -> Result<(DetectionResult, DetectionDiagnostics), DetectError> {
Ok(pipeline::detect_with_mapper(image, &self.config, mapper).split())
}
}
pub fn propose_with_marker_scale(
image: &GrayImage,
target: &TargetLayout,
marker_scale: MarkerScalePrior,
) -> Vec<Proposal> {
let config = derive_proposal_config(target, marker_scale, &crate::ProposalConfig::default());
find_ellipse_centers(image, &config)
}
pub fn propose_with_heatmap_and_marker_scale(
image: &GrayImage,
target: &TargetLayout,
marker_scale: MarkerScalePrior,
) -> ProposalResult {
let config = derive_proposal_config(target, marker_scale, &crate::ProposalConfig::default());
find_ellipse_centers_with_heatmap(image, &config)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pixelmap::PixelMapper;
struct IdentityMapper;
impl PixelMapper for IdentityMapper {
fn image_to_working_pixel(&self, image_xy: [f64; 2]) -> Option<[f64; 2]> {
Some(image_xy)
}
fn working_to_image_pixel(&self, working_xy: [f64; 2]) -> Option<[f64; 2]> {
Some(working_xy)
}
}
#[test]
fn detector_basic_detect() {
let det = Detector::with_config(DetectConfig::from_target(TargetLayout::default_hex()));
let img = GrayImage::new(200, 200);
let result = det.detect(&img).expect("hex coded target is supported");
assert!(result.detected_markers.is_empty());
assert_eq!(result.center_frame, crate::DetectionFrame::Image);
assert_eq!(result.homography_frame, crate::DetectionFrame::Image);
assert!(result.self_undistort.is_none());
}
#[test]
fn detector_detect_honors_self_undistort_enable() {
let mut cfg = DetectConfig::from_target(TargetLayout::default_hex());
cfg.self_undistort.enable = true;
cfg.self_undistort.min_markers = 0;
let det = Detector::with_config(cfg);
let img = GrayImage::new(200, 200);
let result = det.detect(&img).expect("hex coded target is supported");
assert!(result.self_undistort.is_some());
}
#[test]
fn detector_mapper_ignores_self_undistort_config() {
let mut cfg = DetectConfig::from_target(TargetLayout::default_hex());
cfg.self_undistort.enable = true;
cfg.self_undistort.min_markers = 0;
let det = Detector::with_config(cfg);
let img = GrayImage::new(200, 200);
let mapper = IdentityMapper;
let result = det.detect_with_mapper(&img, &mapper).expect("supported");
assert_eq!(result.center_frame, crate::DetectionFrame::Image);
assert_eq!(result.homography_frame, crate::DetectionFrame::Working);
assert!(result.self_undistort.is_none());
}
#[test]
fn detector_config_mut() {
let mut det = Detector::with_config(DetectConfig::from_target(TargetLayout::default_hex()));
det.config_mut().advanced.completion.enable = false;
assert!(!det.config().advanced.completion.enable);
}
#[test]
fn detector_adaptive_tiers_fallback_matches_four_tier_wide_on_blank_image() {
let det = Detector::with_config(DetectConfig::from_target(TargetLayout::default_hex()));
let img = GrayImage::new(200, 200);
let tiers = det.adaptive_tiers(&img, None);
let expected = ScaleTiers::four_tier_wide();
assert_eq!(tiers.tiers().len(), expected.tiers().len());
for (got, want) in tiers.tiers().iter().zip(expected.tiers().iter()) {
assert_eq!(
got.prior.diameter_min_px.to_bits(),
want.prior.diameter_min_px.to_bits()
);
assert_eq!(
got.prior.diameter_max_px.to_bits(),
want.prior.diameter_max_px.to_bits()
);
}
}
#[test]
fn detector_adaptive_tiers_with_hint_builds_two_tier_bracket() {
let det = Detector::with_config(DetectConfig::from_target(TargetLayout::default_hex()));
let img = GrayImage::new(200, 200);
let tiers = det.adaptive_tiers(&img, Some(32.0));
assert_eq!(tiers.tiers().len(), 2);
assert!((tiers.tiers()[0].prior.diameter_min_px - 16.0).abs() < 1e-4);
assert!((tiers.tiers()[0].prior.diameter_max_px - 33.6).abs() < 1e-4);
assert!((tiers.tiers()[1].prior.diameter_min_px - 31.92).abs() < 1e-4);
assert!((tiers.tiers()[1].prior.diameter_max_px - 48.0).abs() < 1e-4);
}
fn draw_ring_image(
w: u32,
h: u32,
center: [f32; 2],
outer_radius: f32,
inner_radius: f32,
) -> GrayImage {
crate::test_utils::draw_ring_image(w, h, center, outer_radius, inner_radius, 30, 220)
}
#[test]
fn detector_propose_is_deterministic() {
let cfg = DetectConfig::from_target(TargetLayout::default_hex());
let det = Detector::with_config(cfg);
let img = draw_ring_image(128, 128, [64.0, 64.0], 24.0, 12.0);
let p1 = det.propose(&img);
let p2 = det.propose(&img);
assert!(!p1.is_empty(), "expected at least one proposal");
assert_eq!(p1.len(), p2.len(), "proposal counts should match");
for (a, b) in p1.iter().zip(p2.iter()) {
assert_eq!(a.x.to_bits(), b.x.to_bits());
assert_eq!(a.y.to_bits(), b.y.to_bits());
assert_eq!(a.score.to_bits(), b.score.to_bits());
}
}
#[test]
fn detect_with_diagnostics_result_matches_detect_and_is_aligned() {
let cfg = DetectConfig::from_target(TargetLayout::default_hex());
let detector = Detector::with_config(cfg);
let img = draw_ring_image(128, 128, [64.0, 64.0], 24.0, 12.0);
let plain = detector.detect(&img).expect("supported");
let (rich, diagnostics) = detector.detect_with_diagnostics(&img).expect("supported");
assert_eq!(plain.detected_markers.len(), rich.detected_markers.len());
assert_eq!(plain.image_size, rich.image_size);
assert_eq!(plain.center_frame, rich.center_frame);
assert_eq!(plain.homography_frame, rich.homography_frame);
for (a, b) in plain
.detected_markers
.iter()
.zip(rich.detected_markers.iter())
{
assert_eq!(a.id, b.id);
assert_eq!(a.center, b.center);
assert_eq!(a.confidence.to_bits(), b.confidence.to_bits());
}
assert_eq!(diagnostics.markers.len(), rich.detected_markers.len());
}
#[test]
fn detect_with_diagnostics_populates_single_pass_timings() {
let cfg = DetectConfig::from_target(TargetLayout::default_hex());
let detector = Detector::with_config(cfg);
let img = draw_ring_image(128, 128, [64.0, 64.0], 24.0, 12.0);
let (_result, diagnostics) = detector.detect_with_diagnostics(&img).expect("supported");
let timings = diagnostics
.timings
.expect("single-pass detection surfaces stage timings");
assert!(timings.proposal_ms >= 0.0);
assert!(timings.fit_decode_ms >= 0.0);
assert!(timings.finalize_ms >= 0.0);
assert!(timings.total_ms >= timings.proposal_ms);
assert!(timings.total_ms >= timings.fit_decode_ms);
assert!(timings.total_ms >= timings.finalize_ms);
}
#[test]
fn detector_proposal_methods_consistent() {
let cfg = DetectConfig::from_target(TargetLayout::default_hex());
let detector = Detector::with_config(cfg);
let img = draw_ring_image(128, 128, [64.0, 64.0], 24.0, 12.0);
let proposals = detector.propose(&img);
let result = detector.propose_with_heatmap(&img);
assert_eq!(proposals, result.proposals);
assert_eq!(
result.heatmap.len(),
img.width() as usize * img.height() as usize
);
}
#[test]
fn size_aware_free_proposal_apis_match_detector_with_marker_hint() {
let board = TargetLayout::default_hex();
let detector = Detector::with_marker_diameter_hint(board.clone(), 32.0);
let img = draw_ring_image(128, 128, [64.0, 64.0], 24.0, 12.0);
let free = propose_with_marker_scale(
&img,
&board,
MarkerScalePrior::from_nominal_diameter_px(32.0),
);
let detector_out = detector.propose(&img);
assert_eq!(free, detector_out);
let free_diag = propose_with_heatmap_and_marker_scale(
&img,
&board,
MarkerScalePrior::from_nominal_diameter_px(32.0),
);
let detector_diag = detector.propose_with_heatmap(&img);
assert_eq!(free_diag.proposals, detector_diag.proposals);
assert_eq!(free_diag.heatmap, detector_diag.heatmap);
}
#[test]
fn plain_rect_target_detects_without_error() {
let det = Detector::new(TargetLayout::rect_24x24());
let img = GrayImage::new(64, 64);
let result = det.detect(&img).expect("plain rect target must detect");
assert!(result.detected_markers.is_empty());
assert!(result.board_frame.is_none(), "nothing labeled ⇒ no frame");
assert!(
det.detect_multiscale(&img, &ScaleTiers::single(MarkerScalePrior::default()))
.is_ok()
);
}
#[test]
fn detector_proposal_apis_honor_proposal_downscale() {
let mut cfg = DetectConfig::from_target(TargetLayout::default_hex());
cfg.advanced.proposal_downscale = crate::ProposalDownscale::Factor(4);
let detector = Detector::with_config(cfg.clone());
let img = draw_ring_image(101, 98, [50.0, 49.0], 20.0, 10.0);
let proposals = detector.propose(&img);
let result = detector.propose_with_heatmap(&img);
let expected = pipeline::proposal_result_for_config(&img, &cfg);
assert_eq!(proposals, expected.proposals);
assert_eq!(result, expected);
assert_eq!(result.image_size, [101, 98]);
assert_eq!(result.heatmap.len(), 101 * 98);
}
}