use crate::board_layout::BoardLayout;
use crate::homography::RansacHomographyConfig;
use crate::marker::{DecodeConfig, MarkerSpec};
use crate::pixelmap::SelfUndistortConfig;
use crate::ring::{EdgeSampleConfig, OuterEstimationConfig};
use crate::proposal::ProposalConfig;
fn proposal_spacing_ratio_for_board(board: &BoardLayout) -> f32 {
let outer_diameter_mm = 2.0 * board.marker_outer_radius_mm();
if !(outer_diameter_mm.is_finite() && outer_diameter_mm > 0.0) {
return 1.0;
}
let spacing_mm = board.min_center_spacing_mm();
if !(spacing_mm.is_finite() && spacing_mm > 0.0) {
return 1.0;
}
spacing_mm / outer_diameter_mm
}
pub(crate) fn derive_proposal_config(
board: &BoardLayout,
marker_scale: MarkerScalePrior,
base: &ProposalConfig,
) -> ProposalConfig {
let [d_min, d_max] = marker_scale.diameter_range_px();
let outer_radius_max_px = d_max * 0.5;
let spacing_ratio = proposal_spacing_ratio_for_board(board);
let spacing_min_px = spacing_ratio * d_min;
let spacing_max_px = spacing_ratio * d_max;
let nms_radius = (0.16 * d_min).max(4.0);
let mut proposal = base.clone();
proposal.r_min = (0.15 * spacing_min_px).max(2.0);
proposal.r_max = (0.45 * spacing_max_px).min(1.35 * outer_radius_max_px);
proposal.min_distance = nms_radius.max(0.85 * spacing_min_px);
proposal
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SeedProposalParams {
pub merge_radius_px: f32,
pub seed_score: f32,
pub max_seeds: Option<usize>,
}
impl Default for SeedProposalParams {
fn default() -> Self {
Self {
merge_radius_px: 3.0,
seed_score: 1.0e12,
max_seeds: Some(512),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct CompletionParams {
pub enable: bool,
pub roi_radius_px: f32,
pub reproj_gate_px: f32,
pub min_fit_confidence: f32,
pub min_arc_coverage: f32,
pub max_attempts: Option<usize>,
pub image_margin_px: f32,
#[serde(default = "CompletionParams::default_require_perfect_decode")]
pub require_perfect_decode: bool,
#[serde(default = "CompletionParams::default_max_radii_std_ratio")]
pub max_radii_std_ratio: f32,
}
impl CompletionParams {
fn default_require_perfect_decode() -> bool {
false
}
fn default_max_radii_std_ratio() -> f32 {
0.35
}
}
impl Default for CompletionParams {
fn default() -> Self {
Self {
enable: true,
roi_radius_px: 24.0,
reproj_gate_px: 3.0,
min_fit_confidence: 0.45,
min_arc_coverage: 0.35,
max_attempts: None,
image_margin_px: 10.0,
require_perfect_decode: false,
max_radii_std_ratio: 0.35,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ProjectiveCenterParams {
pub use_expected_ratio: bool,
pub ratio_penalty_weight: f64,
pub max_center_shift_px: Option<f64>,
pub max_selected_residual: Option<f64>,
pub min_eig_separation: Option<f64>,
}
impl Default for ProjectiveCenterParams {
fn default() -> Self {
Self {
use_expected_ratio: true,
ratio_penalty_weight: 1.0,
max_center_shift_px: None,
max_selected_residual: Some(0.25),
min_eig_separation: Some(1e-6),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct InnerFitConfig {
pub min_points: usize,
pub min_inlier_ratio: f32,
pub max_rms_residual: f64,
pub max_center_shift_px: f64,
pub max_ratio_abs_error: f64,
pub local_peak_halfwidth_idx: usize,
pub ransac: crate::conic::RansacConfig,
#[serde(default = "InnerFitConfig::default_miss_confidence_factor")]
pub miss_confidence_factor: f32,
#[serde(default = "InnerFitConfig::default_max_angular_gap_rad")]
pub max_angular_gap_rad: f64,
#[serde(default = "InnerFitConfig::default_require_inner_fit")]
pub require_inner_fit: bool,
}
impl InnerFitConfig {
fn default_miss_confidence_factor() -> f32 {
0.7
}
fn default_max_angular_gap_rad() -> f64 {
std::f64::consts::FRAC_PI_2
}
fn default_require_inner_fit() -> bool {
false
}
}
impl Default for InnerFitConfig {
fn default() -> Self {
Self {
min_points: 20,
min_inlier_ratio: 0.5,
max_rms_residual: 1.0,
max_center_shift_px: 12.0,
max_ratio_abs_error: 0.15,
local_peak_halfwidth_idx: 3,
ransac: crate::conic::RansacConfig {
max_iters: 200,
inlier_threshold: 1.5,
min_inliers: 8,
seed: 43,
},
miss_confidence_factor: 0.7,
max_angular_gap_rad: Self::default_max_angular_gap_rad(),
require_inner_fit: false,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct OuterFitConfig {
pub min_direct_fit_points: usize,
pub min_ransac_points: usize,
pub ransac: crate::conic::RansacConfig,
#[serde(default = "OuterFitConfig::default_size_score_weight")]
pub size_score_weight: f32,
#[serde(default = "OuterFitConfig::default_max_angular_gap_rad")]
pub max_angular_gap_rad: f64,
}
impl OuterFitConfig {
fn default_size_score_weight() -> f32 {
0.15
}
fn default_max_angular_gap_rad() -> f64 {
std::f64::consts::FRAC_PI_2
}
}
impl Default for OuterFitConfig {
fn default() -> Self {
Self {
min_direct_fit_points: 6,
min_ransac_points: 8,
ransac: crate::conic::RansacConfig {
max_iters: 200,
inlier_threshold: 1.5,
min_inliers: 6,
seed: 42,
},
size_score_weight: Self::default_size_score_weight(),
max_angular_gap_rad: Self::default_max_angular_gap_rad(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum CircleRefinementMethod {
None,
#[default]
ProjectiveCenter,
}
impl CircleRefinementMethod {
pub fn uses_projective_center(self) -> bool {
matches!(self, Self::ProjectiveCenter)
}
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct MarkerScalePrior {
pub diameter_min_px: f32,
pub diameter_max_px: f32,
}
impl MarkerScalePrior {
const MIN_DIAMETER_FLOOR_PX: f32 = 4.0;
pub fn new(diameter_min_px: f32, diameter_max_px: f32) -> Self {
let mut out = Self {
diameter_min_px,
diameter_max_px,
};
out.normalize_in_place();
out
}
pub fn from_nominal_diameter_px(diameter_px: f32) -> Self {
Self::new(diameter_px, diameter_px)
}
pub fn diameter_range_px(self) -> [f32; 2] {
let n = self.normalized();
[n.diameter_min_px, n.diameter_max_px]
}
pub fn nominal_diameter_px(self) -> f32 {
let [d_min, d_max] = self.diameter_range_px();
0.5 * (d_min + d_max)
}
pub fn nominal_outer_radius_px(self) -> f32 {
self.nominal_diameter_px() * 0.5
}
pub fn normalized(self) -> Self {
let mut out = self;
out.normalize_in_place();
out
}
fn normalize_in_place(&mut self) {
let defaults = MarkerScalePrior::default();
let mut d_min = if self.diameter_min_px.is_finite() {
self.diameter_min_px
} else {
defaults.diameter_min_px
};
let mut d_max = if self.diameter_max_px.is_finite() {
self.diameter_max_px
} else {
defaults.diameter_max_px
};
if d_min > d_max {
std::mem::swap(&mut d_min, &mut d_max);
}
d_min = d_min.max(Self::MIN_DIAMETER_FLOOR_PX);
d_max = d_max.max(d_min);
self.diameter_min_px = d_min;
self.diameter_max_px = d_max;
}
}
impl Default for MarkerScalePrior {
fn default() -> Self {
Self {
diameter_min_px: 14.0,
diameter_max_px: 66.0,
}
}
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct ScaleTier {
pub prior: MarkerScalePrior,
}
impl ScaleTier {
pub fn new(diameter_min_px: f32, diameter_max_px: f32) -> Self {
Self {
prior: MarkerScalePrior::new(diameter_min_px, diameter_max_px),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ScaleTiers(pub Vec<ScaleTier>);
impl ScaleTiers {
pub fn four_tier_wide() -> Self {
Self(vec![
ScaleTier::new(8.0, 24.0),
ScaleTier::new(20.0, 60.0),
ScaleTier::new(50.0, 130.0),
ScaleTier::new(110.0, 220.0),
])
}
pub fn two_tier_standard() -> Self {
Self(vec![
ScaleTier::new(14.0, 42.0),
ScaleTier::new(36.0, 100.0),
])
}
pub fn single(prior: MarkerScalePrior) -> Self {
Self(vec![ScaleTier { prior }])
}
pub fn from_detected_radii(probe_radii: &[f32]) -> Self {
let mut sorted: Vec<f32> = probe_radii
.iter()
.copied()
.filter(|r| r.is_finite() && *r > 0.0)
.collect();
if sorted.is_empty() {
return Self::single(MarkerScalePrior::default());
}
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
const PROBE_TO_OUTER: f32 = 1.0 / 0.8;
let mut tiers = Vec::new();
let mut cluster_min = sorted[0];
let mut cluster_max = sorted[0];
for &r in &sorted[1..] {
if r / cluster_min <= 3.0 {
cluster_max = r;
} else {
let r_outer_min = cluster_min * PROBE_TO_OUTER;
let r_outer_max = cluster_max * PROBE_TO_OUTER;
let d_min = (2.0 * r_outer_min * 0.70).max(4.0);
let d_max = (2.0 * r_outer_max * 1.35).max(d_min);
tiers.push(ScaleTier::new(d_min, d_max));
cluster_min = r;
cluster_max = r;
}
}
let r_outer_min = cluster_min * PROBE_TO_OUTER;
let r_outer_max = cluster_max * PROBE_TO_OUTER;
let d_min = (2.0 * r_outer_min * 0.70).max(4.0);
let d_max = (2.0 * r_outer_max * 1.35).max(d_min);
tiers.push(ScaleTier::new(d_min, d_max));
Self(tiers)
}
pub fn tiers(&self) -> &[ScaleTier] {
&self.0
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct IdCorrectionConfig {
pub enable: bool,
pub auto_search_radius_outer_muls: Vec<f64>,
pub consistency_outer_mul: f64,
pub consistency_min_neighbors: usize,
pub consistency_min_support_edges: usize,
pub consistency_max_contradiction_frac: f32,
pub soft_lock_exact_decode: bool,
pub min_votes: usize,
pub min_votes_recover: usize,
pub min_vote_weight_frac: f32,
pub h_reproj_gate_px: f64,
pub homography_fallback_enable: bool,
pub homography_min_trusted: usize,
pub homography_min_inliers: usize,
pub max_iters: usize,
pub remove_unverified: bool,
pub seed_min_decode_confidence: f32,
}
impl IdCorrectionConfig {
pub(crate) fn effective_min_votes(&self, has_id: bool) -> usize {
if has_id {
self.min_votes
} else {
self.min_votes_recover
}
}
}
impl Default for IdCorrectionConfig {
fn default() -> Self {
Self {
enable: true,
auto_search_radius_outer_muls: vec![2.4, 2.9, 3.5, 4.2, 5.0],
consistency_outer_mul: 3.2,
consistency_min_neighbors: 1,
consistency_min_support_edges: 1,
consistency_max_contradiction_frac: 0.5,
soft_lock_exact_decode: true,
min_votes: 2,
min_votes_recover: 1,
min_vote_weight_frac: 0.55,
h_reproj_gate_px: 30.0,
homography_fallback_enable: true,
homography_min_trusted: 24,
homography_min_inliers: 12,
max_iters: 5,
remove_unverified: false,
seed_min_decode_confidence: 0.7,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct InnerAsOuterRecoveryConfig {
pub enable: bool,
pub ratio_threshold: f32,
pub k_neighbors: usize,
pub min_theta_consistency: f32,
pub min_theta_coverage: f32,
pub min_ring_depth: f32,
pub refine_halfwidth_px: f32,
pub size_gate_tolerance: f32,
}
impl Default for InnerAsOuterRecoveryConfig {
fn default() -> Self {
Self {
enable: true,
ratio_threshold: 0.75,
k_neighbors: 6,
min_theta_consistency: 0.18,
min_theta_coverage: 0.40,
min_ring_depth: 0.02,
refine_halfwidth_px: 2.5,
size_gate_tolerance: 0.25,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProposalDownscale {
Auto,
#[default]
Off,
Factor(u32),
}
impl ProposalDownscale {
pub fn resolve(&self, marker_scale: MarkerScalePrior) -> u32 {
match self {
Self::Auto => {
let d_min = marker_scale.diameter_range_px()[0];
(d_min / 14.0).floor().clamp(1.0, 4.0) as u32
}
Self::Off => 1,
Self::Factor(f) => (*f).clamp(1, 4),
}
}
}
#[derive(Debug, Clone)]
pub struct DetectConfig {
pub marker_scale: MarkerScalePrior,
pub outer_estimation: OuterEstimationConfig,
pub proposal: ProposalConfig,
pub seed_proposals: SeedProposalParams,
pub edge_sample: EdgeSampleConfig,
pub decode: DecodeConfig,
pub marker_spec: MarkerSpec,
pub inner_fit: InnerFitConfig,
pub outer_fit: OuterFitConfig,
pub circle_refinement: CircleRefinementMethod,
pub projective_center: ProjectiveCenterParams,
pub completion: CompletionParams,
pub(crate) min_semi_axis: f64,
pub(crate) max_semi_axis: f64,
pub max_aspect_ratio: f64,
pub dedup_radius: f64,
pub use_global_filter: bool,
pub topology_filter_threshold_px: Option<f32>,
pub ransac_homography: RansacHomographyConfig,
pub board: BoardLayout,
pub self_undistort: SelfUndistortConfig,
pub id_correction: IdCorrectionConfig,
pub inner_as_outer_recovery: InnerAsOuterRecoveryConfig,
pub h_reproj_confidence_alpha: f32,
pub proposal_downscale: ProposalDownscale,
}
impl DetectConfig {
pub fn from_target_and_scale_prior(board: BoardLayout, marker_scale: MarkerScalePrior) -> Self {
let mut cfg = Self {
marker_scale: marker_scale.normalized(),
..Default::default()
};
cfg.board = board;
apply_target_geometry_priors(&mut cfg);
apply_marker_scale_prior(&mut cfg);
cfg
}
pub fn from_target(board: BoardLayout) -> Self {
Self::from_target_and_scale_prior(board, MarkerScalePrior::default())
}
pub fn from_target_and_marker_diameter(board: BoardLayout, diameter_px: f32) -> Self {
Self::from_target_and_scale_prior(
board,
MarkerScalePrior::from_nominal_diameter_px(diameter_px),
)
}
pub fn set_marker_scale_prior(&mut self, marker_scale: MarkerScalePrior) {
self.marker_scale = marker_scale.normalized();
apply_marker_scale_prior(self);
}
pub fn set_marker_diameter_hint_px(&mut self, diameter_px: f32) {
self.set_marker_scale_prior(MarkerScalePrior::from_nominal_diameter_px(diameter_px));
}
#[cfg(test)]
fn proposal_spacing_ratio(&self) -> f32 {
proposal_spacing_ratio_for_board(&self.board)
}
#[cfg(test)]
fn proposal_spacing_min_px(&self) -> f32 {
let [d_min, _] = self.marker_scale.diameter_range_px();
self.proposal_spacing_ratio() * d_min
}
#[cfg(test)]
fn proposal_spacing_max_px(&self) -> f32 {
let [_, d_max] = self.marker_scale.diameter_range_px();
self.proposal_spacing_ratio() * d_max
}
}
impl Default for DetectConfig {
fn default() -> Self {
let mut cfg = Self {
marker_scale: MarkerScalePrior::default(),
outer_estimation: OuterEstimationConfig::default(),
proposal: ProposalConfig::default(),
seed_proposals: SeedProposalParams::default(),
edge_sample: EdgeSampleConfig::default(),
decode: DecodeConfig::default(),
marker_spec: MarkerSpec::default(),
inner_fit: InnerFitConfig::default(),
outer_fit: OuterFitConfig::default(),
circle_refinement: CircleRefinementMethod::default(),
projective_center: ProjectiveCenterParams::default(),
completion: CompletionParams::default(),
min_semi_axis: 3.0,
max_semi_axis: 15.0,
max_aspect_ratio: 3.0,
dedup_radius: 6.0,
use_global_filter: true,
topology_filter_threshold_px: None,
ransac_homography: RansacHomographyConfig::default(),
board: BoardLayout::default(),
self_undistort: SelfUndistortConfig::default(),
id_correction: IdCorrectionConfig::default(),
inner_as_outer_recovery: InnerAsOuterRecoveryConfig::default(),
h_reproj_confidence_alpha: 0.2,
proposal_downscale: ProposalDownscale::default(),
};
apply_target_geometry_priors(&mut cfg);
apply_marker_scale_prior(&mut cfg);
cfg
}
}
fn apply_marker_scale_prior(config: &mut DetectConfig) {
config.marker_scale = config.marker_scale.normalized();
let [d_min, d_max] = config.marker_scale.diameter_range_px();
let d_nom = config.marker_scale.nominal_diameter_px();
let outer_radius_min_px = d_min * 0.5;
let outer_radius_max_px = d_max * 0.5;
let r_nom = d_nom * 0.5;
config.proposal = derive_proposal_config(&config.board, config.marker_scale, &config.proposal);
config.edge_sample.r_max = outer_radius_max_px * 2.0;
config.edge_sample.r_min = 1.5;
let desired_halfwidth = ((outer_radius_max_px - outer_radius_min_px) * 0.5).max(2.0);
let base_halfwidth = OuterEstimationConfig::default().search_halfwidth_px;
config.outer_estimation.search_halfwidth_px = desired_halfwidth.max(base_halfwidth);
config.min_semi_axis = (outer_radius_min_px as f64 * 0.3).max(2.0);
config.max_semi_axis = (outer_radius_max_px as f64 * 2.5).max(config.min_semi_axis);
config.completion.roi_radius_px = ((d_nom as f64 * 0.75).clamp(24.0, 80.0)) as f32;
config.projective_center.max_center_shift_px = Some((2.0 * r_nom) as f64);
}
fn apply_target_geometry_priors(config: &mut DetectConfig) {
let outer = config.board.marker_outer_radius_mm();
let inner = config.board.marker_inner_radius_mm();
let ring_width = config.board.marker_ring_width_mm();
if !(outer.is_finite() && inner.is_finite() && ring_width.is_finite())
|| outer <= 0.0
|| inner <= 0.0
|| ring_width <= 0.0
|| inner >= outer
{
return;
}
let edge_pad = 0.5 * ring_width;
let inner_edge = (inner - edge_pad).max(outer * 0.05);
let outer_edge = outer + edge_pad;
if inner_edge > 0.0 && inner_edge < outer_edge {
let r_inner_expected = (inner_edge / outer_edge).clamp(0.1, 0.95);
config.marker_spec.r_inner_expected = r_inner_expected;
config.decode.code_band_ratio = (0.5 * (1.0 + r_inner_expected)).clamp(0.2, 0.98);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inner_fit_config_defaults_are_stable() {
let core = InnerFitConfig::default();
assert_eq!(core.min_points, 20);
assert!((core.min_inlier_ratio - 0.5).abs() < 1e-6);
assert!((core.max_rms_residual - 1.0).abs() < 1e-9);
assert!((core.max_center_shift_px - 12.0).abs() < 1e-9);
assert!((core.max_ratio_abs_error - 0.15).abs() < 1e-9);
assert_eq!(core.local_peak_halfwidth_idx, 3);
assert_eq!(core.ransac.max_iters, 200);
assert!((core.ransac.inlier_threshold - 1.5).abs() < 1e-9);
assert_eq!(core.ransac.min_inliers, 8);
assert_eq!(core.ransac.seed, 43);
assert!((core.miss_confidence_factor - 0.7).abs() < 1e-6);
assert!(
(core.max_angular_gap_rad - std::f64::consts::FRAC_PI_2).abs() < 1e-9,
"inner max_angular_gap_rad"
);
assert!(!core.require_inner_fit);
}
#[test]
fn outer_fit_config_defaults_are_stable() {
let core = OuterFitConfig::default();
assert_eq!(core.min_direct_fit_points, 6);
assert_eq!(core.min_ransac_points, 8);
assert_eq!(core.ransac.max_iters, 200);
assert!((core.ransac.inlier_threshold - 1.5).abs() < 1e-9);
assert_eq!(core.ransac.min_inliers, 6);
assert_eq!(core.ransac.seed, 42);
assert!((core.size_score_weight - 0.15).abs() < 1e-6);
assert!(
(core.max_angular_gap_rad - std::f64::consts::FRAC_PI_2).abs() < 1e-9,
"outer max_angular_gap_rad"
);
}
#[test]
fn outer_fit_config_deserialize_missing_size_weight_uses_default() {
let json = r#"{
"min_direct_fit_points": 6,
"min_ransac_points": 8,
"ransac": {
"max_iters": 200,
"inlier_threshold": 1.5,
"min_inliers": 6,
"seed": 42
}
}"#;
let cfg: OuterFitConfig = serde_json::from_str(json).expect("deserialize outer fit config");
assert!((cfg.size_score_weight - 0.15).abs() < 1e-6);
}
#[test]
fn detect_config_includes_fit_configs() {
let cfg = DetectConfig::default();
assert_eq!(cfg.inner_fit.min_points, 20);
assert_eq!(cfg.inner_fit.ransac.min_inliers, 8);
assert_eq!(cfg.outer_fit.min_direct_fit_points, 6);
assert_eq!(cfg.outer_fit.ransac.min_inliers, 6);
}
#[test]
fn marker_scale_prior_derives_spacing_aware_proposal_geometry() {
let cfg = DetectConfig::from_target(BoardLayout::default());
let spacing_ratio =
cfg.board.min_center_spacing_mm() / (2.0 * cfg.board.marker_outer_radius_mm());
let [d_min, d_max] = cfg.marker_scale.diameter_range_px();
let spacing_min_px = spacing_ratio * d_min;
let spacing_max_px = spacing_ratio * d_max;
let outer_radius_max_px = 0.5 * d_max;
assert!((cfg.proposal_spacing_ratio() - spacing_ratio).abs() < 1.0e-6);
assert!((cfg.proposal_spacing_min_px() - spacing_min_px).abs() < 1.0e-6);
assert!((cfg.proposal_spacing_max_px() - spacing_max_px).abs() < 1.0e-6);
assert!((cfg.proposal.r_min - (0.15 * spacing_min_px).max(2.0)).abs() < 1.0e-6);
assert!(
(cfg.proposal.r_max - (0.45 * spacing_max_px).min(1.35 * outer_radius_max_px)).abs()
< 1.0e-6
);
let expected_nms = (0.16 * d_min).max(4.0);
let expected_min_dist = expected_nms.max(0.85 * spacing_min_px);
assert!((cfg.proposal.min_distance - expected_min_dist).abs() < 1.0e-6);
}
#[test]
fn fixed_marker_hint_keeps_spacing_aware_seed_distance() {
let cfg = DetectConfig::from_target_and_marker_diameter(BoardLayout::default(), 32.0);
assert!((cfg.proposal.r_min - 6.928203).abs() < 1.0e-5);
assert!((cfg.proposal.r_max - 20.784609).abs() < 1.0e-5);
assert!((cfg.proposal.min_distance - 39.259_815).abs() < 1.0e-5);
}
#[test]
fn id_correction_config_defaults_are_stable() {
let cfg = IdCorrectionConfig::default();
assert!(cfg.enable);
assert_eq!(
cfg.auto_search_radius_outer_muls,
vec![2.4, 2.9, 3.5, 4.2, 5.0]
);
assert!((cfg.consistency_outer_mul - 3.2).abs() < 1e-9);
assert_eq!(cfg.consistency_min_neighbors, 1);
assert_eq!(cfg.consistency_min_support_edges, 1);
assert!((cfg.consistency_max_contradiction_frac - 0.5).abs() < 1e-6);
assert!(cfg.soft_lock_exact_decode);
assert_eq!(cfg.min_votes, 2);
assert_eq!(cfg.min_votes_recover, 1);
assert!((cfg.min_vote_weight_frac - 0.55).abs() < 1e-6);
assert!((cfg.h_reproj_gate_px - 30.0).abs() < 1e-9);
assert!(cfg.homography_fallback_enable);
assert_eq!(cfg.homography_min_trusted, 24);
assert_eq!(cfg.homography_min_inliers, 12);
assert_eq!(cfg.max_iters, 5);
assert!(!cfg.remove_unverified);
assert!((cfg.seed_min_decode_confidence - 0.7).abs() < 1e-6);
}
#[test]
fn id_correction_config_unknown_fields_are_silently_ignored() {
let json = r#"{
"enable": true,
"neighbor_search_radius_px": null,
"homography_ransac_max_iters": 1200,
"homography_use_recovered_seeds": false,
"homography_candidate_top_k": 19,
"min_votes": 2,
"min_votes_recover": 1,
"min_vote_weight_frac": 0.55,
"h_reproj_gate_px": 30.0,
"max_iters": 5,
"remove_unverified": false,
"seed_min_decode_confidence": 0.7
}"#;
let cfg: IdCorrectionConfig =
serde_json::from_str(json).expect("deserialize old id correction config");
assert_eq!(
cfg.auto_search_radius_outer_muls,
vec![2.4, 2.9, 3.5, 4.2, 5.0]
);
assert!((cfg.consistency_outer_mul - 3.2).abs() < 1e-9);
assert_eq!(cfg.consistency_min_neighbors, 1);
assert_eq!(cfg.consistency_min_support_edges, 1);
assert!(cfg.homography_fallback_enable);
assert_eq!(cfg.homography_min_trusted, 24);
assert_eq!(cfg.homography_min_inliers, 12);
}
}