ringgrid 0.10.1

Pure-Rust detector for coded ring calibration targets
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
use crate::conic::Ellipse;
use crate::detector::{DetectionSource, FitMetrics, MarkerRecord};
use crate::marker::DecodeMetrics;
use crate::proposal::Proposal;
use std::cmp::Ordering;

/// Coordinate frame used by serialized detection outputs.
///
/// - `Image` — raw distorted pixel coordinates.
/// - `Working` — undistorted coordinates produced by a [`PixelMapper`](crate::PixelMapper).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DetectionFrame {
    /// Raw image pixel coordinates.
    #[default]
    Image,
    /// Working-frame (undistorted) pixel coordinates.
    Working,
}

/// A detected marker: refined geometry plus the decoded ID.
///
/// This is the slim, stable primary output. Algorithm internals (fit metrics,
/// decode metrics, raw edge sample points, stage provenance) live in the
/// separate opt-in [`MarkerDiagnostics`] channel — request them via
/// [`Detector::detect_with_diagnostics`](crate::Detector::detect_with_diagnostics).
///
/// The `center` field is always in image-pixel coordinates, regardless of
/// whether a [`PixelMapper`](crate::PixelMapper) was used. When a mapper is
/// active, `center_mapped` provides the working-frame (undistorted)
/// coordinates. `board_xy_mm` provides board-space marker coordinates in
/// millimeters when the decoded `id` is valid for the active
/// [`TargetLayout`](crate::TargetLayout). Ellipses are in the working frame when
/// a mapper is active.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct DetectedMarker {
    /// Decoded marker ID (codebook index), or None if decoding was rejected.
    ///
    /// Plain (uncoded) targets carry no IDs; consumers key on
    /// [`grid_coord`](Self::grid_coord) instead.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<usize>,
    /// Lattice cell coordinate `[u, v]` assigned to this marker, when grid
    /// assignment succeeded.
    ///
    /// For coded targets this is the board-frame cell coordinate of the decoded
    /// ID (hex targets use axial coordinates). For plain targets the frame is
    /// given by [`DetectionResult::board_frame`]: board-frame when `Absolute`,
    /// canonical relative frame when `RelativeCanonical`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub grid_coord: Option<[i32; 2]>,
    /// Combined detection + decode confidence in [0, 1].
    pub confidence: f32,
    /// Marker center in raw image pixel coordinates.
    ///
    /// This field is always image-space, independent of mapper usage.
    pub center: [f64; 2],
    /// Marker center in mapper working coordinates, when a mapper is active.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub center_mapped: Option<[f64; 2]>,
    /// Marker center on the physical board in millimeters `[x_mm, y_mm]`.
    ///
    /// Populated when `id` is present and valid for the active board layout.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub board_xy_mm: Option<[f64; 2]>,
    /// Outer ellipse parameters.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ellipse_outer: Option<Ellipse>,
    /// Inner ellipse parameters.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ellipse_inner: Option<Ellipse>,
}

/// Detailed per-marker algorithm internals.
///
/// This is the opt-in diagnostics counterpart to [`DetectedMarker`]. Each
/// `MarkerDiagnostics` is positionally aligned 1:1 with the corresponding
/// [`DetectedMarker`] in [`DetectionResult::detected_markers`]: the
/// `MarkerDiagnostics` at index `i` in [`DetectionDiagnostics::markers`]
/// describes the `DetectedMarker` at index `i`.
///
/// Returned only via
/// [`Detector::detect_with_diagnostics`](crate::Detector::detect_with_diagnostics).
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct MarkerDiagnostics {
    /// Fit quality metrics (edge sampling and ellipse fit).
    pub fit: FitMetrics,
    /// Decode metrics (present if decoding was attempted).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub decode: Option<DecodeMetrics>,
    /// Pipeline stage that produced this marker.
    pub source: DetectionSource,
    /// Raw sub-pixel outer edge inlier points used for ellipse fitting.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub edge_points_outer: Option<Vec<[f64; 2]>>,
    /// Raw sub-pixel inner edge inlier points used for ellipse fitting.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub edge_points_inner: Option<Vec<[f64; 2]>>,
}

/// Reference frame of grid-labeled outputs (`grid_coord`, `board_xy_mm`, and
/// the homography's source plane).
///
/// Coded targets are always `Absolute`: decoded IDs anchor markers to physical
/// board cells. Plain targets are `Absolute` only when the origin fiducials
/// resolved the board origin and orientation; otherwise labeling is only known
/// up to the board's rotational symmetry and a lattice translation, and outputs
/// stay in a canonical relative frame (`board_xy_mm` is omitted — a wrong
/// millimeter position is worse than none).
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BoardFrame {
    /// Outputs are absolute board-frame values.
    Absolute,
    /// Origin unresolved: `grid_coord` is in a canonical relative frame
    /// (non-negative, `+u` ≈ image `+x`); `board_xy_mm` is absent.
    RelativeCanonical,
}

impl BoardFrame {
    /// `true` when outputs are anchored to the physical board origin.
    pub fn origin_resolved(self) -> bool {
        matches!(self, Self::Absolute)
    }
}

/// Detection result for a single image.
///
/// Returned by [`Detector::detect`](crate::Detector::detect) and
/// [`Detector::detect_with_mapper`](crate::Detector::detect_with_mapper).
/// Contains detected markers, frame metadata, and an optional
/// board-to-image homography. Serializable to JSON via `serde`.
///
/// Algorithm internals and RANSAC statistics are not part of this type; obtain
/// them through the opt-in [`DetectionDiagnostics`] channel via
/// [`Detector::detect_with_diagnostics`](crate::Detector::detect_with_diagnostics).
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct DetectionResult {
    /// Detected markers.
    pub detected_markers: Vec<DetectedMarker>,
    /// Coordinate frame of `DetectedMarker.center`.
    pub center_frame: DetectionFrame,
    /// Coordinate frame of `homography` output.
    pub homography_frame: DetectionFrame,
    /// Image dimensions [width, height].
    pub image_size: [u32; 2],
    /// Fitted board-to-output-frame homography (3x3, row-major), if available.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub homography: Option<[[f64; 3]; 3]>,
    /// Reference frame of `grid_coord` / `board_xy_mm` / `homography` outputs.
    ///
    /// `None` when no grid assignment took place (no markers labeled).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub board_frame: Option<BoardFrame>,
    /// Whether every target cell was detected.
    ///
    /// `Some(true)` when grid assignment ran and all `TargetLayout` cells were
    /// labeled, `Some(false)` when some are missing, and `None` when nothing was
    /// labeled (completeness is undefined). This is the success criterion for
    /// plain (uncoded) targets without origin dots, whose orientation can only
    /// be resolved from a fully-detected board; see
    /// [`DetectConfig::require_complete_board`](crate::DetectConfig::require_complete_board).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub board_complete: Option<bool>,
    /// Estimated self-undistort division model, if self-undistort was run.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub self_undistort: Option<crate::pixelmap::SelfUndistortResult>,
}

/// Opt-in detection diagnostics for debugging and tuning.
///
/// Returned alongside a [`DetectionResult`] by
/// [`Detector::detect_with_diagnostics`](crate::Detector::detect_with_diagnostics).
/// Carries per-marker algorithm internals and homography RANSAC statistics.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct DetectionDiagnostics {
    /// Per-marker algorithm internals.
    ///
    /// Positionally aligned 1:1 with
    /// [`DetectionResult::detected_markers`]: `markers[i]` describes the
    /// `DetectedMarker` at the same index `i`.
    pub markers: Vec<MarkerDiagnostics>,
    /// Homography RANSAC statistics, if a homography was fitted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ransac: Option<crate::homography::RansacStats>,
    /// Per-stage wall-clock timing for single-pass detection, if measured.
    ///
    /// Populated by single-pass
    /// [`Detector::detect_with_diagnostics`](crate::Detector::detect_with_diagnostics);
    /// `None` for multi-scale / adaptive detection, which fans out across scale
    /// tiers and has no single three-stage breakdown.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub timings: Option<StageTimings>,
}

/// Wall-clock timing for the major single-pass detection stages, in milliseconds.
///
/// Returned inside [`DetectionDiagnostics`] from single-pass
/// [`Detector::detect_with_diagnostics`](crate::Detector::detect_with_diagnostics).
/// The three stage fields sum to approximately [`total_ms`](Self::total_ms);
/// `total_ms` additionally captures minor inter-stage overhead.
///
/// Timing uses a platform-portable clock and is therefore available on native
/// and WASM targets alike.
#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct StageTimings {
    /// Proposal stage (center detection) wall-clock, in milliseconds.
    pub proposal_ms: f64,
    /// Fit + decode stage (outer/inner ellipse fit, codebook decode, dedup),
    /// in milliseconds.
    pub fit_decode_ms: f64,
    /// Finalize stage (center correction, ID correction, global homography
    /// filter, completion, final H refit) wall-clock, in milliseconds.
    pub finalize_ms: f64,
    /// End-to-end wall-clock for the whole single-pass detection, in milliseconds.
    pub total_ms: f64,
}

impl DetectionResult {
    /// Construct an empty result for an image with the provided dimensions.
    pub fn empty(width: u32, height: u32) -> Self {
        Self {
            detected_markers: Vec::new(),
            center_frame: DetectionFrame::Image,
            homography_frame: DetectionFrame::Image,
            image_size: [width, height],
            homography: None,
            board_frame: None,
            board_complete: None,
            self_undistort: None,
        }
    }
}

/// Internal pipeline result threaded through the detection stages.
///
/// Carries the rich [`MarkerRecord`] working markers and the homography RANSAC
/// statistics. Converted into the public `(DetectionResult, DetectionDiagnostics)`
/// pair at the [`Detector::detect`](crate::Detector::detect) boundary via
/// [`PipelineResult::split`].
#[derive(Debug, Clone, Default)]
pub(crate) struct PipelineResult {
    /// Rich working markers.
    pub markers: Vec<MarkerRecord>,
    /// Coordinate frame of `MarkerRecord.center`.
    pub center_frame: DetectionFrame,
    /// Coordinate frame of `homography` output.
    pub homography_frame: DetectionFrame,
    /// Image dimensions [width, height].
    pub image_size: [u32; 2],
    /// Fitted board-to-output-frame homography (3x3, row-major), if available.
    pub homography: Option<[[f64; 3]; 3]>,
    /// Reference frame of grid-labeled outputs; `None` when nothing was labeled.
    pub board_frame: Option<BoardFrame>,
    /// Homography RANSAC statistics, if a homography was fitted.
    pub ransac: Option<crate::homography::RansacStats>,
    /// Estimated self-undistort division model, if self-undistort was run.
    pub self_undistort: Option<crate::pixelmap::SelfUndistortResult>,
    /// Per-stage wall-clock timing, populated by single-pass detection.
    pub timings: Option<StageTimings>,
}

impl PipelineResult {
    /// Split a finished pipeline result into the public slim
    /// [`DetectionResult`] plus its [`DetectionDiagnostics`] counterpart.
    ///
    /// The two `Vec`s remain positionally aligned 1:1.
    pub(crate) fn split(self) -> (DetectionResult, DetectionDiagnostics) {
        let mut detected_markers = Vec::with_capacity(self.markers.len());
        let mut diagnostics = Vec::with_capacity(self.markers.len());
        for record in self.markers {
            let (marker, diag) = split_marker_record(record);
            detected_markers.push(marker);
            diagnostics.push(diag);
        }

        let result = DetectionResult {
            detected_markers,
            center_frame: self.center_frame,
            homography_frame: self.homography_frame,
            image_size: self.image_size,
            homography: self.homography,
            board_frame: self.board_frame,
            // Set by `Detector`'s finish step, which knows the target cell count.
            board_complete: None,
            self_undistort: self.self_undistort,
        };
        let diag = DetectionDiagnostics {
            markers: diagnostics,
            ransac: self.ransac,
            timings: self.timings,
        };
        (result, diag)
    }
}

/// Split one rich [`MarkerRecord`] into the public slim [`DetectedMarker`] and
/// its [`MarkerDiagnostics`] counterpart. Single source of truth for the
/// field partition.
fn split_marker_record(record: MarkerRecord) -> (DetectedMarker, MarkerDiagnostics) {
    let MarkerRecord {
        id,
        grid_coord,
        confidence,
        center,
        center_mapped,
        board_xy_mm,
        ellipse_outer,
        ellipse_inner,
        edge_points_outer,
        edge_points_inner,
        fit,
        decode,
        source,
    } = record;

    let marker = DetectedMarker {
        id,
        grid_coord,
        confidence,
        center,
        center_mapped,
        board_xy_mm,
        ellipse_outer,
        ellipse_inner,
    };
    let diagnostics = MarkerDiagnostics {
        fit,
        decode,
        source,
        edge_points_outer,
        edge_points_inner,
    };
    (marker, diagnostics)
}

/// Build pass-2 seed proposals from pass-1 detection markers.
///
/// Used internally for two-pass / self-undistort seeding.
///
/// Ordering is deterministic and confidence-ranked:
/// 1. higher `confidence` first (`NaN`/non-finite treated as `-inf`)
/// 2. decoded IDs before undecoded markers
/// 3. decoded ID ascending
/// 4. center `x` ascending, then `y` ascending
/// 5. original marker index (stable final tie-break)
pub(crate) fn seed_proposals(markers: &[MarkerRecord], max_seeds: Option<usize>) -> Vec<Proposal> {
    let mut candidates: Vec<SeedCandidate> = markers
        .iter()
        .enumerate()
        .filter_map(|(source_index, marker)| {
            let x = marker.center[0] as f32;
            let y = marker.center[1] as f32;
            if !(x.is_finite() && y.is_finite()) {
                return None;
            }

            let score = if marker.confidence.is_finite() {
                marker.confidence
            } else {
                f32::NEG_INFINITY
            };

            Some(SeedCandidate {
                proposal: Proposal { x, y, score },
                marker_id: marker.id,
                source_index,
            })
        })
        .collect();

    candidates.sort_by(compare_seed_candidate);

    let max = max_seeds.unwrap_or(candidates.len());
    candidates.truncate(max.min(candidates.len()));
    candidates.into_iter().map(|c| c.proposal).collect()
}

fn compare_seed_candidate(a: &SeedCandidate, b: &SeedCandidate) -> Ordering {
    b.proposal
        .score
        .total_cmp(&a.proposal.score)
        .then_with(|| b.marker_id.is_some().cmp(&a.marker_id.is_some()))
        .then_with(|| match (a.marker_id, b.marker_id) {
            (Some(aid), Some(bid)) => aid.cmp(&bid),
            _ => Ordering::Equal,
        })
        .then_with(|| a.proposal.x.total_cmp(&b.proposal.x))
        .then_with(|| a.proposal.y.total_cmp(&b.proposal.y))
        .then_with(|| a.source_index.cmp(&b.source_index))
}

#[derive(Clone)]
struct SeedCandidate {
    proposal: Proposal,
    marker_id: Option<usize>,
    source_index: usize,
}

#[cfg(test)]
mod tests {
    use super::*;

    fn marker(id: Option<usize>, confidence: f32, center: [f64; 2]) -> MarkerRecord {
        MarkerRecord {
            id,
            confidence,
            center,
            ..MarkerRecord::default()
        }
    }

    #[test]
    fn seed_proposals_are_confidence_ordered_and_truncated() {
        let markers = vec![
            marker(Some(11), 0.1, [10.0, 10.0]),
            marker(Some(12), 0.8, [20.0, 20.0]),
            marker(Some(13), 0.6, [30.0, 30.0]),
        ];

        let proposals = seed_proposals(&markers, Some(2));
        assert_eq!(proposals.len(), 2);
        assert_eq!(proposals[0].score, 0.8);
        assert_eq!(proposals[1].score, 0.6);
    }

    #[test]
    fn seed_proposals_tie_break_is_deterministic() {
        let markers = vec![
            marker(Some(7), 0.8, [20.0, 10.0]),
            marker(None, 0.8, [1.0, 1.0]),
            marker(Some(3), 0.8, [8.0, 8.0]),
            marker(Some(3), 0.8, [4.0, 9.0]),
            marker(Some(2), 0.95, [100.0, 100.0]),
        ];

        let permuted = vec![
            markers[1].clone(),
            markers[3].clone(),
            markers[4].clone(),
            markers[0].clone(),
            markers[2].clone(),
        ];

        let pa = seed_proposals(&markers, None);
        let pb = seed_proposals(&permuted, None);
        assert_eq!(pa.len(), 5);
        let pa_xy_score: Vec<(f32, f32, f32)> = pa.iter().map(|p| (p.x, p.y, p.score)).collect();
        let pb_xy_score: Vec<(f32, f32, f32)> = pb.iter().map(|p| (p.x, p.y, p.score)).collect();
        assert_eq!(pa_xy_score, pb_xy_score);

        let ordered_centers: Vec<[f32; 2]> = pa.iter().map(|p| [p.x, p.y]).collect();
        assert_eq!(
            ordered_centers,
            vec![
                [100.0, 100.0], // highest confidence
                [4.0, 9.0],     // same confidence, lower id then lower x
                [8.0, 8.0],
                [20.0, 10.0],
                [1.0, 1.0], // undecoded marker sorted last in confidence tie
            ]
        );
    }

    #[test]
    fn seed_proposals_skip_non_finite_centers_and_demote_non_finite_confidence() {
        let markers = vec![
            marker(Some(1), 0.7, [10.0, 10.0]),
            marker(Some(2), f32::NAN, [11.0, 11.0]),
            marker(Some(3), 0.9, [f64::NAN, 12.0]),
        ];

        let proposals = seed_proposals(&markers, None);
        assert_eq!(proposals.len(), 2);
        assert_eq!(proposals[0].score, 0.7);
        assert!(proposals[1].score.is_infinite() && proposals[1].score.is_sign_negative());
    }

    #[test]
    fn split_partitions_records_into_aligned_slim_and_diagnostics() {
        let mut record = marker(Some(5), 0.9, [12.0, 34.0]);
        record.source = DetectionSource::Completion;
        record.edge_points_outer = Some(vec![[1.0, 2.0]]);
        record.fit.n_points_outer = 7;

        let pipeline = PipelineResult {
            markers: vec![record],
            image_size: [640, 480],
            ..PipelineResult::default()
        };
        let (result, diagnostics) = pipeline.split();

        assert_eq!(result.detected_markers.len(), diagnostics.markers.len());
        assert_eq!(result.detected_markers[0].id, Some(5));
        assert_eq!(result.detected_markers[0].center, [12.0, 34.0]);
        assert_eq!(diagnostics.markers[0].source, DetectionSource::Completion);
        assert_eq!(diagnostics.markers[0].fit.n_points_outer, 7);
        assert_eq!(
            diagnostics.markers[0].edge_points_outer,
            Some(vec![[1.0, 2.0]])
        );
    }
}