Skip to main content

locus_core/
detector.rs

1use crate::batch::{DetectionBatch, DetectionBatchView};
2use crate::config::DetectorConfig;
3use crate::decoder::{TagDecoder, family_to_decoder};
4use crate::error::DetectorError;
5use crate::image::ImageView;
6use bumpalo::Bump;
7
8/// Internal state container for the detector.
9///
10/// Owns the memory pools and reusable buffers to ensure zero-allocation
11/// in the detection hot-path.
12pub struct DetectorState {
13    /// Memory pool for ephemeral per-frame allocations.
14    pub arena: Bump,
15    /// Vectorized storage for quad candidates and results.
16    pub batch: DetectionBatch,
17    /// Reusable buffer for upscaling.
18    pub upscale_buf: Vec<u8>,
19}
20
21impl DetectorState {
22    /// Create a new internal state container.
23    #[must_use]
24    pub fn new() -> Self {
25        Self {
26            arena: Bump::new(),
27            batch: DetectionBatch::new(),
28            upscale_buf: Vec::new(),
29        }
30    }
31
32    /// Reset the state for a new frame.
33    pub fn reset(&mut self) {
34        self.arena.reset();
35    }
36}
37
38impl Default for DetectorState {
39    fn default() -> Self {
40        Self::new()
41    }
42}
43
44/// The primary entry point for the Locus perception library.
45///
46/// `Detector` encapsulates the entire detection pipeline.
47pub struct Detector {
48    config: DetectorConfig,
49    decoders: Vec<Box<dyn TagDecoder + Send + Sync>>,
50    state: DetectorState,
51}
52
53impl Default for Detector {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl Detector {
60    /// Create a new detector with the default configuration.
61    #[must_use]
62    pub fn new() -> Self {
63        Self::builder().build()
64    }
65
66    /// Returns a builder to configure a new detector.
67    #[must_use]
68    pub fn builder() -> DetectorBuilder {
69        DetectorBuilder::new()
70    }
71
72    /// Create a detector with custom pipeline configuration.
73    #[must_use]
74    pub fn with_config(config: DetectorConfig) -> Self {
75        Self::builder().with_config(config).build()
76    }
77
78    /// Access the internal state (for advanced inspection or FFI).
79    #[must_use]
80    pub fn state(&self) -> &DetectorState {
81        &self.state
82    }
83
84    /// Clear all decoders and set new ones based on tag families.
85    pub fn set_families(&mut self, families: &[crate::config::TagFamily]) {
86        self.decoders.clear();
87        for &family in families {
88            self.decoders
89                .push(crate::decoder::family_to_decoder(family));
90        }
91    }
92
93    /// Detect tags in the provided image.
94    ///
95    /// This method is the main execution pipeline.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`DetectorError`] if the input image cannot be decimated, upscaled,
100    /// or if an intermediate image view cannot be constructed.
101    #[allow(clippy::similar_names)]
102    #[allow(clippy::too_many_lines)]
103    pub fn detect(
104        &mut self,
105        img: &ImageView,
106        intrinsics: Option<&crate::pose::CameraIntrinsics>,
107        tag_size: Option<f64>,
108        pose_mode: crate::config::PoseEstimationMode,
109        debug_telemetry: bool,
110    ) -> Result<DetectionBatchView<'_>, DetectorError> {
111        self.state.reset();
112        let state = &mut self.state;
113
114        let (detection_img, _effective_scale, refinement_img) = if self.config.decimation > 1 {
115            let new_w = img.width / self.config.decimation;
116            let new_h = img.height / self.config.decimation;
117            let decimated_data = state.arena.alloc_slice_fill_copy(new_w * new_h, 0u8);
118            let decimated_img = img
119                .decimate_to(self.config.decimation, decimated_data)
120                .map_err(DetectorError::Preprocessing)?;
121            (decimated_img, 1.0 / self.config.decimation as f64, *img)
122        } else if self.config.upscale_factor > 1 {
123            let new_w = img.width * self.config.upscale_factor;
124            let new_h = img.height * self.config.upscale_factor;
125            state.upscale_buf.resize(new_w * new_h, 0);
126
127            let upscaled_img = img
128                .upscale_to(self.config.upscale_factor, &mut state.upscale_buf)
129                .map_err(DetectorError::Preprocessing)?;
130            (
131                upscaled_img,
132                self.config.upscale_factor as f64,
133                upscaled_img,
134            )
135        } else {
136            (*img, 1.0, *img)
137        };
138
139        let img = &detection_img;
140
141        // 1a. Optional bilateral pre-filtering
142        let filtered_img = if self.config.enable_bilateral {
143            let filtered = state
144                .arena
145                .alloc_slice_fill_copy(img.width * img.height, 0u8);
146            crate::filter::bilateral_filter(
147                &state.arena,
148                img,
149                filtered,
150                3, // spatial radius
151                self.config.bilateral_sigma_space,
152                self.config.bilateral_sigma_color,
153            );
154            ImageView::new(filtered, img.width, img.height, img.width)
155                .map_err(DetectorError::InvalidImage)?
156        } else {
157            *img
158        };
159
160        // 1b. Optional Laplacian sharpening
161        let sharpened_img = if self.config.enable_sharpening {
162            let sharpened = state
163                .arena
164                .alloc_slice_fill_copy(filtered_img.width * filtered_img.height, 0u8);
165            crate::filter::laplacian_sharpen(&filtered_img, sharpened);
166
167            ImageView::new(
168                sharpened,
169                filtered_img.width,
170                filtered_img.height,
171                filtered_img.width,
172            )
173            .map_err(DetectorError::InvalidImage)?
174        } else {
175            filtered_img
176        };
177
178        let binarized = state
179            .arena
180            .alloc_slice_fill_copy(img.width * img.height, 0u8);
181        let threshold_map = state
182            .arena
183            .alloc_slice_fill_copy(img.width * img.height, 0u8);
184
185        // 1. Thresholding & 2. Segmentation & 3. Quad Extraction
186        let (n, unrefined) = {
187            let engine = crate::threshold::ThresholdEngine::from_config(&self.config);
188            let tile_stats = engine.compute_tile_stats(&state.arena, &sharpened_img);
189            engine.apply_threshold_with_map(
190                &state.arena,
191                &sharpened_img,
192                &tile_stats,
193                binarized,
194                threshold_map,
195            );
196
197            // 2. Segmentation (SIMD Fused RLE + LSL)
198            let label_result = crate::simd_ccl_fusion::label_components_lsl(
199                &state.arena,
200                &sharpened_img,
201                threshold_map,
202                self.config.segmentation_connectivity
203                    == crate::config::SegmentationConnectivity::Eight,
204                self.config.quad_min_area,
205            );
206
207            // 3. Quad Extraction (SoA)
208            let (n, unrefined) = crate::quad::extract_quads_soa(
209                &mut state.batch,
210                &sharpened_img,
211                &label_result,
212                &self.config,
213                self.config.decimation,
214                &refinement_img,
215                debug_telemetry,
216            );
217
218            // 3.5 Fast-Path Funnel Gate
219            // Rejects candidates early based on boundary contrast
220            crate::funnel::apply_funnel_gate(
221                &mut state.batch,
222                n,
223                &sharpened_img,
224                &tile_stats,
225                self.config.threshold_tile_size,
226                self.config.decoder_min_contrast,
227                1.0 / self.config.decimation as f64,
228            );
229
230            (n, unrefined)
231        };
232
233        // Compute subpixel jitter if requested
234        let mut jitter_ptr = std::ptr::null();
235        let mut num_jitter = 0;
236        if let (true, Some(unrefined_pts)) = (debug_telemetry, unrefined) {
237            // Number of candidates to store jitter for (all extracted ones)
238            num_jitter = unrefined_pts.len();
239            // Store 4 corners * 2 (dx, dy) per candidate = 8 floats
240            let jitter = state.arena.alloc_slice_fill_copy(num_jitter * 8, 0.0f32);
241            for (i, unrefined_corners) in unrefined_pts.iter().enumerate() {
242                for (j, unrefined_corner) in unrefined_corners.iter().enumerate() {
243                    let dx = state.batch.corners[i][j].x - unrefined_corner.x as f32;
244                    let dy = state.batch.corners[i][j].y - unrefined_corner.y as f32;
245                    jitter[i * 8 + j * 2] = dx;
246                    jitter[i * 8 + j * 2 + 1] = dy;
247                }
248            }
249            jitter_ptr = jitter.as_ptr();
250        }
251
252        // 4. Homography Pass (SoA)
253        crate::decoder::compute_homographies_soa(
254            &state.batch.corners[0..n],
255            &state.batch.status_mask[0..n],
256            &mut state.batch.homographies[0..n],
257        );
258
259        // Optional: GWLF Refinement
260        let mut gwlf_fallback_count = 0;
261        let mut gwlf_avg_delta = 0.0f32;
262        if self.config.refinement_mode == crate::config::CornerRefinementMode::Gwlf {
263            let mut total_delta = 0.0f32;
264            let mut count = 0;
265            for i in 0..n {
266                let coarse = [
267                    [state.batch.corners[i][0].x, state.batch.corners[i][0].y],
268                    [state.batch.corners[i][1].x, state.batch.corners[i][1].y],
269                    [state.batch.corners[i][2].x, state.batch.corners[i][2].y],
270                    [state.batch.corners[i][3].x, state.batch.corners[i][3].y],
271                ];
272                if let Some((refined, covs)) = crate::gwlf::refine_quad_gwlf_with_cov(
273                    &refinement_img,
274                    &coarse,
275                    self.config.gwlf_transversal_alpha,
276                ) {
277                    for j in 0..4 {
278                        let dx = refined[j][0] - coarse[j][0];
279                        let dy = refined[j][1] - coarse[j][1];
280                        total_delta += (dx * dx + dy * dy).sqrt();
281                        count += 1;
282
283                        state.batch.corners[i][j].x = refined[j][0];
284                        state.batch.corners[i][j].y = refined[j][1];
285
286                        // Store 2x2 covariance (4 floats) for each corner
287                        state.batch.corner_covariances[i][j * 4] = covs[j][(0, 0)] as f32;
288                        state.batch.corner_covariances[i][j * 4 + 1] = covs[j][(0, 1)] as f32;
289                        state.batch.corner_covariances[i][j * 4 + 2] = covs[j][(1, 0)] as f32;
290                        state.batch.corner_covariances[i][j * 4 + 3] = covs[j][(1, 1)] as f32;
291                    }
292                } else {
293                    gwlf_fallback_count += 1;
294                }
295            }
296            if count > 0 {
297                gwlf_avg_delta = total_delta / count as f32;
298            }
299
300            // Recompute homographies after refinement
301            crate::decoder::compute_homographies_soa(
302                &state.batch.corners[0..n],
303                &state.batch.status_mask[0..n],
304                &mut state.batch.homographies[0..n],
305            );
306        }
307
308        // 5. Decoding Pass (SoA)
309        crate::decoder::decode_batch_soa(
310            &mut state.batch,
311            n,
312            &refinement_img,
313            &self.decoders,
314            &self.config,
315        );
316
317        // Partition valid candidates to the front [0..v]
318        let v = state.batch.partition(n);
319
320        // 6. Pose Refinement (SoA)
321        let (repro_errors_ptr, num_repro) = run_pose_refinement(
322            &mut state.batch,
323            &state.arena,
324            v,
325            intrinsics,
326            tag_size,
327            &refinement_img,
328            pose_mode,
329            &self.config,
330            debug_telemetry,
331        );
332
333        // Detectors return corners at pixel centers (indices + 0.5) following OpenCV conventions.
334        // No additional adjustment needed as the internal pipeline is now unbiased.
335
336        let telemetry = if debug_telemetry {
337            Some(crate::batch::TelemetryPayload {
338                binarized_ptr: binarized.as_ptr(),
339                threshold_map_ptr: threshold_map.as_ptr(),
340                subpixel_jitter_ptr: jitter_ptr,
341                num_jitter,
342                reprojection_errors_ptr: repro_errors_ptr,
343                num_reprojection: num_repro,
344                gwlf_fallback_count,
345                gwlf_avg_delta,
346                width: img.width,
347                height: img.height,
348                stride: img.width,
349            })
350        } else {
351            None
352        };
353
354        Ok(self.state.batch.view_with_telemetry(v, n, telemetry))
355    }
356
357    /// Get the current detector configuration.
358    #[must_use]
359    pub fn config(&self) -> DetectorConfig {
360        self.config
361    }
362
363    /// Returns a cloned copy of the internal detection batch.
364    /// Exclusively for benchmarking.
365    #[cfg(feature = "bench-internals")]
366    #[must_use]
367    pub fn bench_api_get_batch_cloned(&self) -> DetectionBatch {
368        let mut new_batch = DetectionBatch::new();
369        new_batch.corners.copy_from_slice(&self.state.batch.corners);
370        new_batch
371            .homographies
372            .copy_from_slice(&self.state.batch.homographies);
373        new_batch.ids.copy_from_slice(&self.state.batch.ids);
374        new_batch
375            .payloads
376            .copy_from_slice(&self.state.batch.payloads);
377        new_batch
378            .error_rates
379            .copy_from_slice(&self.state.batch.error_rates);
380        new_batch.poses.copy_from_slice(&self.state.batch.poses);
381        new_batch
382            .status_mask
383            .copy_from_slice(&self.state.batch.status_mask);
384        new_batch
385            .funnel_status
386            .copy_from_slice(&self.state.batch.funnel_status);
387        new_batch
388            .corner_covariances
389            .copy_from_slice(&self.state.batch.corner_covariances);
390        new_batch
391    }
392}
393
394/// Run pose refinement on valid candidates and optionally compute reprojection errors.
395///
396/// Returns `(reprojection_errors_ptr, num_reprojection)` for telemetry.
397#[allow(clippy::too_many_arguments)]
398fn run_pose_refinement(
399    batch: &mut crate::batch::DetectionBatch,
400    arena: &bumpalo::Bump,
401    v: usize,
402    intrinsics: Option<&crate::pose::CameraIntrinsics>,
403    tag_size: Option<f64>,
404    refinement_img: &ImageView,
405    pose_mode: crate::config::PoseEstimationMode,
406    config: &DetectorConfig,
407    debug_telemetry: bool,
408) -> (*const f32, usize) {
409    let mut repro_errors_ptr = std::ptr::null();
410    let mut num_repro = 0;
411
412    if let (Some(intr), Some(size)) = (intrinsics, tag_size) {
413        crate::pose::refine_poses_soa_with_config(
414            batch,
415            v,
416            intr,
417            size,
418            Some(refinement_img),
419            pose_mode,
420            config,
421        );
422
423        if debug_telemetry {
424            num_repro = v;
425            let repro_errors = arena.alloc_slice_fill_copy(num_repro, 0.0f32);
426
427            let model_pts = [
428                nalgebra::Vector3::new(0.0, 0.0, 0.0),
429                nalgebra::Vector3::new(size, 0.0, 0.0),
430                nalgebra::Vector3::new(size, size, 0.0),
431                nalgebra::Vector3::new(0.0, size, 0.0),
432            ];
433
434            for (i, repro_error) in repro_errors.iter_mut().enumerate().take(v) {
435                let det_pose_data = batch.poses[i].data;
436                let det_t = nalgebra::Vector3::new(
437                    f64::from(det_pose_data[0]),
438                    f64::from(det_pose_data[1]),
439                    f64::from(det_pose_data[2]),
440                );
441                let det_q = nalgebra::UnitQuaternion::from_quaternion(nalgebra::Quaternion::new(
442                    f64::from(det_pose_data[6]),
443                    f64::from(det_pose_data[3]),
444                    f64::from(det_pose_data[4]),
445                    f64::from(det_pose_data[5]),
446                ));
447
448                let pose = crate::pose::Pose {
449                    rotation: *det_q.to_rotation_matrix().matrix(),
450                    translation: det_t,
451                };
452
453                let mut sum_sq_err = 0.0;
454                for (j, model_pt) in model_pts.iter().enumerate() {
455                    let proj = pose.project(model_pt, intr);
456                    let dx = proj[0] - f64::from(batch.corners[i][j].x);
457                    let dy = proj[1] - f64::from(batch.corners[i][j].y);
458                    sum_sq_err += dx * dx + dy * dy;
459                }
460                *repro_error = (sum_sq_err / 4.0).sqrt() as f32;
461            }
462            repro_errors_ptr = repro_errors.as_ptr();
463        }
464    }
465
466    (repro_errors_ptr, num_repro)
467}
468
469/// A builder for configuring and instantiating a [`Detector`].
470pub struct DetectorBuilder {
471    config: DetectorConfig,
472    families: Vec<crate::config::TagFamily>,
473}
474
475impl DetectorBuilder {
476    /// Create a new builder.
477    #[must_use]
478    pub fn new() -> Self {
479        Self {
480            config: DetectorConfig::default(),
481            families: Vec::new(),
482        }
483    }
484
485    /// Use an existing configuration.
486    #[must_use]
487    pub fn with_config(mut self, config: DetectorConfig) -> Self {
488        self.config = config;
489        self
490    }
491
492    /// Set the decimation factor for the input image.
493    #[must_use]
494    pub fn with_decimation(mut self, decimation: usize) -> Self {
495        self.config.decimation = decimation;
496        self
497    }
498
499    /// Add a tag family to be detected.
500    #[must_use]
501    pub fn with_family(mut self, family: crate::config::TagFamily) -> Self {
502        if !self.families.contains(&family) {
503            self.families.push(family);
504        }
505        self
506    }
507
508    /// Set the thread count for parallel processing.
509    #[must_use]
510    pub fn with_threads(mut self, threads: usize) -> Self {
511        self.config.nthreads = threads;
512        self
513    }
514
515    /// Set the upscale factor for detecting small tags.
516    #[must_use]
517    pub fn with_upscale_factor(mut self, factor: usize) -> Self {
518        self.config.upscale_factor = factor;
519        self
520    }
521
522    /// Set the corner refinement mode.
523    #[must_use]
524    pub fn with_corner_refinement(mut self, mode: crate::config::CornerRefinementMode) -> Self {
525        self.config.refinement_mode = mode;
526        self
527    }
528
529    /// Set the decoding mode (Hard vs Soft).
530    #[must_use]
531    pub fn with_decode_mode(mut self, mode: crate::config::DecodeMode) -> Self {
532        self.config.decode_mode = mode;
533        self
534    }
535
536    /// Set the segmentation connectivity (4-way or 8-way).
537    #[must_use]
538    pub fn with_connectivity(
539        mut self,
540        connectivity: crate::config::SegmentationConnectivity,
541    ) -> Self {
542        self.config.segmentation_connectivity = connectivity;
543        self
544    }
545
546    /// Set the tile size for adaptive thresholding.
547    #[must_use]
548    pub fn with_threshold_tile_size(mut self, size: usize) -> Self {
549        self.config.threshold_tile_size = size;
550        self
551    }
552
553    /// Set the minimum intensity range for valid tiles.
554    #[must_use]
555    pub fn with_threshold_min_range(mut self, range: u8) -> Self {
556        self.config.threshold_min_range = range;
557        self
558    }
559
560    /// Set the constant subtracted from local mean in adaptive thresholding.
561    #[must_use]
562    pub fn with_adaptive_threshold_constant(mut self, c: i16) -> Self {
563        self.config.adaptive_threshold_constant = c;
564        self
565    }
566
567    /// Set the minimum quad area.
568    #[must_use]
569    pub fn with_quad_min_area(mut self, area: u32) -> Self {
570        self.config.quad_min_area = area;
571        self
572    }
573
574    /// Set the minimum fill ratio.
575    #[must_use]
576    pub fn with_quad_min_fill_ratio(mut self, ratio: f32) -> Self {
577        self.config.quad_min_fill_ratio = ratio;
578        self
579    }
580
581    /// Set the minimum edge alignment score.
582    #[must_use]
583    pub fn with_quad_min_edge_score(mut self, score: f64) -> Self {
584        self.config.quad_min_edge_score = score;
585        self
586    }
587
588    /// Set the maximum number of Hamming errors allowed.
589    #[must_use]
590    pub fn with_max_hamming_error(mut self, errors: u32) -> Self {
591        self.config.max_hamming_error = errors;
592        self
593    }
594
595    /// Set the minimum contrast for decoder bit classification.
596    #[must_use]
597    pub fn with_decoder_min_contrast(mut self, contrast: f64) -> Self {
598        self.config.decoder_min_contrast = contrast;
599        self
600    }
601
602    /// Set the GWLF transversal alpha.
603    #[must_use]
604    pub fn with_gwlf_transversal_alpha(mut self, alpha: f64) -> Self {
605        self.config.gwlf_transversal_alpha = alpha;
606        self
607    }
608
609    /// Set the maximum elongation allowed for a component.
610    #[must_use]
611    pub fn with_quad_max_elongation(mut self, elongation: f64) -> Self {
612        self.config.quad_max_elongation = elongation;
613        self
614    }
615
616    /// Set the minimum pixel density required to pass the moments gate.
617    #[must_use]
618    pub fn with_quad_min_density(mut self, density: f64) -> Self {
619        self.config.quad_min_density = density;
620        self
621    }
622
623    /// Set the quad extraction mode.
624    #[must_use]
625    pub fn with_quad_extraction_mode(mut self, mode: crate::config::QuadExtractionMode) -> Self {
626        self.config.quad_extraction_mode = mode;
627        self
628    }
629
630    /// Enable or disable Laplacian sharpening.
631    #[must_use]
632    pub fn with_sharpening(mut self, enable: bool) -> Self {
633        self.config.enable_sharpening = enable;
634        self
635    }
636
637    /// Build the [`Detector`] instance.
638    #[must_use]
639    pub fn build(self) -> Detector {
640        let mut decoders = Vec::new();
641        let families = if self.families.is_empty() {
642            vec![crate::config::TagFamily::AprilTag36h11]
643        } else {
644            self.families
645        };
646        for family in families {
647            decoders.push(family_to_decoder(family));
648        }
649
650        Detector {
651            config: self.config,
652            decoders,
653            state: DetectorState::new(),
654        }
655    }
656}
657
658impl Default for DetectorBuilder {
659    fn default() -> Self {
660        Self::new()
661    }
662}