Skip to main content

optical_flow_lk/
float_features.rs

1use crate::features::{Corner, Detector, DetectorCapacity, DetectorImage, Grid, Threshold};
2use crate::float_image::FloatImageView;
3use crate::utils::float_gradients::scharr_f32_into;
4
5/// Corner score threshold of the f32 detector.
6///
7/// Scores are minimum eigenvalues of the mean 3x3 structure tensor of Scharr
8/// gradients normalized by 32, in (input unit / pixel)^2: (°C / px)^2 for a
9/// thermal image in °C. They scale with the square of the image contrast and
10/// do not depend on a constant offset.
11///
12/// A local maximum is kept when its score is positive and at least
13/// `max(absolute, relative * strongest)`, where `strongest` is the largest
14/// local-maximum score in the whole image. The reference is global for grid
15/// detection as well, not per cell. The relative part is disabled (zero)
16/// unless set with [`with_relative`](Self::with_relative).
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub struct ScoreThreshold {
19    absolute: f32,
20    relative: f32,
21}
22
23impl ScoreThreshold {
24    /// Keeps local maxima scoring at least `min_score`, in
25    /// (input unit / pixel)^2.
26    ///
27    /// # Panics
28    /// Panics if `min_score` is negative, NaN or infinite.
29    #[must_use]
30    pub fn absolute(min_score: f32) -> Self {
31        assert!(
32            min_score.is_finite() && min_score >= 0.0,
33            "absolute score threshold must be finite and non-negative, got {min_score}"
34        );
35        Self {
36            absolute: min_score,
37            relative: 0.0,
38        }
39    }
40
41    /// Additionally requires at least `quality_level` times the strongest
42    /// local maximum's score, as the 8-bit detector's `quality_level` does.
43    ///
44    /// # Panics
45    /// Panics unless `0 <= quality_level <= 1`.
46    #[must_use]
47    pub fn with_relative(self, quality_level: f32) -> Self {
48        assert!(
49            (0.0..=1.0).contains(&quality_level),
50            "relative score threshold must be within 0..=1, got {quality_level}"
51        );
52        Self {
53            relative: quality_level,
54            ..self
55        }
56    }
57
58    /// The absolute part, in (input unit / pixel)^2.
59    pub fn absolute_part(&self) -> f32 {
60        self.absolute
61    }
62
63    /// The relative part; zero when disabled.
64    pub fn relative_part(&self) -> f32 {
65        self.relative
66    }
67
68    fn threshold(self) -> Threshold {
69        Threshold {
70            absolute: self.absolute,
71            relative: self.relative,
72        }
73    }
74}
75
76/// Shi-Tomasi corner detector for f32 images in physical units, e.g. thermal
77/// frames in °C. The counterpart of [`DetectorContext`](crate::DetectorContext),
78/// owning every buffer it touches.
79///
80/// The pipeline matches the 8-bit detector: Scharr gradients, the mean 3x3
81/// structure tensor, its minimum eigenvalue, 3x3 non-maximum suppression,
82/// sorting and spacing. Gradients are computed in f32 and the tensor is
83/// summed in f64, so neither quantization nor cancellation limits weak
84/// structure. For an image whose values are integers, the result equals the
85/// 8-bit detector's on the same values bit for bit, as long as the tensor
86/// determinant stays below 2^53, which holds for all but extreme 8-bit
87/// contrast. See [`ScoreThreshold`] for the score unit and the threshold.
88///
89/// Conventions, shared with the 8-bit detector:
90/// - Border pixels have zero gradient but count as window samples, and the
91///   outermost pixels are never returned.
92/// - A pixel is a local maximum when no 8-neighbor scores higher, so equal
93///   neighbors on a plateau are all kept.
94/// - Results are sorted by descending score, then by row, then by column.
95///
96/// Buffers grow on demand and are reused. Create the context with
97/// [`with_capacity`](Self::with_capacity) to keep allocation out of the
98/// per-frame path; calls within the [`DetectorCapacity`] then perform no heap
99/// allocation, whatever the image content.
100#[derive(Default)]
101pub struct FloatDetectorContext {
102    detector: Detector<f64>,
103}
104
105impl FloatDetectorContext {
106    /// Creates an empty context. Buffers grow to fit on the first detection
107    /// and are reused thereafter.
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Creates a context with every buffer allocated for `capacity`.
113    pub fn with_capacity(capacity: DetectorCapacity) -> Self {
114        let mut context = Self::default();
115        context.reserve(capacity);
116        context
117    }
118
119    /// Allocates every buffer for `capacity` now, so that later calls within
120    /// it do not allocate. Existing capacity is kept.
121    pub fn reserve(&mut self, capacity: DetectorCapacity) {
122        self.detector.reserve(&capacity);
123    }
124
125    /// Detects corners scoring at least `threshold`, at least `min_distance`
126    /// pixels apart (zero disables spacing), greedily by descending score.
127    ///
128    /// Returns [`Corner`]s from the context's own result buffer; the slice is
129    /// valid until the next call. Images narrower or shorter than three
130    /// pixels, and flat images, yield no corners.
131    pub fn detect(
132        &mut self,
133        image: FloatImageView<'_>,
134        threshold: ScoreThreshold,
135        min_distance: u32,
136    ) -> &[Corner] {
137        self.detector
138            .detect(image, threshold.threshold(), min_distance)
139    }
140
141    /// Detects up to `grid.max_per_cell` corners per cell of `grid`, counting
142    /// `existing_points` against each cell's budget and keeping `min_distance`
143    /// to them, as
144    /// [`good_features_to_track_grid`](crate::good_features_to_track_grid) does.
145    ///
146    /// # Panics
147    /// Panics if `grid.cols` or `grid.rows` is zero.
148    pub fn detect_grid(
149        &mut self,
150        image: FloatImageView<'_>,
151        grid: Grid,
152        threshold: ScoreThreshold,
153        min_distance: u32,
154        existing_points: &[(f32, f32)],
155    ) -> &[Corner] {
156        self.detector.detect_grid(
157            image,
158            grid,
159            threshold.threshold(),
160            min_distance,
161            existing_points,
162        )
163    }
164}
165
166impl DetectorImage for FloatImageView<'_> {
167    type Sum = f64;
168
169    fn dimensions(self) -> (u32, u32) {
170        (self.width(), self.height())
171    }
172
173    #[inline(always)]
174    fn gradients_into(self, grad_x: &mut [f32], grad_y: &mut [f32]) {
175        let (width, height) = (self.width() as usize, self.height() as usize);
176        scharr_f32_into(self.pixels(), width, height, grad_x, grad_y);
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn threshold_passes_both_parts_to_the_detector() {
186        let absolute = ScoreThreshold::absolute(2.0);
187        assert_eq!(absolute.relative_part(), 0.0);
188        let both = absolute.with_relative(0.1);
189        assert_eq!((both.absolute_part(), both.relative_part()), (2.0, 0.1));
190        assert_eq!(
191            both.threshold(),
192            Threshold {
193                absolute: 2.0,
194                relative: 0.1
195            }
196        );
197    }
198
199    #[test]
200    #[should_panic(expected = "finite and non-negative")]
201    fn negative_absolute_threshold_panics() {
202        let _ = ScoreThreshold::absolute(-1e-9);
203    }
204
205    #[test]
206    #[should_panic(expected = "finite and non-negative")]
207    fn nan_absolute_threshold_panics() {
208        let _ = ScoreThreshold::absolute(f32::NAN);
209    }
210
211    #[test]
212    #[should_panic(expected = "within 0..=1")]
213    fn relative_threshold_above_one_panics() {
214        let _ = ScoreThreshold::absolute(0.0).with_relative(1.5);
215    }
216
217    #[test]
218    #[should_panic(expected = "within 0..=1")]
219    fn nan_relative_threshold_panics() {
220        let _ = ScoreThreshold::absolute(0.0).with_relative(f32::NAN);
221    }
222
223    #[test]
224    fn extreme_permitted_values_keep_scores_finite() {
225        let max = FloatImageView::MAX_ABS_VALUE;
226        let (width, height) = (8u32, 8u32);
227        let pixels: Vec<f32> = (0..width * height)
228            .map(|i| {
229                if (i / width + i % width) % 2 == 0 {
230                    max
231                } else {
232                    -max
233                }
234            })
235            .collect();
236        let checker: Vec<f32> = (0..width * height)
237            .map(|i| {
238                if (i / width < 4) == (i % width < 4) {
239                    max
240                } else {
241                    -max
242                }
243            })
244            .collect();
245        for pixels in [pixels, checker] {
246            let view = FloatImageView::new(&pixels, width, height).unwrap();
247            let mut context = FloatDetectorContext::new();
248            context.detect(view, ScoreThreshold::absolute(0.0), 0);
249            assert!(
250                context
251                    .detector
252                    .scores()
253                    .iter()
254                    .all(|s| s.is_finite() && *s >= 0.0)
255            );
256        }
257    }
258}