Skip to main content

locus_core/
batch.rs

1/// The maximum number of candidates in a single batch.
2pub(crate) const MAX_CANDIDATES: usize = 1024;
3
4/// A 2D point with subpixel precision (f32).
5#[derive(Debug, Clone, Copy, Default)]
6#[repr(C)]
7pub struct Point2f {
8    /// X coordinate.
9    pub x: f32,
10    /// Y coordinate.
11    pub y: f32,
12}
13
14/// A 3x3 homography matrix (f32).
15#[derive(Debug, Clone, Copy, Default)]
16#[repr(C, align(32))]
17pub struct Matrix3x3 {
18    /// The matrix elements in row-major or column-major format (internal use).
19    pub data: [f32; 9],
20    /// Padding to ensure 64-byte size (cache line) and alignment for SIMD.
21    pub padding: [f32; 7],
22}
23
24/// A 6D pose representing translation and rotation (unit quaternion).
25#[derive(Debug, Clone, Copy, Default)]
26#[repr(C, align(32))]
27pub struct Pose6D {
28    /// Translation (x, y, z) and Rotation as a unit quaternion (x, y, z, w).
29    pub data: [f32; 7],
30    /// Padding to 32-byte alignment.
31    pub padding: f32,
32}
33
34/// The lifecycle state of a candidate in the detection pipeline.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36#[repr(u8)]
37pub enum CandidateState {
38    /// No candidate at this index.
39    #[default]
40    Empty = 0,
41    /// Quad vertices have been extracted.
42    Active = 1,
43    /// Failed to decode the marker payload.
44    FailedDecode = 2,
45    /// Successfully decoded and mathematically verified.
46    Valid = 3,
47}
48
49/// The status of a candidate in the fast-path decoding funnel.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51#[repr(u8)]
52pub enum FunnelStatus {
53    /// Candidate has not yet been processed by the funnel.
54    #[default]
55    None = 0,
56    /// Candidate passed the O(1) contrast gate.
57    PassedContrast = 1,
58    /// Candidate rejected by the O(1) contrast gate.
59    RejectedContrast = 2,
60    /// Candidate rejected during homography DDA or SIMD sampling.
61    RejectedSampling = 3,
62}
63
64/// A batched state container for fiducial marker detections using a Structure of Arrays (SoA) layout.
65/// This structure is designed for high-performance SIMD processing and zero heap allocations.
66#[repr(C, align(32))]
67pub struct DetectionBatch {
68    /// Flattened array of sub-pixel quad vertices (4 corners per candidate).
69    pub corners: [[Point2f; 4]; MAX_CANDIDATES],
70    /// The 3x3 projection matrices.
71    pub homographies: [Matrix3x3; MAX_CANDIDATES],
72    /// The decoded IDs of the tags.
73    pub ids: [u32; MAX_CANDIDATES],
74    /// The extracted bitstrings.
75    pub payloads: [u64; MAX_CANDIDATES],
76    /// The MSE or Log-Likelihood Ratio confidence scores.
77    pub error_rates: [f32; MAX_CANDIDATES],
78    /// Translation vectors and unit quaternions.
79    pub poses: [Pose6D; MAX_CANDIDATES],
80    /// A dense byte-array tracking the lifecycle of each candidate.
81    pub status_mask: [CandidateState; MAX_CANDIDATES],
82    /// Detailed status from the fast-path funnel.
83    pub funnel_status: [FunnelStatus; MAX_CANDIDATES],
84    /// Four 2x2 corner covariance matrices per quad (16 floats).
85    /// Layout: [c0_xx, c0_xy, c0_yx, c0_yy, c1_xx, ...]
86    pub corner_covariances: [[f32; 16]; MAX_CANDIDATES],
87}
88
89impl DetectionBatch {
90    /// Creates a new DetectionBatch with all fields initialized to zero (Empty state).
91    #[must_use]
92    #[allow(clippy::large_stack_arrays)]
93    pub fn new() -> Self {
94        *Box::new(Self {
95            corners: [[Point2f { x: 0.0, y: 0.0 }; 4]; MAX_CANDIDATES],
96            homographies: [Matrix3x3 {
97                data: [0.0; 9],
98                padding: [0.0; 7],
99            }; MAX_CANDIDATES],
100            ids: [0; MAX_CANDIDATES],
101            payloads: [0; MAX_CANDIDATES],
102            error_rates: [0.0; MAX_CANDIDATES],
103            poses: [Pose6D {
104                data: [0.0; 7],
105                padding: 0.0,
106            }; MAX_CANDIDATES],
107            status_mask: [CandidateState::Empty; MAX_CANDIDATES],
108            funnel_status: [FunnelStatus::None; MAX_CANDIDATES],
109            corner_covariances: [[0.0; 16]; MAX_CANDIDATES],
110        })
111    }
112    /// Returns the maximum capacity of the batch.
113    #[must_use]
114    #[allow(clippy::unused_self)]
115    pub fn capacity(&self) -> usize {
116        MAX_CANDIDATES
117    }
118
119    /// Partitions the batch so that all `Valid` candidates are at the front `[0..V]`.
120    /// Returns the number of valid candidates `V`.
121    pub fn partition(&mut self, n: usize) -> usize {
122        let mut v = 0;
123        let n_clamped = n.min(MAX_CANDIDATES);
124
125        // Two-pointer partition:
126        // Move Valid candidates to [0..v]
127        // Move everything else to [v..n_clamped]
128        for i in 0..n_clamped {
129            if self.status_mask[i] == CandidateState::Valid {
130                if i != v {
131                    // Swap index i with index v across all parallel arrays.
132                    self.corners.swap(i, v);
133                    self.homographies.swap(i, v);
134                    self.ids.swap(i, v);
135                    self.payloads.swap(i, v);
136                    self.error_rates.swap(i, v);
137                    self.poses.swap(i, v);
138                    self.status_mask.swap(i, v);
139                    self.funnel_status.swap(i, v);
140                    self.corner_covariances.swap(i, v);
141                }
142                v += 1;
143            }
144        }
145        v
146    }
147
148    /// Reassemble the batched SoA data into a list of discrete `Detection` objects.
149    #[must_use]
150    #[allow(clippy::cast_sign_loss)]
151    pub fn reassemble(&self, v: usize) -> Vec<crate::Detection> {
152        let mut detections = Vec::with_capacity(v);
153        for i in 0..v {
154            let corners = [
155                [
156                    f64::from(self.corners[i][0].x),
157                    f64::from(self.corners[i][0].y),
158                ],
159                [
160                    f64::from(self.corners[i][1].x),
161                    f64::from(self.corners[i][1].y),
162                ],
163                [
164                    f64::from(self.corners[i][2].x),
165                    f64::from(self.corners[i][2].y),
166                ],
167                [
168                    f64::from(self.corners[i][3].x),
169                    f64::from(self.corners[i][3].y),
170                ],
171            ];
172
173            let center = [
174                (corners[0][0] + corners[1][0] + corners[2][0] + corners[3][0]) / 4.0,
175                (corners[0][1] + corners[1][1] + corners[2][1] + corners[3][1]) / 4.0,
176            ];
177
178            // Reconstruct Pose if available (Z translation > 0)
179            let pose = if self.poses[i].data[2] > 0.0 {
180                let d = self.poses[i].data;
181                // layout: [tx, ty, tz, qx, qy, qz, qw]
182                let t = nalgebra::Vector3::new(f64::from(d[0]), f64::from(d[1]), f64::from(d[2]));
183                let q = nalgebra::UnitQuaternion::from_quaternion(nalgebra::Quaternion::new(
184                    f64::from(d[6]), // w
185                    f64::from(d[3]), // x
186                    f64::from(d[4]), // y
187                    f64::from(d[5]), // z
188                ));
189                Some(crate::pose::Pose {
190                    rotation: q.to_rotation_matrix().into_inner(),
191                    translation: t,
192                })
193            } else {
194                None
195            };
196
197            detections.push(crate::Detection {
198                id: self.ids[i],
199                center,
200                corners,
201                hamming: self.error_rates[i] as u32,
202                rotation: 0,
203                decision_margin: 0.0,
204                bits: self.payloads[i],
205                pose,
206                pose_covariance: None,
207            });
208        }
209        detections
210    }
211}
212
213/// Helper function to partition a batch, moving all valid candidates to the front.
214#[cfg(feature = "bench-internals")]
215pub fn partition_batch_soa(batch: &mut DetectionBatch, n: usize) -> usize {
216    batch.partition(n)
217}
218
219/// Metadata for zero-copy telemetry extraction.
220#[derive(Debug, Clone, Copy)]
221pub struct TelemetryPayload {
222    /// Pointer to the binarized image buffer.
223    pub binarized_ptr: *const u8,
224    /// Pointer to the threshold map buffer.
225    pub threshold_map_ptr: *const u8,
226    /// Pointer to subpixel jitter data [4 corners * 2 (dx, dy)] per valid candidate.
227    /// Allocated in the detector's arena.
228    pub subpixel_jitter_ptr: *const f32,
229    /// Number of valid candidates jitter data is available for.
230    pub num_jitter: usize,
231    /// Pointer to reprojection RMSE values per valid candidate.
232    /// Allocated in the detector's arena.
233    pub reprojection_errors_ptr: *const f32,
234    /// Number of valid candidates reprojection data is available for.
235    pub num_reprojection: usize,
236    /// Number of quads that fell back to coarse corners during GWLF.
237    pub gwlf_fallback_count: usize,
238    /// Average Euclidean distance (delta) of GWLF refinement (pixels).
239    pub gwlf_avg_delta: f32,
240    /// Width of the buffers.
241    pub width: usize,
242    /// Height of the buffers.
243    pub height: usize,
244    /// Stride of the buffers.
245    pub stride: usize,
246}
247
248// SAFETY: TelemetryPayload contains raw pointers to the detector's arena.
249// These pointers are stable for the duration of the frame processing and
250// are only accessed from the Python thread while the arena is still alive.
251#[allow(unsafe_code)]
252unsafe impl Send for TelemetryPayload {}
253
254// SAFETY: TelemetryPayload is read-only from the Python thread and does not
255// contain any interior mutability.
256#[allow(unsafe_code)]
257unsafe impl Sync for TelemetryPayload {}
258
259/// A lightweight, borrowed view of the detection results.
260///
261/// This struct holds slices to the active elements in a [`DetectionBatch`].
262/// It avoids heap allocations and provides efficient access to detection data.
263#[derive(Debug, Clone, Copy)]
264pub struct DetectionBatchView<'a> {
265    /// Decoded IDs of the markers.
266    pub ids: &'a [u32],
267    /// Refined corners in image coordinates.
268    pub corners: &'a [[Point2f; 4]],
269    /// Computed homography matrices.
270    pub homographies: &'a [Matrix3x3],
271    /// Decoded bitstrings (paylods).
272    pub payloads: &'a [u64],
273    /// Confidence scores or error rates.
274    pub error_rates: &'a [f32],
275    /// 3D poses (rotation + translation).
276    pub poses: &'a [Pose6D],
277    /// Corner covariances (Fisher information priors).
278    pub corner_covariances: &'a [[f32; 16]],
279    /// Optional telemetry data for intermediate images.
280    pub telemetry: Option<TelemetryPayload>,
281    /// Corners of quads that were extracted but rejected during decoding or verification.
282    pub rejected_corners: &'a [[Point2f; 4]],
283    /// Error rates (e.g. Hamming distance) for rejected quads.
284    pub rejected_error_rates: &'a [f32],
285    /// Detailed status from the fast-path funnel for rejected quads.
286    pub rejected_funnel_status: &'a [FunnelStatus],
287}
288
289impl DetectionBatchView<'_> {
290    /// Returns the number of detections in the view.
291    #[must_use]
292    pub fn len(&self) -> usize {
293        self.ids.len()
294    }
295
296    /// Returns true if the view contains no detections.
297    #[must_use]
298    pub fn is_empty(&self) -> bool {
299        self.ids.is_empty()
300    }
301
302    /// Materialise an owned copy of the detection results from this view's slices.
303    ///
304    /// This is the correct escape hatch for pool-based concurrent detection: call
305    /// `reassemble_owned()`, then drop the view, then return the `FrameContext` to
306    /// the pool — the view borrows from the context, so extraction must happen first.
307    #[must_use]
308    #[allow(clippy::cast_sign_loss)]
309    pub fn reassemble_owned(&self) -> Vec<crate::Detection> {
310        let v = self.ids.len();
311        let mut detections = Vec::with_capacity(v);
312        for i in 0..v {
313            let corners = [
314                [
315                    f64::from(self.corners[i][0].x),
316                    f64::from(self.corners[i][0].y),
317                ],
318                [
319                    f64::from(self.corners[i][1].x),
320                    f64::from(self.corners[i][1].y),
321                ],
322                [
323                    f64::from(self.corners[i][2].x),
324                    f64::from(self.corners[i][2].y),
325                ],
326                [
327                    f64::from(self.corners[i][3].x),
328                    f64::from(self.corners[i][3].y),
329                ],
330            ];
331            let center = [
332                (corners[0][0] + corners[1][0] + corners[2][0] + corners[3][0]) / 4.0,
333                (corners[0][1] + corners[1][1] + corners[2][1] + corners[3][1]) / 4.0,
334            ];
335            let pose = if self.poses[i].data[2] > 0.0 {
336                let d = self.poses[i].data;
337                let t = nalgebra::Vector3::new(f64::from(d[0]), f64::from(d[1]), f64::from(d[2]));
338                let q = nalgebra::UnitQuaternion::from_quaternion(nalgebra::Quaternion::new(
339                    f64::from(d[6]),
340                    f64::from(d[3]),
341                    f64::from(d[4]),
342                    f64::from(d[5]),
343                ));
344                Some(crate::pose::Pose {
345                    rotation: q.to_rotation_matrix().into_inner(),
346                    translation: t,
347                })
348            } else {
349                None
350            };
351            detections.push(crate::Detection {
352                id: self.ids[i],
353                center,
354                corners,
355                hamming: self.error_rates[i] as u32,
356                rotation: 0,
357                decision_margin: 0.0,
358                bits: self.payloads[i],
359                pose,
360                pose_covariance: None,
361            });
362        }
363        detections
364    }
365}
366
367impl DetectionBatch {
368    /// Returns a borrowed view of the first `v` candidates in the batch.
369    #[must_use]
370    pub fn view(&self, v: usize) -> DetectionBatchView<'_> {
371        let n = v.min(MAX_CANDIDATES);
372        DetectionBatchView {
373            ids: &self.ids[..n],
374            corners: &self.corners[..n],
375            homographies: &self.homographies[..n],
376            payloads: &self.payloads[..n],
377            error_rates: &self.error_rates[..n],
378            poses: &self.poses[..n],
379            corner_covariances: &self.corner_covariances[..n],
380            telemetry: None,
381            rejected_corners: &[],
382            rejected_error_rates: &[],
383            rejected_funnel_status: &[],
384        }
385    }
386
387    /// Returns a borrowed view with telemetry data.
388    #[must_use]
389    pub fn view_with_telemetry(
390        &self,
391        v: usize,
392        n: usize,
393        telemetry: Option<TelemetryPayload>,
394    ) -> DetectionBatchView<'_> {
395        let v_clamped = v.min(MAX_CANDIDATES);
396        let n_clamped = n.min(MAX_CANDIDATES);
397        DetectionBatchView {
398            ids: &self.ids[..v_clamped],
399            corners: &self.corners[..v_clamped],
400            homographies: &self.homographies[..v_clamped],
401            payloads: &self.payloads[..v_clamped],
402            error_rates: &self.error_rates[..v_clamped],
403            poses: &self.poses[..v_clamped],
404            corner_covariances: &self.corner_covariances[..v_clamped],
405            telemetry,
406            rejected_corners: &self.corners[v_clamped..n_clamped],
407            rejected_error_rates: &self.error_rates[v_clamped..n_clamped],
408            rejected_funnel_status: &self.funnel_status[v_clamped..n_clamped],
409        }
410    }
411}
412
413impl Default for DetectionBatch {
414    fn default() -> Self {
415        Self::new()
416    }
417}