calib_targets_chessboard/params.rs
1//! Chessboard detector parameters.
2//!
3//! All spatial tolerances are **multiplicative with respect to `s`**
4//! (the global cell size) — the pipeline is scale-invariant once `s`
5//! is known. All angular tolerances are absolute degrees.
6//!
7//! Default values follow spec §6.
8
9use projective_grid::{LocalMergeParams, TopologicalParams};
10use serde::{Deserialize, Serialize};
11
12/// Which graph-build algorithm to run.
13///
14/// The detector ships two pipelines side by side:
15///
16/// - [`ChessboardV2`](GraphBuildAlgorithm::ChessboardV2) — invariant-rich
17/// 8-stage seed-and-grow pipeline (axis clustering → cell-size
18/// estimate → 4-corner seed → BFS grow → validate → boosters).
19/// **Current default.** Pinned for ChArUco because non-uniform marker
20/// cells defeat the topological cell test.
21/// - [`Topological`](GraphBuildAlgorithm::Topological) — Delaunay
22/// triangulation + axis-driven cell test (Shu/Brunton/Fiala 2009 with
23/// image-free classification). Lower setup cost, no global cell-size
24/// dependency. **Currently opt-in only.** Designed to handle severe
25/// radial distortion and low view angles that the seed-and-grow
26/// pipeline stalls on (the PuzzleBoard `130x130_puzzle` low-angle
27/// target). Recall on the chessboard testdata regression set is
28/// below ChessboardV2's; the default will flip once tolerances are
29/// tuned to match the precision-and-recall baseline. Opt in per call
30/// via [`DetectorParams::graph_build_algorithm`].
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33#[non_exhaustive]
34pub enum GraphBuildAlgorithm {
35 Topological,
36 /// Conservative default while topological recall catches up to
37 /// ChessboardV2 on the public testdata regression set.
38 #[default]
39 ChessboardV2,
40}
41
42fn default_graph_build_algorithm() -> GraphBuildAlgorithm {
43 GraphBuildAlgorithm::default()
44}
45
46fn default_topological_params() -> TopologicalParams {
47 TopologicalParams::default()
48}
49
50fn default_component_merge_params() -> LocalMergeParams {
51 LocalMergeParams::default()
52}
53
54fn default_validate_step_aware() -> bool {
55 // Default off: shipping the capability without changing behaviour.
56 // The step-aware threshold is anisotropic per-corner — tighter in
57 // perspective-foreshortened regions, looser in radially-distorted
58 // ones. On the public bench, enabling it drops one labelled corner
59 // on `testdata/puzzleboard_reference/example1.png` (the tighter
60 // back-edge tolerance over-flags). Treat enabling it as a focused
61 // experiment per dataset until we have a tuned `line_tol_rel` /
62 // `local_h_tol_rel` pair that holds the precision contract on
63 // every blessed image.
64 false
65}
66
67fn default_step_deviation_thresh_rel() -> f32 {
68 // Off by default. Set to e.g. 0.5 to flag corners whose local
69 // step deviates from the labelled-set median by more than 50%.
70 // Combined with a line flag, the corner is blacklisted (rule 4).
71 0.0
72}
73
74fn default_cluster_sigma_k() -> f32 {
75 // k = 0 by default — sigma-aware tolerance is plumbed through but
76 // disabled. Empirical study (k = 0.5–2.0 with cap 3–4°): every
77 // positive setting that recovers `small3.png`'s NoCluster set also
78 // destabilises `example2.png`'s seed finder under heavy radial
79 // distortion. Extra Clustered candidates expose a ~1.4×-cell seed
80 // quad whose edge midpoints don't coincide with any real corner,
81 // so the existing midpoint-violation check (even broadened to
82 // include all positions) does not reject it. The seed selector
83 // needs cell-size consistency or trial-grow scoring before this
84 // gate can open. Setting `cluster_sigma_k` > 0 in a custom
85 // `DetectorParams` is fine for experiments.
86 0.0
87}
88
89fn default_enable_stage6_5_rescue() -> bool {
90 // Default on. The rescue pass runs after Stage 6 and is gated on
91 // (a) local-H position match, (b) parity match against the global
92 // centers, and (c) the axis-slot-swap edge invariant. None of
93 // these admit a structurally wrong corner — the precision
94 // contract still holds.
95 true
96}
97
98fn default_rescue_axis_tol_deg() -> f32 {
99 // 22° covers the false-NoCluster `max_d_deg` quartiles observed
100 // on `example1.png` (max 32°) and `example2.png` (max 21°).
101 // Goes wider than `weak_cluster_tol_deg` because Stage 6.5
102 // requires the additional position + parity + edge gates.
103 22.0
104}
105
106fn default_rescue_search_rel() -> f32 {
107 // 0.8 cell — wide enough to catch corners under heavy perspective
108 // foreshortening where local-H extrapolation at boundary cells
109 // overshoots the actual position by ~0.5 cell. The ambiguity gate
110 // and parity / axis / edge invariants keep precision intact.
111 0.8
112}
113
114fn default_enable_post_grow_refit() -> bool {
115 // Default on. After Stage 6.5 / boosters converge, recompute
116 // cluster centres from the labelled axes alone (no marker
117 // contribution), and re-run Stage 6 / 6.5 once with the new
118 // centres. Recovers chessboard parity-B corners on images where
119 // the histogram-driven Stage-3 centres are biased downward by
120 // marker-internal corners.
121 true
122}
123
124fn default_refit_min_labelled() -> usize {
125 8
126}
127
128fn default_refit_min_shift_deg() -> f32 {
129 // Below 0.5° the centre shift cannot move a borderline corner
130 // across the cluster gate; skip the second Stage-6 / 6.5 pass.
131 0.5
132}
133
134fn default_enable_post_grow_bfs_regrow() -> bool {
135 // Default ON. Runs first when the refit triggers; lifts recall
136 // on cases where the orphan strip is 1+ cells past the existing
137 // labelled set's bbox edge (small3.png left strip, +21 corners
138 // on the public bench). The destructive regrow can flip a few
139 // borderline parity slots under the small (~3°) centre shift —
140 // those losses are recovered immediately by
141 // `enable_post_grow_bfs_extend` running after, which walks the
142 // regrown labelled set's boundary and re-attaches dropped
143 // corners via cardinal-neighbour prediction.
144 true
145}
146
147fn default_enable_post_grow_bfs_extend() -> bool {
148 // Default ON. After refit produces refined centres, walk the
149 // existing labelled set's boundary with a non-destructive BFS
150 // (`projective_grid::square::grow_extend::extend_from_labelled`),
151 // attaching newly-Clustered corners via cardinal-neighbour
152 // propagation. Reaches interior-hole / left-strip corners that
153 // local-H extrapolation (Stage 6 / 6.5) cannot, but unlike the
154 // destructive `enable_post_grow_bfs_regrow` it never demotes
155 // existing Labeled corners — so it preserves perimeter rows
156 // on heavy-distortion images that the destructive regrow would
157 // strip.
158 true
159}
160
161fn default_geometry_check_line_tol_rel() -> f32 {
162 // Final geometry check uses a much looser line-collinearity
163 // tolerance than the BFS-validation pass (`line_tol_rel = 0.18`).
164 // The geometry check's role is to catch gross mislabels —
165 // full-cell or diagonal shifts (~1.4 cell residual) — not the
166 // borderline perspective drift that the BFS-validation loop
167 // already worked through and accepted. A tight tolerance here
168 // produces catastrophic recall regressions on
169 // `puzzleboard_reference/example2.png` (heavy radial distortion)
170 // and the 130x130_puzzle dataset.
171 0.45
172}
173
174fn default_geometry_check_local_h_tol_rel() -> f32 {
175 // Same logic as above for local-H residual. A diagonal mislabel
176 // shifts a corner by ~1.4 cell from its predicted position; a
177 // tolerance of 0.6 cell is well below that gap while leaving the
178 // legitimate perspective-distorted corners alone.
179 0.6
180}
181
182fn default_stage6_local_h() -> bool {
183 // Local-H Stage 6 is the production default: per-candidate
184 // homography from the K nearest labelled corners + deeper bbox
185 // enumeration (`extend_depth = 3`). On the public bench it lifts
186 // `testdata/puzzleboard_reference/example2.png` from 75 → 134
187 // labelled corners (heavy radial distortion, where global-H's
188 // residual gate refused). All other public images stay byte-exact.
189 // p95 latency goes from ~10 ms to ~18 ms — the cost of one DLT
190 // per candidate cell, within the Phase 5 budget (≤ 1.3× baseline).
191 //
192 // Set to `false` to fall back to the single-global-H Stage 6 if
193 // the latency or determinism behaviour ever needs to be compared
194 // back-to-back.
195 true
196}
197
198fn default_stage6_local_k_nearest() -> usize {
199 // K = 12 gives 3× over-determination on the 9-DOF DLT and is
200 // wide enough to capture local perspective without diluting it
201 // with far-away labels. Reduce to 8 for very small labelled sets;
202 // raise to 16 for large/dense grids.
203 12
204}
205
206/// Top-level detector configuration.
207#[non_exhaustive]
208#[derive(Clone, Debug, Deserialize, Serialize)]
209pub struct DetectorParams {
210 // --- Pipeline dispatch ---------------------------------------------------
211 /// Which graph-build algorithm to run. See [`GraphBuildAlgorithm`].
212 /// Default: [`GraphBuildAlgorithm::Topological`].
213 #[serde(default = "default_graph_build_algorithm")]
214 pub graph_build_algorithm: GraphBuildAlgorithm,
215
216 /// Tuning knobs for the [`GraphBuildAlgorithm::Topological`] path.
217 /// Ignored when [`graph_build_algorithm`](Self::graph_build_algorithm)
218 /// is [`ChessboardV2`](GraphBuildAlgorithm::ChessboardV2).
219 #[serde(default = "default_topological_params")]
220 pub topological: TopologicalParams,
221
222 /// Tuning knobs for the shared local-geometry component merger.
223 /// Used by both the topological and chessboard-v2 pipelines.
224 #[serde(default = "default_component_merge_params")]
225 pub component_merge: LocalMergeParams,
226
227 // --- Stage 1: pre-filter -------------------------------------------------
228 /// Minimum corner strength (ChESS response). `0.0` disables the filter.
229 pub min_corner_strength: f32,
230 /// Corners are dropped when `c.fit_rms > max_fit_rms_ratio * c.contrast`
231 /// (and `c.contrast > 0`). `f32::INFINITY` disables the filter.
232 pub max_fit_rms_ratio: f32,
233
234 // --- Stage 2 + 3: clustering --------------------------------------------
235 /// Number of histogram bins on `[0, π)` for axis-direction clustering.
236 pub num_bins: usize,
237 /// Max 2-means refinement iterations over axis votes.
238 pub max_iters_2means: usize,
239 /// Per-axis absolute tolerance (degrees) for a corner's axis to count as
240 /// matching a cluster center. The effective per-corner gate is
241 /// `cluster_tol_deg + cluster_sigma_k * max(σ_a0, σ_a1)`, so noisier
242 /// axis estimates get proportional slack — see [`cluster_sigma_k`].
243 ///
244 /// [`cluster_sigma_k`]: DetectorParams::cluster_sigma_k
245 pub cluster_tol_deg: f32,
246 /// Multiplier on the per-corner axis sigma added to [`cluster_tol_deg`]
247 /// when admitting a corner. Default `2.0`: clean corners
248 /// (σ ≈ 0.5–1°) get ≈ `cluster_tol_deg + 1–2°`; noisy corners
249 /// (σ ≈ 3–5° on tilted-lens / partial-focus images) get
250 /// `cluster_tol_deg + 6–10°`. Set to `0.0` to restore the strict
251 /// fixed-tolerance behaviour.
252 ///
253 /// Justification: ChESS axis sigma is the 1σ Gauss–Newton uncertainty
254 /// of the two-axis fit, so a per-corner gate of `tol + k·σ` is the
255 /// standard way to pass corners whose true axis is within tolerance
256 /// but whose estimate fell outside under noise. `k = 2` corresponds
257 /// to a ≈ 95% one-sided confidence band.
258 ///
259 /// [`cluster_tol_deg`]: DetectorParams::cluster_tol_deg
260 #[serde(default = "default_cluster_sigma_k")]
261 pub cluster_sigma_k: f32,
262 /// Minimal angular separation (degrees) between the two peaks. Guards
263 /// against seed-peak collisions; true grid axes are `~90°` apart.
264 pub peak_min_separation_deg: f32,
265 /// Minimal fraction of total axis-vote weight required for a peak to be
266 /// considered.
267 pub min_peak_weight_fraction: f32,
268
269 // --- Stage 4: cell size --------------------------------------------------
270 /// Optional caller hint. When provided and close to the estimate, the
271 /// hint may tighten Stage-5/6 search windows. See `cell_size.rs`.
272 pub cell_size_hint: Option<f32>,
273
274 // --- Stage 5: seed -------------------------------------------------------
275 /// Seed edge length window: `[1 - t, 1 + t] × s`.
276 pub seed_edge_tol: f32,
277 /// Angular tolerance (degrees) for seed-edge direction vs matched axis.
278 pub seed_axis_tol_deg: f32,
279 /// Parallelogram-closure tolerance (fraction of `s`) for seed quad `D`.
280 pub seed_close_tol: f32,
281
282 // --- Stage 6: grow -------------------------------------------------------
283 /// Candidate-search radius (fraction of `s`) around predicted `(i, j)`.
284 pub attach_search_rel: f32,
285 /// Axis alignment tolerance at attachment time (degrees).
286 pub attach_axis_tol_deg: f32,
287 /// Ambiguity factor: if the second-nearest candidate is within
288 /// `factor × nearest_distance`, the attachment is skipped.
289 pub attach_ambiguity_factor: f32,
290 /// Edge-length window (fraction of `s`) enforced when admitting edges
291 /// from the new corner to its labelled neighbors.
292 pub step_tol: f32,
293 /// Edge axis-direction tolerance (degrees) enforced at admission time.
294 pub edge_axis_tol_deg: f32,
295
296 // --- Stage 7: validate ---------------------------------------------------
297 /// Straight-line-fit collinearity tolerance (fraction of the
298 /// per-corner scale — see [`validate_step_aware`]).
299 ///
300 /// [`validate_step_aware`]: DetectorParams::validate_step_aware
301 pub line_tol_rel: f32,
302 /// Minimum members required to fit a line / column for collinearity
303 /// checks.
304 pub line_min_members: usize,
305 /// Local-H prediction tolerance (fraction of the per-corner scale
306 /// — see [`validate_step_aware`]).
307 ///
308 /// [`validate_step_aware`]: DetectorParams::validate_step_aware
309 pub local_h_tol_rel: f32,
310 /// When `true`, [`line_tol_rel`] / [`local_h_tol_rel`] are
311 /// multiplied by a per-corner local step (computed from labelled
312 /// grid neighbours via central or one-sided finite differences)
313 /// instead of the global cell size. Anisotropic thresholds catch
314 /// outliers in dense (perspective-foreshortened) regions that a
315 /// global threshold would miss, and stay loose enough in
316 /// distorted regions where the local cell pitch grows. Falls back
317 /// to global cell size for corners with too few labelled
318 /// neighbours.
319 ///
320 /// Default `true`. Set to `false` to restore the pre-2026-04
321 /// behaviour.
322 ///
323 /// [`line_tol_rel`]: DetectorParams::line_tol_rel
324 /// [`local_h_tol_rel`]: DetectorParams::local_h_tol_rel
325 #[serde(default = "default_validate_step_aware")]
326 pub validate_step_aware: bool,
327 /// When `> 0` and [`validate_step_aware`] is set, an extra flag
328 /// fires for corners whose local step deviates from the labelled-
329 /// set median by more than this fraction (e.g. `0.5` flags
330 /// corners whose step is < 1/(1+0.5) of the median or > 1.5×
331 /// median). Combined with a line flag, the corner is
332 /// blacklisted.
333 ///
334 /// Default `0.5`. Set to `0.0` to disable the deviation flag.
335 ///
336 /// [`validate_step_aware`]: DetectorParams::validate_step_aware
337 #[serde(default = "default_step_deviation_thresh_rel")]
338 pub validate_step_deviation_thresh_rel: f32,
339 /// Blacklist-retry cap.
340 pub max_validation_iters: u32,
341
342 // --- Stage 6.5: NoCluster rescue ---------------------------------------
343 /// Run a Stage-6.5 pass after Stage-6 boundary extension that
344 /// re-considers `Strong` / `NoCluster` corners as candidates for
345 /// empty grid cells. Reuses the same per-candidate local-H
346 /// machinery as Stage 6 but admits corners whose axes failed the
347 /// strict Stage-3 gate by a margin, gated on (a) position match
348 /// with the local-H prediction, (b) parity match against the
349 /// global cluster centers via the cheaper canonical/swapped
350 /// assignment, and (c) the axis-slot-swap edge invariant to a
351 /// labelled neighbour. Recovers corners whose axes drifted under
352 /// perspective foreshortening or radial distortion (typical
353 /// failure mode on `puzzleboard_reference/example1.png` and
354 /// `example2.png`).
355 ///
356 /// Default `true`. Set to `false` to restore the pre-Stage-6.5
357 /// behaviour.
358 #[serde(default = "default_enable_stage6_5_rescue")]
359 pub enable_stage6_5_rescue: bool,
360 /// Per-axis absolute tolerance (degrees) for [`Stage 6.5
361 /// rescue`](DetectorParams::enable_stage6_5_rescue) admission.
362 /// Wider than [`cluster_tol_deg`] (typically 12°) and the booster's
363 /// [`weak_cluster_tol_deg`] (typically 18°) because the rescue
364 /// pass is precision-anchored on local-H position match — a wide
365 /// axis gate alone cannot admit a wrong corner.
366 ///
367 /// Default `22°`: the Step-0 evidence on
368 /// `puzzleboard_reference/example1.png` and `example2.png` showed
369 /// false-NoCluster `max_d_deg` quartiles in the 12–22° range; this
370 /// value covers them without admitting structurally-misoriented
371 /// corners.
372 ///
373 /// [`cluster_tol_deg`]: DetectorParams::cluster_tol_deg
374 /// [`weak_cluster_tol_deg`]: DetectorParams::weak_cluster_tol_deg
375 #[serde(default = "default_rescue_axis_tol_deg")]
376 pub rescue_axis_tol_deg: f32,
377 /// `K` parameter for Stage-6.5 local-H fitting (same semantics as
378 /// [`stage6_local_k_nearest`]).
379 ///
380 /// [`stage6_local_k_nearest`]: DetectorParams::stage6_local_k_nearest
381 #[serde(default = "default_stage6_local_k_nearest")]
382 pub stage6_5_local_k_nearest: usize,
383 /// Position-search radius for Stage-6.5 candidate matching, as a
384 /// fraction of `cell_size`. Wider than Stage-6's `search_rel`
385 /// (default 0.40) because heavy perspective foreshortening makes
386 /// the local-H prediction at boundary cells overshoot by
387 /// significantly more than 0.40 cell. The wider gate is safe
388 /// because Stage 6.5 still enforces parity + axis match + edge
389 /// invariant + ambiguity, all of which fail on a wrongly-located
390 /// candidate.
391 ///
392 /// Default `0.8`.
393 #[serde(default = "default_rescue_search_rel")]
394 pub rescue_search_rel: f32,
395
396 // --- Stage 6.75: post-grow centre refit -------------------------------
397 /// Recompute Stage-3 cluster centres from the labelled set's axes
398 /// after Stage 6.5 / boosters converge, and re-run Stage 6 / 6.5
399 /// once with the refined centres. Recovers chessboard parity-B
400 /// corners on images where the histogram-driven Stage-3 centres
401 /// are biased downward by marker-internal corners (small3.png
402 /// case study in CLAUDE.md "Evidence-driven detector debugging").
403 ///
404 /// Default `true`.
405 #[serde(default = "default_enable_post_grow_refit")]
406 pub enable_post_grow_refit: bool,
407 /// Minimum labelled corners required for the refit to run. Below
408 /// this, the labelled set is too small to estimate the centres
409 /// reliably; the original centres are kept.
410 ///
411 /// Default `8`.
412 #[serde(default = "default_refit_min_labelled")]
413 pub refit_min_labelled: usize,
414 /// Minimum centre shift (degrees) required to trigger a second
415 /// Stage 6 / 6.5 pass. Below this, the shift cannot move a
416 /// borderline corner across the cluster gate, so the second pass
417 /// is skipped.
418 ///
419 /// Default `0.5°`.
420 #[serde(default = "default_refit_min_shift_deg")]
421 pub refit_min_shift_deg: f32,
422 /// When `true` AND [`enable_post_grow_refit`] triggered a refit,
423 /// the second pass demotes the `Labeled` set back to `Clustered`
424 /// and re-runs `grow_from_seed` with the refined centres. This
425 /// absorbs newly-Clustered corners via cardinal-neighbour BFS
426 /// propagation — reaching interior-hole / left-strip corners
427 /// that local-H extrapolation (Stage 6 / 6.5) cannot.
428 ///
429 /// Default `false`. Trade-off: a small centre shift can flip
430 /// borderline BFS slot assignments and produce SHIFT-INCONSISTENT
431 /// labelling on heavy-distortion ChArUco-style images
432 /// (`puzzleboard_reference/example2.png` regresses miss=68 with
433 /// this on). Turn on for chessboard-only datasets where the
434 /// distortion is mild and the recall lift outweighs that risk.
435 ///
436 /// [`enable_post_grow_refit`]: DetectorParams::enable_post_grow_refit
437 #[serde(default = "default_enable_post_grow_bfs_regrow")]
438 pub enable_post_grow_bfs_regrow: bool,
439 /// When `true` AND [`enable_post_grow_refit`] triggered a refit,
440 /// run a non-destructive cardinal-neighbour BFS extension
441 /// (`projective_grid::square::grow_extend::extend_from_labelled`) over
442 /// the existing labelled set with the refined centres. Walks the
443 /// labelled bbox boundary one cell at a time, predicts each cell
444 /// from cardinal labelled neighbours only (K=1 — much more
445 /// reliable than Stage 6 / 6.5's K=12 local-H when extrapolating
446 /// past the bbox edge), and attaches eligible corners via the
447 /// chessboard edge-slot-swap invariant.
448 ///
449 /// Default `true`. Replaces the destructive
450 /// [`enable_post_grow_bfs_regrow`] — same recall lift on
451 /// chessboard-only datasets without the perimeter-row losses on
452 /// heavy-distortion puzzleboard images. Both can run together,
453 /// in the order extend → regrow.
454 ///
455 /// [`enable_post_grow_refit`]: DetectorParams::enable_post_grow_refit
456 /// [`enable_post_grow_bfs_regrow`]: DetectorParams::enable_post_grow_bfs_regrow
457 #[serde(default = "default_enable_post_grow_bfs_extend")]
458 pub enable_post_grow_bfs_extend: bool,
459
460 // --- Final mandatory geometry check -----------------------------------
461 /// Line-collinearity tolerance (fraction of cell_size) for the
462 /// MANDATORY final geometry check that runs before any detection
463 /// is emitted. Must be looser than [`line_tol_rel`] because the
464 /// geometry check's role is to catch gross mislabels (diagonal /
465 /// full-cell shifts), not the borderline perspective drift the
466 /// BFS-validation loop already accepted.
467 ///
468 /// Default `0.45` of cell_size.
469 ///
470 /// [`line_tol_rel`]: DetectorParams::line_tol_rel
471 #[serde(default = "default_geometry_check_line_tol_rel")]
472 pub geometry_check_line_tol_rel: f32,
473 /// Local-H residual tolerance (fraction of cell_size) for the
474 /// MANDATORY final geometry check. A diagonal mislabel shifts a
475 /// corner by ~1.4 cell from its predicted position; a tolerance
476 /// of `0.6 × cell_size` is well below that gap while leaving the
477 /// legitimate perspective-distorted corners alone.
478 ///
479 /// Default `0.6` of cell_size.
480 #[serde(default = "default_geometry_check_local_h_tol_rel")]
481 pub geometry_check_local_h_tol_rel: f32,
482
483 // --- Stage 6: boundary extension --------------------------------------
484 /// Use the per-candidate local-homography Stage 6
485 /// (`projective_grid::square::grow_extension::extend_via_local_homography`)
486 /// instead of the single-global-H one. The local-H variant fits an
487 /// H per candidate cell from the K nearest labelled corners, gets
488 /// per-candidate trust gates, and reaches further past the bbox
489 /// because each iteration shifts the local-H window with the
490 /// growing labelled set.
491 ///
492 /// Default `false` (single-global-H, baseline today). Flip to
493 /// `true` after A/B confirms parity / superset on every blessed
494 /// image.
495 #[serde(default = "default_stage6_local_h")]
496 pub stage6_local_h: bool,
497 /// `K` parameter for [`stage6_local_h`]: the number of nearest
498 /// labelled corners (by grid Manhattan distance) used to fit each
499 /// candidate cell's local H.
500 ///
501 /// [`stage6_local_h`]: DetectorParams::stage6_local_h
502 #[serde(default = "default_stage6_local_k_nearest")]
503 pub stage6_local_k_nearest: usize,
504
505 // --- Stage 8: recall boosters -------------------------------------------
506 pub enable_line_extrapolation: bool,
507 pub enable_gap_fill: bool,
508 pub enable_component_merge: bool,
509 pub enable_weak_cluster_rescue: bool,
510 /// Cluster tolerance for "weakly clustered" corners eligible as recall-
511 /// booster candidates. Must be ≥ `cluster_tol_deg`.
512 pub weak_cluster_tol_deg: f32,
513 /// Minimum boundary-pair count required to attempt a component merge.
514 pub component_merge_min_boundary_pairs: usize,
515 /// Cap on the outer booster loop.
516 pub max_booster_iters: u32,
517
518 // --- Stage 9: output ----------------------------------------------------
519 /// Minimum labelled corners for a Detection to be emitted.
520 pub min_labeled_corners: usize,
521
522 // --- Multi-component (same-board, disconnected pieces) ------------------
523 /// Maximum number of components returned by [`crate::Detector::detect_all`].
524 ///
525 /// A chessboard can split into multiple disconnected pieces on ChArUco
526 /// scenes where markers break contiguity. Each iteration peels off one
527 /// grown grid from the unconsumed corners and re-runs seed → grow →
528 /// validate. Default `3`.
529 ///
530 /// Does NOT claim to support scenes with two separate physical boards —
531 /// one target per frame is the contract.
532 pub max_components: u32,
533}
534
535impl Default for DetectorParams {
536 fn default() -> Self {
537 Self {
538 graph_build_algorithm: GraphBuildAlgorithm::default(),
539 topological: TopologicalParams::default(),
540 component_merge: LocalMergeParams::default(),
541
542 min_corner_strength: 0.0,
543 max_fit_rms_ratio: 0.5,
544
545 num_bins: 90,
546 max_iters_2means: 10,
547 cluster_tol_deg: 12.0,
548 cluster_sigma_k: default_cluster_sigma_k(),
549 peak_min_separation_deg: 60.0,
550 // Raised from 0.05 → 0.02: with fine (2°) bins and
551 // realistic axis noise, the per-bin weight of a genuine
552 // grid-direction peak on a 500-corner scene can fall to
553 // ~2–3% of total axis-vote weight (see small1/3/4
554 // ChArUco snaps in testdata/). 0.05 was tuned for the
555 // private flagship dataset where corners are cleaner and mass
556 // concentrates tightly; 0.02 is still comfortably above
557 // pure-noise bins.
558 min_peak_weight_fraction: 0.02,
559
560 cell_size_hint: None,
561
562 seed_edge_tol: 0.25,
563 seed_axis_tol_deg: 15.0,
564 seed_close_tol: 0.25,
565
566 attach_search_rel: 0.35,
567 attach_axis_tol_deg: 15.0,
568 attach_ambiguity_factor: 1.5,
569 step_tol: 0.25,
570 edge_axis_tol_deg: 15.0,
571
572 // Raised from 0.15 → 0.18: under extreme perspective on
573 // dense boards, straight-line fits over long columns
574 // legitimately deviate from the fit by ~0.15-0.18 × s.
575 // The invariant-first contract still holds because
576 // line-failure is only one of several conditions for a
577 // blacklist (see validate::attribution).
578 line_tol_rel: 0.18,
579 line_min_members: 3,
580 local_h_tol_rel: 0.20,
581 validate_step_aware: default_validate_step_aware(),
582 validate_step_deviation_thresh_rel: default_step_deviation_thresh_rel(),
583 // Raised from 3 → 6: on dense boards with many
584 // borderline-outlier corners near the edge, the
585 // validate→blacklist→regrow loop can take 4–5 iterations
586 // to settle (see testdata/puzzleboard_reference/example1.png
587 // with ~230 labelled corners and an oscillating blacklist
588 // of 2–4 per iter). 3 was adequate for the private flagship
589 // benchmark where blacklists are typically empty on the
590 // first pass; 6 absorbs the wider real-world variance
591 // without noticeable cost (each iter is cheap).
592 max_validation_iters: 6,
593
594 stage6_local_h: default_stage6_local_h(),
595 stage6_local_k_nearest: default_stage6_local_k_nearest(),
596
597 enable_stage6_5_rescue: default_enable_stage6_5_rescue(),
598 rescue_axis_tol_deg: default_rescue_axis_tol_deg(),
599 stage6_5_local_k_nearest: default_stage6_local_k_nearest(),
600 rescue_search_rel: default_rescue_search_rel(),
601
602 enable_post_grow_refit: default_enable_post_grow_refit(),
603 refit_min_labelled: default_refit_min_labelled(),
604 refit_min_shift_deg: default_refit_min_shift_deg(),
605 enable_post_grow_bfs_regrow: default_enable_post_grow_bfs_regrow(),
606 enable_post_grow_bfs_extend: default_enable_post_grow_bfs_extend(),
607 geometry_check_line_tol_rel: default_geometry_check_line_tol_rel(),
608 geometry_check_local_h_tol_rel: default_geometry_check_local_h_tol_rel(),
609
610 enable_line_extrapolation: true,
611 enable_gap_fill: true,
612 enable_component_merge: true,
613 enable_weak_cluster_rescue: true,
614 weak_cluster_tol_deg: 18.0,
615 component_merge_min_boundary_pairs: 2,
616 max_booster_iters: 5,
617
618 min_labeled_corners: 8,
619
620 max_components: 3,
621 }
622 }
623}
624
625impl DetectorParams {
626 /// Three-config sweep preset: default + tighter + looser angular tolerances.
627 ///
628 /// Intended for `detect_chessboard_best`-style flows that try multiple
629 /// configurations and return the result with the most labelled corners.
630 /// All three configurations preserve the detector's
631 /// precision-by-construction invariants; only recall-affecting
632 /// tolerances are varied.
633 pub fn sweep_default() -> Vec<Self> {
634 let base = Self::default();
635 let tight = Self {
636 cluster_tol_deg: 9.0,
637 seed_edge_tol: 0.18,
638 attach_axis_tol_deg: 12.0,
639 ..base.clone()
640 };
641 let loose = Self {
642 cluster_tol_deg: 16.0,
643 seed_edge_tol: 0.32,
644 attach_axis_tol_deg: 18.0,
645 ..base.clone()
646 };
647 vec![base, tight, loose]
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[test]
656 fn sweep_default_has_three_configs() {
657 let configs = DetectorParams::sweep_default();
658 assert_eq!(configs.len(), 3);
659 let base = &configs[0];
660 let tight = &configs[1];
661 let loose = &configs[2];
662 assert!(tight.cluster_tol_deg < base.cluster_tol_deg);
663 assert!(loose.cluster_tol_deg > base.cluster_tol_deg);
664 assert!(tight.seed_edge_tol < base.seed_edge_tol);
665 assert!(loose.seed_edge_tol > base.seed_edge_tol);
666 }
667}