Skip to main content

optical_flow_lk/
features.rs

1use image::{GrayImage, ImageBuffer};
2use std::cmp::Ordering;
3
4use crate::utils::{
5    box_filter_3x3::{sum_row_3, sum_row_3_f64, sum_rows_3, sum_rows_3_f64},
6    fast_gradients::compute_gradients_into,
7};
8
9/// A detected corner: its pixel position and its Shi-Tomasi score, the
10/// minimum eigenvalue of the mean 3x3 structure tensor.
11///
12/// Scores are in (grey levels / pixel)^2 for 8-bit images and in
13/// (input unit / pixel)^2 for f32 images.
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub struct Corner {
16    /// Column in pixels.
17    pub x: u32,
18    /// Row in pixels.
19    pub y: u32,
20    /// Corner score.
21    pub score: f32,
22}
23
24impl Corner {
25    /// The position as tracker input, `(x, y)` in pixels.
26    pub fn position(&self) -> (f32, f32) {
27        (self.x as f32, self.y as f32)
28    }
29}
30
31impl From<(u32, u32, f32)> for Corner {
32    fn from((x, y, score): (u32, u32, f32)) -> Self {
33        Self { x, y, score }
34    }
35}
36
37impl From<Corner> for (u32, u32, f32) {
38    fn from(corner: Corner) -> Self {
39        (corner.x, corner.y, corner.score)
40    }
41}
42
43/// Detection grid of [`good_features_to_track_grid`] and `detect_grid`: the
44/// image is split into `cols` x `rows` equal cells, and each cell keeps up to
45/// `max_per_cell` corners, counting existing points.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub struct Grid {
48    /// Number of columns; must be non-zero.
49    pub cols: u32,
50    /// Number of rows; must be non-zero.
51    pub rows: u32,
52    /// Corner budget per cell, counting existing points in the cell.
53    pub max_per_cell: u32,
54}
55
56/// Finds good features points using the Shi-Tomasi algorithm
57///
58/// This is a convenience wrapper that allocates a fresh [`DetectorContext`];
59/// per-frame callers should keep one context and call
60/// [`DetectorContext::detect`] instead.
61///
62/// # Arguments
63/// * `image` - Target image (grayscale)
64/// * `quality_level` - Minimum fraction of the strongest local corner response
65/// * `min_distance` - Minimum Euclidean spacing in pixels; zero disables spacing
66///
67///
68/// # Returns
69/// [`Corner`]s sorted by descending score; equal scores retain row-major
70/// order. Only finite, positive scores are returned. Flat images and images
71/// narrower or shorter than three pixels yield no features. Scores are
72/// eigenvalues of the mean 3x3 gradient tensor, using Scharr gradients
73/// normalized by 32, in (grey levels / pixel)^2. Raw products and window
74/// sums are accumulated exactly; normalization occurs once, at the final score.
75pub fn good_features_to_track(
76    image: &GrayImage,
77    quality_level: f32,
78    min_distance: u32,
79) -> Vec<Corner> {
80    let mut ctx = DetectorContext::new();
81    ctx.detect(image, quality_level, min_distance);
82    ctx.detector.take_results()
83}
84
85/// Finds good feature points with uniform frame coverage by detecting per grid
86/// cell, while respecting features that are already being tracked.
87///
88/// For every cell of `grid` up to `max_per_cell` best Shi-Tomasi corners are
89/// returned, where `min_distance` is respected both among the freshly detected
90/// corners and against `existing_points`. Cells whose budget is already met by
91/// `existing_points` are skipped, so this can be called every few frames to
92/// top up lost tracks without clustering new detections on top of surviving
93/// ones.
94///
95/// This is a convenience wrapper that allocates a fresh [`DetectorContext`];
96/// per-frame callers should keep one context and call
97/// [`DetectorContext::detect_grid`] instead.
98///
99/// # Arguments
100/// * `image` - Target image (grayscale)
101/// * `grid` - grid dimensions and per-cell budget, counting `existing_points`
102/// * `quality_level` - Shi-Tomasi quality level, see [`good_features_to_track`]
103/// * `min_distance` - minimum spacing in pixels between any two kept points
104///   (new or existing)
105/// * `existing_points` - already-tracked points (level-0 pixel coordinates) that
106///   occupy budget and must be kept clear by `min_distance`
107///
108/// # Returns
109/// Newly detected [`Corner`]s, sorted by descending score. `existing_points`
110/// are never included in the output.
111///
112/// # Panics
113/// Panics if `grid.cols` or `grid.rows` is zero.
114pub fn good_features_to_track_grid(
115    image: &GrayImage,
116    grid: Grid,
117    quality_level: f32,
118    min_distance: u32,
119    existing_points: &[(f32, f32)],
120) -> Vec<Corner> {
121    let mut ctx = DetectorContext::new();
122    ctx.detect_grid(image, grid, quality_level, min_distance, existing_points);
123    ctx.detector.take_results()
124}
125
126/// Upper bounds for the work a detector context will see, used to allocate
127/// every buffer up front with [`DetectorContext::with_capacity`] or
128/// [`DetectorContext::reserve`].
129#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
130pub struct DetectorCapacity {
131    /// Largest image width in pixels.
132    pub width: u32,
133    /// Largest image height in pixels.
134    pub height: u32,
135    /// Smallest `min_distance` that will be passed; zero reserves for no
136    /// spacing at all.
137    pub min_distance: u32,
138    /// Largest `grid_cols * grid_rows` that will be passed to `detect_grid`.
139    pub grid_cells: u32,
140    /// Most `existing_points` that will be passed to `detect_grid`.
141    pub existing_points: usize,
142}
143
144impl DetectorCapacity {
145    fn max_pixels(&self) -> usize {
146        self.width as usize * self.height as usize
147    }
148
149    /// Interior pixels: every one can be a local maximum on a score plateau.
150    fn max_candidates(&self) -> usize {
151        self.width.saturating_sub(2) as usize * self.height.saturating_sub(2) as usize
152    }
153
154    /// Points that can be pairwise at least `min_distance` apart. Two points
155    /// in one half-open square of side `min_distance / sqrt(2)` are closer, so
156    /// each square holds at most one.
157    fn max_spaced(&self) -> usize {
158        let candidates = self.max_candidates();
159        if self.min_distance < 2 {
160            return candidates;
161        }
162        let side = self.min_distance as f64 / std::f64::consts::SQRT_2;
163        let cells = |length: u32| (length as f64 / side).ceil() as usize + 1;
164        candidates.min(cells(self.width) * cells(self.height))
165    }
166}
167
168/// Grows `buffer`'s capacity to at least `total` elements.
169pub(crate) fn reserve_total<T>(buffer: &mut Vec<T>, total: usize) {
170    buffer.reserve(total.saturating_sub(buffer.len()));
171}
172
173/// Reusable owner of every buffer the Shi-Tomasi detector touches: gradient
174/// planes, structure-tensor row buffers, the score plane, the candidate list,
175/// the spacing grid and the result vector.
176///
177/// Keep one per detection thread and call [`detect`](Self::detect) or
178/// [`detect_grid`](Self::detect_grid) each time. Buffers grow on demand and are
179/// reused. To keep allocation out of the per-frame path, create the context
180/// with [`with_capacity`](Self::with_capacity): calls within its
181/// [`DetectorCapacity`] then perform **no heap allocation**, whatever the image
182/// content. Without a reservation, the first call allocates, and a later image
183/// with more candidates than any before can still grow a buffer. The free
184/// functions [`good_features_to_track`] and [`good_features_to_track_grid`]
185/// create a context per call.
186#[derive(Default)]
187pub struct DetectorContext {
188    detector: Detector<i32>,
189}
190
191impl DetectorContext {
192    /// Creates an empty context. Buffers grow to fit on the first detection
193    /// and are reused thereafter.
194    pub fn new() -> Self {
195        Self::default()
196    }
197
198    /// Creates a context with every buffer allocated for `capacity`.
199    pub fn with_capacity(capacity: DetectorCapacity) -> Self {
200        let mut context = Self::default();
201        context.reserve(capacity);
202        context
203    }
204
205    /// Allocates every buffer for `capacity` now, so that later calls within
206    /// it do not allocate. Existing capacity is kept.
207    pub fn reserve(&mut self, capacity: DetectorCapacity) {
208        self.detector.reserve(&capacity);
209    }
210
211    /// Detects corners as [`good_features_to_track`] does, returning them from
212    /// the context's own result buffer. The slice is valid until the next call.
213    pub fn detect(
214        &mut self,
215        image: &GrayImage,
216        quality_level: f32,
217        min_distance: u32,
218    ) -> &[Corner] {
219        self.detector
220            .detect(image, Threshold::relative(quality_level), min_distance)
221    }
222
223    /// Detects corners per grid cell as [`good_features_to_track_grid`] does,
224    /// returning them from the context's own result buffer. The slice is valid
225    /// until the next call.
226    ///
227    /// # Panics
228    /// Panics if `grid.cols` or `grid.rows` is zero.
229    pub fn detect_grid(
230        &mut self,
231        image: &GrayImage,
232        grid: Grid,
233        quality_level: f32,
234        min_distance: u32,
235        existing_points: &[(f32, f32)],
236    ) -> &[Corner] {
237        self.detector.detect_grid(
238            image,
239            grid,
240            Threshold::relative(quality_level),
241            min_distance,
242            existing_points,
243        )
244    }
245}
246
247/// An image the detector runs on: its size and its Scharr gradients, whose
248/// products the structure tensor sums in `Sum`.
249pub(crate) trait DetectorImage: Copy {
250    type Sum: TensorSum;
251
252    fn dimensions(self) -> (u32, u32);
253
254    /// Scharr gradients, 32 times the slope per pixel, zero on the border.
255    fn gradients_into(
256        self,
257        grad_x: &mut [<Self::Sum as TensorSum>::Gradient],
258        grad_y: &mut [<Self::Sum as TensorSum>::Gradient],
259    );
260}
261
262impl DetectorImage for &GrayImage {
263    type Sum = i32;
264
265    fn dimensions(self) -> (u32, u32) {
266        ImageBuffer::dimensions(self)
267    }
268
269    fn gradients_into(self, grad_x: &mut [i16], grad_y: &mut [i16]) {
270        compute_gradients_into(self, grad_x, grad_y);
271    }
272}
273
274/// Which local maxima a detection keeps: those scoring at least `absolute`
275/// and at least `relative` times the strongest local maximum of the image.
276#[derive(Clone, Copy, Debug, PartialEq)]
277pub(crate) struct Threshold {
278    pub(crate) absolute: f32,
279    pub(crate) relative: f32,
280}
281
282impl Threshold {
283    /// The 8-bit detector's `quality_level`, unvalidated as it always was.
284    fn relative(quality_level: f32) -> Self {
285        Self {
286            absolute: 0.0,
287            relative: quality_level,
288        }
289    }
290
291    /// A score below which no pixel can pass, so NMS may skip it. The
292    /// relative part costs a pass over the scores, saved when it is disabled.
293    // Inlined into the pipeline, this pass slows the surrounding loops under
294    // WASM by 5-10% (Node, simd128); kept out of line there, detection is
295    // 2-9% faster than with the pass inlined. Native targets inline it, which
296    // also gives it the AVX2 code of the pipeline on x86.
297    #[cfg_attr(target_arch = "wasm32", inline(never))]
298    #[cfg_attr(not(target_arch = "wasm32"), inline(always))]
299    fn floor(&self, scores: &[f32], width: usize, height: usize) -> f32 {
300        let relative = if self.relative > 0.0 {
301            quality_floor(scores, width, height, self.relative)
302        } else {
303            0.0
304        };
305        self.absolute.max(relative)
306    }
307
308    /// Whether a local maximum scoring `score` passes, given the strongest.
309    fn keeps(&self, score: f32, strongest: f32) -> bool {
310        // Two comparisons rather than one against the larger bound, so that a
311        // NaN quality level keeps nothing, as it always did.
312        score >= self.absolute && score >= self.relative * strongest
313    }
314}
315
316/// The detector pipeline with every buffer it touches, for tensor sums of
317/// type `T`: the core of [`DetectorContext`] and of the f32 detector.
318#[derive(Default)]
319pub(crate) struct Detector<T: TensorSum> {
320    planes: Planes<T>,
321    selection: Selection,
322}
323
324impl<T: TensorSum> Detector<T> {
325    pub(crate) fn reserve(&mut self, capacity: &DetectorCapacity) {
326        self.planes.reserve(capacity);
327        self.selection.reserve(capacity);
328    }
329
330    /// Corners passing `threshold`, greedily at least `min_distance` apart,
331    /// from the result buffer.
332    pub(crate) fn detect<I: DetectorImage<Sum = T>>(
333        &mut self,
334        image: I,
335        threshold: Threshold,
336        min_distance: u32,
337    ) -> &[Corner] {
338        self.detect_candidates(image, threshold);
339        let (width, height) = image.dimensions();
340        self.selection
341            .spaced(&self.planes.candidates, min_distance, width, height)
342    }
343
344    /// Corners per grid cell, see [`DetectorContext::detect_grid`], from the
345    /// result buffer.
346    ///
347    /// # Panics
348    /// Panics if `grid.cols` or `grid.rows` is zero.
349    pub(crate) fn detect_grid<I: DetectorImage<Sum = T>>(
350        &mut self,
351        image: I,
352        grid: Grid,
353        threshold: Threshold,
354        min_distance: u32,
355        existing_points: &[(f32, f32)],
356    ) -> &[Corner] {
357        assert!(grid.cols > 0 && grid.rows > 0, "grid must be non-empty");
358        if grid.max_per_cell == 0 {
359            return self.selection.clear();
360        }
361        self.detect_candidates(image, threshold);
362        let (width, height) = image.dimensions();
363        self.selection.grid(
364            &self.planes.candidates,
365            width,
366            height,
367            grid,
368            min_distance,
369            existing_points,
370        )
371    }
372
373    /// Moves the result buffer out, for the allocating free functions.
374    pub(crate) fn take_results(&mut self) -> Vec<Corner> {
375        std::mem::take(&mut self.selection.results)
376    }
377
378    /// The score plane of the last detection.
379    #[cfg(test)]
380    pub(crate) fn scores(&self) -> &[f32] {
381        &self.planes.scores
382    }
383
384    /// Runs the Shi-Tomasi pipeline, leaving the candidates that pass
385    /// `threshold` sorted by descending score in `self.planes.candidates`,
386    /// before any spacing constraint.
387    fn detect_candidates<I: DetectorImage<Sum = T>>(&mut self, image: I, threshold: Threshold) {
388        self.planes.candidates.clear();
389        let (width, height) = image.dimensions();
390        if width < 3 || height < 3 {
391            return;
392        }
393
394        self.planes.run(image, threshold);
395        let candidates = &mut self.planes.candidates;
396        filter_by_threshold(candidates, threshold);
397        sort_by_score(candidates);
398    }
399}
400
401/// Descending score; ties keep row-major order, which is the order the
402/// candidates were collected in. The unstable sort does not allocate.
403fn sort_by_score(candidates: &mut [(u32, u32, f32)]) {
404    candidates.sort_unstable_by(|a, b| {
405        b.2.partial_cmp(&a.2)
406            .unwrap_or(Ordering::Equal)
407            .then(a.1.cmp(&b.1))
408            .then(a.0.cmp(&b.0))
409    });
410}
411
412/// The per-pixel stages of a detector and their buffers, for tensor sums of
413/// type `T`.
414#[derive(Default)]
415struct Planes<T: TensorSum> {
416    grad_x: Vec<T::Gradient>,
417    grad_y: Vec<T::Gradient>,
418    tensor: TensorRows<T>,
419    scores: Vec<f32>,
420    /// Horizontal 3-pixel score maxima of rows y - 1, y and y + 1 for NMS.
421    maxima_rows: [Vec<f32>; 3],
422    candidates: Vec<(u32, u32, f32)>,
423}
424
425impl<T: TensorSum> Planes<T> {
426    fn reserve(&mut self, capacity: &DetectorCapacity) {
427        let (width, pixels) = (capacity.width as usize, capacity.max_pixels());
428        reserve_total(&mut self.grad_x, pixels);
429        reserve_total(&mut self.grad_y, pixels);
430        reserve_total(&mut self.scores, pixels);
431        self.tensor.reserve(width);
432        for row in &mut self.maxima_rows {
433            reserve_total(row, width.saturating_sub(2));
434        }
435        reserve_total(&mut self.candidates, capacity.max_candidates());
436    }
437
438    /// Gradients, structure tensor, scores and non-maximum suppression. Leaves
439    /// the local maxima that can pass `threshold` in `candidates`, in
440    /// row-major order. The image must be at least 3x3.
441    ///
442    /// The stages are plain loops compiled for the target's baseline (SSE2,
443    /// NEON, `simd128`); on x86 an AVX2 copy is selected at runtime.
444    fn run<I: DetectorImage<Sum = T>>(&mut self, image: I, threshold: Threshold) {
445        #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
446        if is_x86_feature_detected!("avx2") {
447            // SAFETY: guarded by the runtime feature check.
448            return unsafe { run_avx2(self, image, threshold) };
449        }
450        run_impl(self, image, threshold)
451    }
452}
453
454#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
455#[target_feature(enable = "avx2")]
456unsafe fn run_avx2<I: DetectorImage>(planes: &mut Planes<I::Sum>, image: I, threshold: Threshold) {
457    run_impl(planes, image, threshold)
458}
459
460#[inline(always)]
461fn run_impl<I: DetectorImage>(p: &mut Planes<I::Sum>, image: I, threshold: Threshold) {
462    let (w, h) = image.dimensions();
463    let (w, h) = (w as usize, h as usize);
464    let n = w * h;
465    p.grad_x.resize(n, Default::default());
466    p.grad_y.resize(n, Default::default());
467    p.scores.resize(n, 0.0);
468
469    image.gradients_into(&mut p.grad_x, &mut p.grad_y);
470    min_eigenvalue_scores(&p.grad_x, &p.grad_y, w, &mut p.tensor, &mut p.scores);
471    let floor = threshold.floor(&p.scores, w, h);
472    local_maxima(
473        &p.scores,
474        w,
475        h,
476        floor,
477        &mut p.maxima_rows,
478        &mut p.candidates,
479    );
480}
481
482/// Keeps the candidates that pass `threshold`, relative to the strongest one.
483fn filter_by_threshold(candidates: &mut Vec<(u32, u32, f32)>, threshold: Threshold) {
484    let strongest = candidates.iter().fold(0.0f32, |max, p| max.max(p.2));
485    candidates.retain(|&(_, _, score)| threshold.keeps(score, strongest));
486}
487
488/// Greedy minimum-distance filter over quality-sorted candidates, writing the
489/// kept points into `out`, as [`Corner`]s or as plain `(x, y, score)` triples.
490fn enforce_spacing<O: From<(u32, u32, f32)>>(
491    candidates: &[(u32, u32, f32)],
492    min_distance: u32,
493    width: u32,
494    height: u32,
495    occupancy: &mut OccupancyGrid,
496    out: &mut Vec<O>,
497) {
498    out.clear();
499    if candidates.is_empty() {
500        return;
501    }
502    if min_distance == 0 {
503        out.extend(candidates.iter().map(|&candidate| O::from(candidate)));
504        return;
505    }
506    occupancy.reset(width, height, min_distance);
507    for &(x, y, q) in candidates {
508        let (xf, yf) = (x as f32, y as f32);
509        if !occupancy.has_neighbor_within(xf, yf, min_distance as f32) {
510            // Multiple accepted points can occupy one cell (diagonally). Keep
511            // every one so later candidates cannot hide an earlier neighbor.
512            occupancy.insert(xf, yf);
513            out.push(O::from((x, y, q)));
514        }
515    }
516}
517
518/// Spacing and grid selection over score-sorted candidates, with the result
519/// buffer.
520#[derive(Default)]
521struct Selection {
522    results: Vec<Corner>,
523    occupancy: OccupancyGrid,
524    cell_count: Vec<u32>,
525}
526
527impl Selection {
528    fn reserve(&mut self, capacity: &DetectorCapacity) {
529        let spaced = capacity.max_spaced();
530        reserve_total(&mut self.results, spaced);
531        self.occupancy
532            .reserve(capacity, capacity.existing_points + spaced);
533        reserve_total(&mut self.cell_count, capacity.grid_cells as usize);
534    }
535
536    fn clear(&mut self) -> &[Corner] {
537        self.results.clear();
538        &self.results
539    }
540
541    /// Greedy minimum-distance selection, see [`enforce_spacing`].
542    fn spaced(
543        &mut self,
544        candidates: &[(u32, u32, f32)],
545        min_distance: u32,
546        width: u32,
547        height: u32,
548    ) -> &[Corner] {
549        enforce_spacing(
550            candidates,
551            min_distance,
552            width,
553            height,
554            &mut self.occupancy,
555            &mut self.results,
556        );
557        &self.results
558    }
559
560    /// Per-cell selection for [`DetectorContext::detect_grid`].
561    fn grid(
562        &mut self,
563        candidates: &[(u32, u32, f32)],
564        width: u32,
565        height: u32,
566        grid: Grid,
567        min_distance: u32,
568        existing_points: &[(f32, f32)],
569    ) -> &[Corner] {
570        self.results.clear();
571        if candidates.is_empty() {
572            return &self.results;
573        }
574        let Grid {
575            cols: grid_cols,
576            rows: grid_rows,
577            max_per_cell,
578        } = grid;
579
580        // Detection cell of a point, clamped to the grid.
581        let cell_of = |x: f32, y: f32| -> usize {
582            let col = ((x / width as f32) * grid_cols as f32) as u32;
583            let row = ((y / height as f32) * grid_rows as f32) as u32;
584            let col = col.min(grid_cols - 1);
585            let row = row.min(grid_rows - 1);
586            (row * grid_cols + col) as usize
587        };
588
589        // Per-cell occupancy (existing + accepted) against the feature budget.
590        self.cell_count.clear();
591        self.cell_count.resize((grid_cols * grid_rows) as usize, 0);
592        // Spatial hash for min_distance, independent of the detection grid.
593        self.occupancy.reset(width, height, min_distance);
594
595        for &(ex, ey) in existing_points {
596            self.cell_count[cell_of(ex, ey)] += 1;
597            self.occupancy.insert(ex, ey);
598        }
599
600        let min_distance = min_distance as f32;
601        for &(x, y, q) in candidates {
602            let (xf, yf) = (x as f32, y as f32);
603            let cell = cell_of(xf, yf);
604
605            if self.cell_count[cell] >= max_per_cell {
606                continue;
607            }
608            if self.occupancy.has_neighbor_within(xf, yf, min_distance) {
609                continue;
610            }
611
612            self.occupancy.insert(xf, yf);
613            self.cell_count[cell] += 1;
614            self.results.push(Corner { x, y, score: q });
615        }
616
617        &self.results
618    }
619}
620
621/// Uniform spatial hash used to enforce `min_distance` between kept points.
622///
623/// Cells are `min_distance` wide, so any point closer than `min_distance` to a
624/// query is guaranteed to live in the query's cell or one of its 8 neighbors.
625/// Points in a cell form an intrusive linked list (`heads` / `next`), so the
626/// grid reuses three flat vectors and never allocates once warm.
627#[derive(Default)]
628struct OccupancyGrid {
629    cell_size: u32,
630    cols: u32,
631    rows: u32,
632    heads: Vec<u32>,
633    next: Vec<u32>,
634    points: Vec<(f32, f32)>,
635}
636
637const NO_POINT: u32 = u32::MAX;
638
639impl OccupancyGrid {
640    /// Allocates the cells for the smallest spacing in `capacity` and room
641    /// for `points` points.
642    fn reserve(&mut self, capacity: &DetectorCapacity, points: usize) {
643        let cell_size = capacity.min_distance.max(1);
644        let cells = |length: u32| length.div_ceil(cell_size).max(1) as usize;
645        reserve_total(
646            &mut self.heads,
647            cells(capacity.width) * cells(capacity.height),
648        );
649        reserve_total(&mut self.next, points);
650        reserve_total(&mut self.points, points);
651    }
652
653    fn reset(&mut self, width: u32, height: u32, min_distance: u32) {
654        self.cell_size = min_distance.max(1);
655        self.cols = width.div_ceil(self.cell_size).max(1);
656        self.rows = height.div_ceil(self.cell_size).max(1);
657        self.heads.clear();
658        self.heads
659            .resize((self.cols * self.rows) as usize, NO_POINT);
660        self.next.clear();
661        self.points.clear();
662    }
663
664    fn cell(&self, x: f32, y: f32) -> (u32, u32) {
665        let col = (x.max(0.0) as u32 / self.cell_size).min(self.cols - 1);
666        let row = (y.max(0.0) as u32 / self.cell_size).min(self.rows - 1);
667        (col, row)
668    }
669
670    fn insert(&mut self, x: f32, y: f32) {
671        let (col, row) = self.cell(x, y);
672        let cell = (row * self.cols + col) as usize;
673        let index = self.points.len() as u32;
674        self.points.push((x, y));
675        self.next.push(self.heads[cell]);
676        self.heads[cell] = index;
677    }
678
679    fn has_neighbor_within(&self, x: f32, y: f32, min_distance: f32) -> bool {
680        if min_distance <= 0.0 {
681            return false;
682        }
683
684        let min_dist_sq = min_distance * min_distance;
685        let (col, row) = self.cell(x, y);
686
687        for drow in -1..=1 {
688            for dcol in -1..=1 {
689                let cx = col as i32 + dcol;
690                let cy = row as i32 + drow;
691                if cx < 0 || cy < 0 || cx >= self.cols as i32 || cy >= self.rows as i32 {
692                    continue;
693                }
694
695                let mut index = self.heads[(cy as u32 * self.cols + cx as u32) as usize];
696                while index != NO_POINT {
697                    let (px, py) = self.points[index as usize];
698                    let dist_sq = (x - px).powi(2) + (y - py).powi(2);
699                    if dist_sq < min_dist_sq {
700                        return true;
701                    }
702                    index = self.next[index as usize];
703                }
704            }
705        }
706
707        false
708    }
709}
710
711/// Accumulator type of the structure tensor and the gradient type it sums:
712/// exact i32 sums of i16 Scharr gradients for 8-bit images, f64 sums of f32
713/// gradients for f32 images. Products of two f32 values are exact in f64.
714pub(crate) trait TensorSum: Copy + Default {
715    type Gradient: Copy + Default;
716
717    /// `(gx * gx, gy * gy, gx * gy)`.
718    fn products(gx: Self::Gradient, gy: Self::Gradient) -> (Self, Self, Self);
719
720    /// Horizontal pass of the 3x3 window sum, see [`sum_row_3`].
721    fn sum_row_3(src: &[Self], dst: &mut [Self]);
722
723    /// Vertical pass of the 3x3 window sum, see [`sum_rows_3`].
724    fn sum_rows_3(
725        above: Option<&[Self]>,
726        center: &[Self],
727        below: Option<&[Self]>,
728        dst: &mut [Self],
729    );
730
731    /// Small eigenvalue of [[a, c], [c, b]] for summed gradient products,
732    /// divided by `normalization`.
733    fn scaled_min_eigenvalue(a: Self, b: Self, c: Self, normalization: f32) -> f32;
734}
735
736impl TensorSum for i32 {
737    type Gradient = i16;
738
739    #[inline(always)]
740    fn products(gx: i16, gy: i16) -> (i32, i32, i32) {
741        let (ix, iy) = (gx as i32, gy as i32);
742        (ix * ix, iy * iy, ix * iy)
743    }
744
745    #[inline(always)]
746    fn sum_row_3(src: &[i32], dst: &mut [i32]) {
747        sum_row_3(src, dst);
748    }
749
750    #[inline(always)]
751    fn sum_rows_3(above: Option<&[i32]>, center: &[i32], below: Option<&[i32]>, dst: &mut [i32]) {
752        sum_rows_3(above, center, below, dst);
753    }
754
755    #[inline(always)]
756    fn scaled_min_eigenvalue(a: i32, b: i32, c: i32, normalization: f32) -> f32 {
757        scaled_min_eigenvalue(a, b, c, normalization)
758    }
759}
760
761impl TensorSum for f64 {
762    type Gradient = f32;
763
764    #[inline(always)]
765    fn products(gx: f32, gy: f32) -> (f64, f64, f64) {
766        let (ix, iy) = (gx as f64, gy as f64);
767        (ix * ix, iy * iy, ix * iy)
768    }
769
770    #[inline(always)]
771    fn sum_row_3(src: &[f64], dst: &mut [f64]) {
772        sum_row_3_f64(src, dst);
773    }
774
775    #[inline(always)]
776    fn sum_rows_3(above: Option<&[f64]>, center: &[f64], below: Option<&[f64]>, dst: &mut [f64]) {
777        sum_rows_3_f64(above, center, below, dst);
778    }
779
780    #[inline(always)]
781    fn scaled_min_eigenvalue(a: f64, b: f64, c: f64, normalization: f32) -> f32 {
782        // As for i32 sums, with the determinant in f64. Products are exact,
783        // so its only rounding comes from the window sums, the two products
784        // and their difference, far below the precision of f32 inputs. Pixel
785        // magnitudes within FloatImageView::MAX_ABS_VALUE keep the determinant
786        // and every f32 intermediate finite.
787        let determinant = a * b - c * c;
788        let (trace, difference, c) = ((a + b) as f32, (a - b) as f32, c as f32);
789        let largest = (trace + (difference * difference + 4.0 * c * c).sqrt()) * 0.5;
790        let score = determinant as f32 / (largest * normalization);
791        if determinant > 0.0 { score } else { 0.0 }
792    }
793}
794
795/// One image row of the structure tensor components `[[xx, xy], [xy, yy]]`:
796/// raw gradient products, or their horizontal or full 3x3 window sums.
797#[derive(Default)]
798struct TensorRow<T> {
799    xx: Vec<T>,
800    yy: Vec<T>,
801    xy: Vec<T>,
802}
803
804impl<T: TensorSum> TensorRow<T> {
805    #[cfg(test)]
806    fn new(width: usize) -> Self {
807        let mut row = Self::default();
808        row.resize(width);
809        row
810    }
811
812    fn resize(&mut self, width: usize) {
813        for plane in [&mut self.xx, &mut self.yy, &mut self.xy] {
814            plane.resize(width, T::default());
815        }
816    }
817
818    fn reserve(&mut self, width: usize) {
819        for plane in [&mut self.xx, &mut self.yy, &mut self.xy] {
820            reserve_total(plane, width);
821        }
822    }
823
824    fn planes(&self) -> [&[T]; 3] {
825        [&self.xx, &self.yy, &self.xy]
826    }
827
828    fn planes_mut(&mut self) -> [&mut [T]; 3] {
829        [&mut self.xx, &mut self.yy, &mut self.xy]
830    }
831
832    /// Vertical 3-row sum of horizontally summed rows; `None` is outside the image.
833    #[inline(always)]
834    fn sum_rows(&mut self, above: Option<&Self>, center: &Self, below: Option<&Self>) {
835        let (above, below) = (above.map(Self::planes), below.map(Self::planes));
836        for (k, dst) in self.planes_mut().into_iter().enumerate() {
837            let (a, b) = (above.map(|p| p[k]), below.map(|p| p[k]));
838            T::sum_rows_3(a, center.planes()[k], b, dst);
839        }
840    }
841}
842
843/// Row buffers of the fused tensor pass: raw products of one row, horizontal
844/// sums of rows y - 1, y and y + 1 (stored at row % 3), and the 3x3 window
845/// sums of row y.
846#[derive(Default)]
847struct TensorRows<T> {
848    products: TensorRow<T>,
849    ring: [TensorRow<T>; 3],
850    window: TensorRow<T>,
851}
852
853impl<T: TensorSum> TensorRows<T> {
854    fn reserve(&mut self, width: usize) {
855        let Self {
856            products,
857            ring,
858            window,
859        } = self;
860        for row in [products, window].into_iter().chain(ring) {
861            row.reserve(width);
862        }
863    }
864}
865
866/// Raw Scharr products of one row. For u8 images, |Scharr| <= 4080: products
867/// <= 16_646_400 and 3x3 sums <= 149_817_600 fit in i32. Deferring the /32
868/// normalization avoids discarding sub-grey-level gradients before squaring.
869#[inline(always)]
870fn gradient_products_row<T: TensorSum>(
871    gx: &[T::Gradient],
872    gy: &[T::Gradient],
873    products: &mut TensorRow<T>,
874) {
875    let rows = products
876        .xx
877        .iter_mut()
878        .zip(&mut products.yy)
879        .zip(&mut products.xy)
880        .zip(gx.iter().zip(gy));
881    for (((xx, yy), xy), (&ix, &iy)) in rows {
882        (*xx, *yy, *xy) = T::products(ix, iy);
883    }
884}
885
886/// Products of one gradient row, summed horizontally over three pixels.
887#[inline(always)]
888fn horizontal_tensor_row<T: TensorSum>(
889    gx: &[T::Gradient],
890    gy: &[T::Gradient],
891    products: &mut TensorRow<T>,
892    sums: &mut TensorRow<T>,
893) {
894    gradient_products_row(gx, gy, products);
895    T::sum_row_3(&products.xx, &mut sums.xx);
896    T::sum_row_3(&products.yy, &mut sums.yy);
897    T::sum_row_3(&products.xy, &mut sums.xy);
898}
899
900/// Minimum eigenvalue of the mean 3x3 structure tensor at every pixel of a
901/// row-major `width`-wide plane. Products and window sums are computed one row
902/// at a time, keeping only three horizontally summed rows instead of tensor
903/// planes.
904#[inline(always)]
905fn min_eigenvalue_scores<T: TensorSum>(
906    gx: &[T::Gradient],
907    gy: &[T::Gradient],
908    width: usize,
909    rows: &mut TensorRows<T>,
910    scores: &mut [f32],
911) {
912    if width == 0 || scores.is_empty() {
913        return;
914    }
915    let height = scores.len() / width;
916    let TensorRows {
917        products,
918        ring,
919        window,
920    } = rows;
921    for row in [&mut *products, &mut *window]
922        .into_iter()
923        .chain(ring.iter_mut())
924    {
925        row.resize(width);
926    }
927
928    let row = |y: usize| y * width..(y + 1) * width;
929    horizontal_tensor_row(&gx[row(0)], &gy[row(0)], products, &mut ring[0]);
930    for y in 0..height {
931        if y + 1 < height {
932            let next = &mut ring[(y + 1) % 3];
933            horizontal_tensor_row(&gx[row(y + 1)], &gy[row(y + 1)], products, next);
934        }
935        let above = (y > 0).then(|| &ring[(y + 2) % 3]);
936        let below = (y + 1 < height).then(|| &ring[(y + 1) % 3]);
937        window.sum_rows(above, &ring[y % 3], below);
938        score_row(window, y as u32, height as u32, &mut scores[row(y)]);
939    }
940}
941
942/// Normalized minimum eigenvalues of one row of 3x3 window sums.
943#[inline(always)]
944fn score_row<T: TensorSum>(window: &TensorRow<T>, y: u32, height: u32, scores: &mut [f32]) {
945    let width = scores.len();
946    let rows = 1 + u32::from(y > 0) + u32::from(y + 1 < height);
947    // Eigenvalues scale linearly with a common tensor scale. Divide once by
948    // Scharr gain squared and the actual sample count, including edges.
949    let normalization = |cols: u32| (32 * 32 * cols * rows) as f32;
950    let interior = normalization(3);
951    let sums = window.xx.iter().zip(&window.yy).zip(&window.xy);
952    for (score, ((&a, &b), &c)) in scores.iter_mut().zip(sums) {
953        *score = T::scaled_min_eigenvalue(a, b, c, interior);
954    }
955    // Boundary columns average fewer samples; a single column has none beside it.
956    let edge = normalization(if width == 1 { 1 } else { 2 });
957    for x in [0, width - 1] {
958        scores[x] = T::scaled_min_eigenvalue(window.xx[x], window.yy[x], window.xy[x], edge);
959    }
960}
961
962/// Small eigenvalue of [[a, c], [c, b]] for summed gradient products, divided
963/// by `normalization`.
964#[inline(always)]
965fn scaled_min_eigenvalue(a: i32, b: i32, c: i32, normalization: f32) -> f32 {
966    // Compute the determinant exactly in i64: i32 tensor sums can have a
967    // discriminant larger than i32, and ab - c^2 cancels for nearly rank-one
968    // tensors. Then det / lambda_max leaves no cancellation, so f32 suffices
969    // for the rest: lambda_max only adds non-negative terms. Valid u8 tensors
970    // have |entry| <= 149_817_600, so i64 is ample and a + b fits in i32.
971    let determinant = a as i64 * b as i64 - c as i64 * c as i64;
972    let (trace, difference, c) = ((a + b) as f32, (a - b) as f32, c as f32);
973    let largest = (trace + (difference * difference + 4.0 * c * c).sqrt()) * 0.5;
974    // Select instead of an early return so the row loop vectorizes; the
975    // discarded lane of a zero tensor computes 0 / 0.
976    let score = determinant as f32 / (largest * normalization);
977    if determinant > 0 { score } else { 0.0 }
978}
979
980/// A score below which no pixel can pass the quality filter, so NMS may skip it.
981///
982/// The quality threshold is relative to the strongest local maximum. A global
983/// maximum inside the image is one, which fixes the threshold before NMS. NMS
984/// never selects border pixels, so a maximum found only on the border does not;
985/// nothing beyond non-positive scores is skipped then.
986#[inline(always)]
987fn quality_floor(scores: &[f32], width: usize, height: usize, quality_level: f32) -> f32 {
988    let (mut interior, mut border) = (0.0f32, 0.0f32);
989    for (y, row) in scores.chunks_exact(width).enumerate() {
990        if y == 0 || y + 1 == height {
991            border = max_of(row, border);
992        } else {
993            interior = max_of(&row[1..width - 1], interior);
994            border = max_score(max_score(border, row[0]), row[width - 1]);
995        }
996    }
997    if interior >= border {
998        quality_level * interior
999    } else {
1000        0.0
1001    }
1002}
1003
1004/// Appends the positive interior local maxima at or above `floor` to `maxima`,
1005/// in row-major order. A pixel is kept when no 8-neighbor has a greater score,
1006/// so plateaus survive; it equals the maximum of its 3x3 neighborhood, which is
1007/// computed separably in `rows`.
1008///
1009/// Almost all pixels fail the test, so each row is tested in groups of
1010/// [`MAXIMA_GROUP`] pixels first, without branches, which vectorizes. Only
1011/// groups containing a kept pixel are scanned one by one.
1012#[inline(always)]
1013fn local_maxima(
1014    scores: &[f32],
1015    width: usize,
1016    height: usize,
1017    floor: f32,
1018    rows: &mut [Vec<f32>; 3],
1019    maxima: &mut Vec<(u32, u32, f32)>,
1020) {
1021    if width < 3 || height < 3 {
1022        return;
1023    }
1024    for row in rows.iter_mut() {
1025        row.resize(width - 2, 0.0);
1026    }
1027    let row = |y: usize| &scores[y * width..(y + 1) * width];
1028    // Horizontal 3-pixel maxima for the interior columns of rows y - 1, y and
1029    // y + 1, stored at row % 3.
1030    max_row_3(row(0), &mut rows[0]);
1031    max_row_3(row(1), &mut rows[1]);
1032    for y in 1..height - 1 {
1033        max_row_3(row(y + 1), &mut rows[(y + 1) % 3]);
1034        let (above, center, below) = (&rows[(y + 2) % 3], &rows[y % 3], &rows[(y + 1) % 3]);
1035        let interior = &row(y)[1..width - 1];
1036        let groups = interior
1037            .as_chunks::<MAXIMA_GROUP>()
1038            .0
1039            .iter()
1040            .zip(above.as_chunks().0)
1041            .zip(center.as_chunks().0)
1042            .zip(below.as_chunks().0);
1043        let mut start = 0;
1044        for (((s, a), c), b) in groups {
1045            let mut mask = kept_mask(s, a, c, b, floor);
1046            while mask != 0 {
1047                let i = start + mask.trailing_zeros() as usize;
1048                maxima.push((i as u32 + 1, y as u32, interior[i]));
1049                mask &= mask - 1;
1050            }
1051            start += MAXIMA_GROUP;
1052        }
1053        for i in start..interior.len() {
1054            let neighborhood = max_score(max_score(above[i], center[i]), below[i]);
1055            if is_kept(interior[i], neighborhood, floor) {
1056                maxima.push((i as u32 + 1, y as u32, interior[i]));
1057            }
1058        }
1059    }
1060}
1061
1062/// Pixels per branch-free group in [`local_maxima`].
1063const MAXIMA_GROUP: usize = 8;
1064
1065/// Whether [`local_maxima`] keeps a pixel with `score` whose 3x3 neighborhood
1066/// maximum is `neighborhood`. Evaluates all three comparisons, without
1067/// branches.
1068#[inline(always)]
1069fn is_kept(score: f32, neighborhood: f32, floor: f32) -> bool {
1070    (score > 0.0) & (score >= floor) & (score == neighborhood)
1071}
1072
1073/// Bit `k` is set when [`local_maxima`] keeps pixel `k` of a group, given the
1074/// horizontal 3-pixel maxima of the rows above, at and below it.
1075#[inline(always)]
1076fn kept_mask(
1077    scores: &[f32; MAXIMA_GROUP],
1078    above: &[f32; MAXIMA_GROUP],
1079    center: &[f32; MAXIMA_GROUP],
1080    below: &[f32; MAXIMA_GROUP],
1081    floor: f32,
1082) -> u32 {
1083    let pixels = scores.iter().zip(above).zip(center).zip(below);
1084    let mask = |(k, (((&score, &above), &center), &below))| {
1085        let neighborhood = max_score(max_score(above, center), below);
1086        u32::from(is_kept(score, neighborhood, floor)) << k
1087    };
1088    pixels.enumerate().map(mask).fold(0, |all, bit| all | bit)
1089}
1090
1091/// `dst[i] = max(src[i], src[i + 1], src[i + 2])`, one value per interior column.
1092#[inline(always)]
1093fn max_row_3(src: &[f32], dst: &mut [f32]) {
1094    let windows = src.iter().zip(&src[1..]).zip(&src[2..]);
1095    for (value, ((&left, &center), &right)) in dst.iter_mut().zip(windows) {
1096        *value = max_score(max_score(left, center), right);
1097    }
1098}
1099
1100/// Maximum of two scores. Scores are never NaN, so both forms below are exact.
1101/// `f32::max` is one instruction on aarch64 (`fmaxnm`); elsewhere, such as on
1102/// WASM, its NaN handling costs extra instructions, so compare instead.
1103#[inline(always)]
1104fn max_score(a: f32, b: f32) -> f32 {
1105    if cfg!(target_arch = "aarch64") {
1106        a.max(b)
1107    } else if a > b {
1108        a
1109    } else {
1110        b
1111    }
1112}
1113
1114/// Maximum of `init` and all `scores`. The maximum is exact in any order, so
1115/// independent lanes may reduce in parallel, which lets the loop vectorize.
1116#[inline(always)]
1117fn max_of(scores: &[f32], init: f32) -> f32 {
1118    let mut lanes = [init; 8];
1119    let chunks = scores.chunks_exact(lanes.len());
1120    let rest = chunks.remainder();
1121    for chunk in chunks {
1122        for (lane, &score) in lanes.iter_mut().zip(chunk) {
1123            *lane = max_score(*lane, score);
1124        }
1125    }
1126    let max = |max, &score: &f32| max_score(max, score);
1127    rest.iter().chain(&lanes).fold(init, max)
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132    use super::*;
1133    use crate::utils::box_filter_3x3::box_sum_3x3_in_place;
1134    use image::{ImageBuffer, Luma};
1135
1136    fn spaced(candidates: &[(u32, u32, f32)], min_distance: u32) -> Vec<(u32, u32, f32)> {
1137        let mut occupancy = OccupancyGrid::default();
1138        let mut out = Vec::new();
1139        enforce_spacing(candidates, min_distance, 30, 30, &mut occupancy, &mut out);
1140        out
1141    }
1142
1143    fn maxima(scores: &[f32], width: usize, height: usize, floor: f32) -> Vec<(u32, u32, f32)> {
1144        let mut rows = Default::default();
1145        let mut out = Vec::new();
1146        local_maxima(scores, width, height, floor, &mut rows, &mut out);
1147        out
1148    }
1149
1150    #[test]
1151    fn minimum_eigenvalues_match_analytic_tensors() {
1152        let tensors = [
1153            (4, 9, 0, 4.0),
1154            (5, 5, 4, 1.0),
1155            (5, 5, -4, 1.0),
1156            (4, 9, 6, 0.0),
1157            (0, 0, 0, 0.0),
1158            (16_129, 16_129, 0, 16_129.0),
1159            (16_129, 16_129, 16_129, 0.0),
1160            // Clamp indefinite tensors to zero as well.
1161            (0, 0, 1, 0.0),
1162        ];
1163        for tensor in tensors {
1164            let score = scaled_min_eigenvalue(tensor.0, tensor.1, tensor.2, 1.0);
1165            assert!(score.is_finite());
1166            assert!(
1167                (score - tensor.3).abs() <= 1e-6 * tensor.3.max(1.0),
1168                "wrong score for {tensor:?}"
1169            );
1170        }
1171    }
1172
1173    type Plane = ImageBuffer<Luma<i32>, Vec<i32>>;
1174
1175    /// Score plane of full-image window-sum planes, via the production row scorer.
1176    fn scores_of_window_planes(a: &Plane, b: &Plane, c: &Plane) -> Vec<f32> {
1177        let (width, height) = a.dimensions();
1178        let mut scores = vec![0.0; (width * height) as usize];
1179        let mut window = TensorRow::<i32>::new(width as usize);
1180        for y in 0..height {
1181            let row = (y * width) as usize..((y + 1) * width) as usize;
1182            window.xx.copy_from_slice(&a.as_raw()[row.clone()]);
1183            window.yy.copy_from_slice(&b.as_raw()[row.clone()]);
1184            window.xy.copy_from_slice(&c.as_raw()[row.clone()]);
1185            score_row(&window, y, height, &mut scores[row]);
1186        }
1187        scores
1188    }
1189
1190    #[test]
1191    fn raw_products_preserve_fractional_normalized_gradients_and_extremes() {
1192        let gx = [1i16, -31, 32, 4080, -4080];
1193        let gy = [-1i16, 1, 31, -4080, -4080];
1194        let mut products = TensorRow::<i32>::new(5);
1195        gradient_products_row(&gx, &gy, &mut products);
1196        assert_eq!(products.xx, [1, 961, 1024, 16_646_400, 16_646_400]);
1197        assert_eq!(products.yy, [1, 1, 961, 16_646_400, 16_646_400]);
1198        assert_eq!(products.xy, [-1, -31, 992, -16_646_400, 16_646_400]);
1199    }
1200
1201    #[test]
1202    fn row_pipeline_matches_full_plane_box_sums_for_small_and_odd_sizes() {
1203        // One set of row buffers across sizes also checks that resizing them
1204        // between calls leaves no stale values behind.
1205        let mut rows = TensorRows::<i32>::default();
1206        for width in 1..8 {
1207            for height in 1..8 {
1208                let gradient = |seed: u32| {
1209                    ImageBuffer::from_fn(width, height, |x, y| {
1210                        let hash = (x * 7 + y * 13 + seed).wrapping_mul(2_654_435_761) >> 19;
1211                        Luma([(hash % 8161) as i16 - 4080])
1212                    })
1213                };
1214                let (gx, gy) = (gradient(1), gradient(2));
1215                let plane = |product: fn(i32, i32) -> i32| {
1216                    let mut plane = ImageBuffer::from_fn(width, height, |x, y| {
1217                        let (ix, iy) = (gx.get_pixel(x, y)[0], gy.get_pixel(x, y)[0]);
1218                        Luma([product(ix as i32, iy as i32)])
1219                    });
1220                    box_sum_3x3_in_place(&mut plane);
1221                    plane
1222                };
1223                let expected = scores_of_window_planes(
1224                    &plane(|ix, _| ix * ix),
1225                    &plane(|_, iy| iy * iy),
1226                    &plane(|ix, iy| ix * iy),
1227                );
1228                let mut scores = vec![0.0; (width * height) as usize];
1229                min_eigenvalue_scores(
1230                    gx.as_raw(),
1231                    gy.as_raw(),
1232                    width as usize,
1233                    &mut rows,
1234                    &mut scores,
1235                );
1236                assert_eq!(scores, expected, "{width}x{height}");
1237            }
1238        }
1239    }
1240
1241    #[test]
1242    fn normalization_preserves_tensor_mean_at_borders_and_small_dimensions() {
1243        for (width, height) in [(1, 1), (1, 5), (5, 1), (2, 2), (3, 3), (7, 9)] {
1244            let mut a = ImageBuffer::from_pixel(width, height, Luma([1024]));
1245            let mut b = ImageBuffer::from_pixel(width, height, Luma([2048]));
1246            let c = ImageBuffer::new(width, height);
1247            box_sum_3x3_in_place(&mut a);
1248            box_sum_3x3_in_place(&mut b);
1249            let scores = scores_of_window_planes(&a, &b, &c);
1250            assert_eq!(scores.len(), (width * height) as usize);
1251            assert!(scores.iter().all(|&score| score == 1.0));
1252        }
1253    }
1254
1255    #[test]
1256    fn fractional_window_mean_is_not_truncated() {
1257        let a = ImageBuffer::from_pixel(3, 3, Luma([1]));
1258        let b = a.clone();
1259        let c = ImageBuffer::new(3, 3);
1260        let scores = scores_of_window_planes(&a, &b, &c);
1261        let expected = 1.0 / (32.0 * 32.0 * 9.0);
1262        assert_eq!(scores[4], expected);
1263    }
1264
1265    /// Positive interior pixels without a strictly greater 8-neighbor.
1266    fn brute_force_maxima(scores: &[f32], width: usize, height: usize) -> Vec<(u32, u32, f32)> {
1267        let mut maxima = Vec::new();
1268        for y in 1..height.saturating_sub(1) {
1269            for x in 1..width.saturating_sub(1) {
1270                let score = scores[y * width + x];
1271                let neighbor = |nx: usize, ny: usize| scores[ny * width + nx];
1272                let is_max =
1273                    (y - 1..=y + 1).all(|ny| (x - 1..=x + 1).all(|nx| neighbor(nx, ny) <= score));
1274                if score > 0.0 && is_max {
1275                    maxima.push((x as u32, y as u32, score));
1276                }
1277            }
1278        }
1279        maxima
1280    }
1281
1282    #[test]
1283    fn local_maxima_match_brute_force_with_ties_and_floors() {
1284        let mut state = 0x2545_f491u32;
1285        // Interior widths cover no, exactly one, and several full groups of
1286        // MAXIMA_GROUP pixels, with and without a remainder.
1287        for (width, height) in [(3, 3), (4, 3), (3, 7), (8, 5), (10, 4), (17, 11), (27, 6)] {
1288            for levels in [2, 4, 1000] {
1289                // Few distinct levels produce plateaus; zero scores are never maxima.
1290                let scores: Vec<f32> = (0..width * height)
1291                    .map(|_| {
1292                        state ^= state << 13;
1293                        state ^= state >> 17;
1294                        state ^= state << 5;
1295                        (state % levels) as f32
1296                    })
1297                    .collect();
1298                let expected = brute_force_maxima(&scores, width, height);
1299                assert_eq!(maxima(&scores, width, height, 0.0), expected);
1300                for floor in [0.5, 1.0, 3.0, 999.0] {
1301                    let mut above = expected.clone();
1302                    above.retain(|p| p.2 >= floor);
1303                    assert_eq!(maxima(&scores, width, height, floor), above);
1304                }
1305            }
1306        }
1307    }
1308
1309    #[test]
1310    fn local_maxima_exclude_borders_zeros_and_dominated_pixels() {
1311        // 6x5 plane. Two equal adjacent scores are both kept; a pixel next to a
1312        // strictly higher one is suppressed; border pixels are never candidates.
1313        let width = 6;
1314        let mut scores = vec![0f32; width * 5];
1315        scores[width + 1] = 5.0;
1316        scores[width + 2] = 5.0;
1317        scores[3 * width + 4] = 7.0;
1318        scores[3 * width + 3] = 6.0; // neighbour of the 7, suppressed
1319        scores[4 * width] = 100.0; // border, never a candidate
1320        assert_eq!(
1321            maxima(&scores, width, 5, 0.0),
1322            vec![(1, 1, 5.0), (2, 1, 5.0), (4, 3, 7.0)]
1323        );
1324    }
1325
1326    #[test]
1327    fn quality_floor_is_skipped_when_the_maximum_is_only_on_the_border() {
1328        // The border pixel (0, 0) suppresses (1, 1); the strongest local maximum
1329        // is (3, 2), so a floor from the global maximum would drop it.
1330        #[rustfmt::skip]
1331        let scores = [
1332            9.0, 0.0, 0.0, 0.0, 0.0,
1333            0.0, 5.0, 0.0, 0.0, 0.0,
1334            0.0, 0.0, 0.0, 2.0, 0.0,
1335            0.0, 0.0, 0.0, 0.0, 0.0,
1336        ];
1337        assert_eq!(quality_floor(&scores, 5, 4, 0.5), 0.0);
1338        assert_eq!(maxima(&scores, 5, 4, 0.0), [(3, 2, 2.0)]);
1339
1340        let mut interior = scores;
1341        interior[0] = 1.0;
1342        assert_eq!(quality_floor(&interior, 5, 4, 0.5), 2.5);
1343        assert_eq!(maxima(&interior, 5, 4, 2.5), [(1, 1, 5.0)]);
1344    }
1345
1346    #[test]
1347    fn nearly_rank_one_tensor_keeps_its_small_positive_eigenvalue() {
1348        // Outer products of (4080, 4079) and (4079, 4078) have determinant 1.
1349        let (a, b, c) = (33_284_641, 33_268_325, 33_276_482);
1350        assert_eq!(a as i64 * b as i64 - c as i64 * c as i64, 1);
1351        let score = scaled_min_eigenvalue(a, b, c, 1.0) as f64;
1352        let expected = 1.0 / (a as f64 + b as f64);
1353        assert!((score - expected).abs() <= expected * 1e-6);
1354    }
1355
1356    #[test]
1357    fn maximum_product_sum_is_finite_and_rank_one_stays_zero() {
1358        let max = 9 * 4080 * 4080;
1359        let score = scaled_min_eigenvalue(max, max, 0, 1.0);
1360        assert!((score - max as f32).abs() <= max as f32 * 1e-6);
1361        assert_eq!(scaled_min_eigenvalue(max, max, max, 1.0), 0.0);
1362        assert_eq!(scaled_min_eigenvalue(max, max, -max, 1.0), 0.0);
1363    }
1364
1365    #[test]
1366    fn f32_score_matches_f64_eigenvalue_of_window_tensors() {
1367        // Sums of nine gradient outer products, including nearly collinear ones.
1368        let mut state = 0x9e37_79b9u32;
1369        let mut next = || {
1370            state ^= state << 13;
1371            state ^= state >> 17;
1372            state ^= state << 5;
1373            (state % 8161) as i32 - 4080
1374        };
1375        for trial in 0..20_000 {
1376            let (dx, dy) = (next(), next());
1377            let (mut a, mut b, mut c) = (0, 0, 0);
1378            for _ in 0..9 {
1379                let jitter = if trial % 2 == 0 { 4080 } else { 3 };
1380                let scale = next().signum();
1381                let ix = (scale * dx + next() % jitter).clamp(-4080, 4080);
1382                let iy = (scale * dy + next() % jitter).clamp(-4080, 4080);
1383                (a, b, c) = (a + ix * ix, b + iy * iy, c + ix * iy);
1384            }
1385            let determinant = a as i64 * b as i64 - c as i64 * c as i64;
1386            let (af, bf, cf) = (a as f64, b as f64, c as f64);
1387            let largest = (af + bf + ((af - bf).powi(2) + 4.0 * cf * cf).sqrt()) / 2.0;
1388            let expected = (determinant.max(0) as f64 / largest) as f32;
1389            let score = scaled_min_eigenvalue(a, b, c, 1.0);
1390            assert!(
1391                (score - expected).abs() <= expected * 1e-6,
1392                "{a} {b} {c}: {score} vs {expected}"
1393            );
1394        }
1395    }
1396
1397    #[test]
1398    fn spacing_keeps_all_accepted_points_in_each_cell() {
1399        let candidates = [(10, 10, 3.0), (19, 19, 2.0), (10, 11, 1.0)];
1400        assert_eq!(spaced(&candidates, 10), candidates[..2]);
1401    }
1402
1403    #[test]
1404    fn zero_spacing_keeps_all_candidates_and_large_spacing_does_not_overflow() {
1405        let candidates = [(1, 1, 3.0), (1, 2, 2.0), (20, 20, 1.0)];
1406        assert_eq!(spaced(&candidates, 0), candidates);
1407        assert_eq!(spaced(&candidates, u32::MAX), candidates[..1]);
1408    }
1409
1410    #[test]
1411    fn reservation_covers_plateaus_and_densest_spacing() {
1412        // On a plateau every interior pixel is a local maximum.
1413        let capacity = DetectorCapacity {
1414            width: 9,
1415            height: 7,
1416            ..Default::default()
1417        };
1418        let mut planes = Planes::<i32>::default();
1419        planes.reserve(&capacity);
1420        let buffer = planes.candidates.as_ptr();
1421        let scores = vec![1.0; 9 * 7];
1422        local_maxima(
1423            &scores,
1424            9,
1425            7,
1426            0.0,
1427            &mut planes.maxima_rows,
1428            &mut planes.candidates,
1429        );
1430        assert_eq!(planes.candidates.len(), capacity.max_candidates());
1431        assert_eq!(planes.candidates.as_ptr(), buffer);
1432
1433        // Greedy spacing over every interior pixel packs points most densely.
1434        for min_distance in [0, 1, 2, 3, 5, 8, 13, 100] {
1435            let capacity = DetectorCapacity {
1436                width: 61,
1437                height: 47,
1438                min_distance,
1439                ..Default::default()
1440            };
1441            let candidates: Vec<_> = (1..46)
1442                .flat_map(|y| (1..60).map(move |x| (x, y, 1.0)))
1443                .collect();
1444            let mut selection = Selection::default();
1445            selection.reserve(&capacity);
1446            let buffer = selection.results.as_ptr();
1447            let kept = selection.spaced(&candidates, min_distance, 61, 47).len();
1448            assert!(kept <= capacity.max_spaced(), "{min_distance}: {kept}");
1449            assert_eq!(selection.results.as_ptr(), buffer);
1450        }
1451    }
1452
1453    #[test]
1454    fn context_reuse_gives_identical_results_to_free_functions() {
1455        let image = GrayImage::from_fn(64, 48, |x, y| {
1456            image::Luma([((x * 31 + y * 17 + (x ^ y) * 13) & 255) as u8])
1457        });
1458        let mut ctx = DetectorContext::new();
1459        for _ in 0..2 {
1460            assert_eq!(
1461                ctx.detect(&image, 0.01, 5),
1462                good_features_to_track(&image, 0.01, 5)
1463            );
1464            let existing = [(10.0, 10.0), (40.0, 30.0)];
1465            let grid = Grid {
1466                cols: 4,
1467                rows: 3,
1468                max_per_cell: 3,
1469            };
1470            assert_eq!(
1471                ctx.detect_grid(&image, grid, 0.01, 6, &existing),
1472                good_features_to_track_grid(&image, grid, 0.01, 6, &existing)
1473            );
1474        }
1475    }
1476
1477    #[test]
1478    fn threshold_requires_both_parts() {
1479        let both = Threshold {
1480            absolute: 2.0,
1481            relative: 0.1,
1482        };
1483        assert!(both.keeps(10.0, 100.0) && !both.keeps(9.9, 100.0));
1484        assert!(both.keeps(2.0, 5.0) && !both.keeps(1.9, 5.0));
1485        // 8-bit quality levels are not validated: NaN keeps nothing, a
1486        // negative level every positive score.
1487        assert!(!Threshold::relative(f32::NAN).keeps(1.0, 1.0));
1488        assert!(Threshold::relative(-1.0).keeps(1e-30, 1.0));
1489    }
1490
1491    /// Minimum eigenvalue of a window tensor in f64 throughout.
1492    fn reference(a: f64, b: f64, c: f64) -> f64 {
1493        let determinant = a * b - c * c;
1494        let largest = (a + b + ((a - b).powi(2) + 4.0 * c * c).sqrt()) / 2.0;
1495        if determinant > 0.0 {
1496            determinant / largest
1497        } else {
1498            0.0
1499        }
1500    }
1501
1502    #[test]
1503    fn f64_tensor_scores_match_reference_for_real_valued_gradients() {
1504        let mut state = 0x2545_f491u32;
1505        let mut next = || {
1506            state ^= state << 13;
1507            state ^= state >> 17;
1508            state ^= state << 5;
1509            state as f32 / u32::MAX as f32 * 2.0 - 1.0
1510        };
1511        // Gradient magnitudes from tiny physical slopes to the largest
1512        // permitted ones, with nearly collinear windows. Below about 1e-6,
1513        // nearly collinear windows lose precision once the determinant is a
1514        // subnormal f32; their scores are then negligible anyway.
1515        for scale in [1e-6f32, 1e-3, 0.37, 41.0, 5e8] {
1516            for collinear in [false, true] {
1517                let (dx, dy) = (next(), next());
1518                let (mut a, mut b, mut c) = (0.0f64, 0.0f64, 0.0f64);
1519                for _ in 0..9 {
1520                    let jitter = if collinear { 1e-3 } else { 1.0 };
1521                    let t = next();
1522                    let gx = (t * dx + next() * jitter) * scale;
1523                    let gy = (t * dy + next() * jitter) * scale;
1524                    let (xx, yy, xy) = <f64 as TensorSum>::products(gx, gy);
1525                    (a, b, c) = (a + xx, b + yy, c + xy);
1526                }
1527                let score = <f64 as TensorSum>::scaled_min_eigenvalue(a, b, c, 1.0);
1528                let expected = reference(a, b, c);
1529                assert!(
1530                    (score as f64 - expected).abs() <= expected * 1e-5,
1531                    "scale {scale}, collinear {collinear}: {score} vs {expected}"
1532                );
1533            }
1534        }
1535    }
1536}