Skip to main content

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}