Skip to main content

eventcv_core/
features.rs

1//! Event feature detection (OpenCV `features2d` analogue). Both detectors maintain a per-polarity
2//! Surface of Active Events (SAE — the latest timestamp seen at each pixel) and return a **new**
3//! [`EventStream`] holding only the events that sit on a moving corner, so detection **chains**
4//! like a denoising filter. Both assume events arrive in **ascending time order** (what the
5//! readers produce); call [`EventStream::sort_by_time`] first if a stream might be unordered.
6
7use crate::{EventStream, EventStreamBuilder};
8
9/// Bresenham circle of radius 3 (16 pixels), in contiguous clockwise order — the FAST ring.
10const INNER_CIRCLE: [(i32, i32); 16] = [
11    (0, 3),
12    (1, 3),
13    (2, 2),
14    (3, 1),
15    (3, 0),
16    (3, -1),
17    (2, -2),
18    (1, -3),
19    (0, -3),
20    (-1, -3),
21    (-2, -2),
22    (-3, -1),
23    (-3, 0),
24    (-3, 1),
25    (-2, 2),
26    (-1, 3),
27];
28
29/// Bresenham circle of radius 4 (20 pixels), contiguous clockwise — eFAST's outer ring.
30const OUTER_CIRCLE: [(i32, i32); 20] = [
31    (0, 4),
32    (1, 4),
33    (2, 3),
34    (3, 2),
35    (4, 1),
36    (4, 0),
37    (4, -1),
38    (3, -2),
39    (2, -3),
40    (1, -4),
41    (0, -4),
42    (-1, -4),
43    (-2, -3),
44    (-3, -2),
45    (-4, -1),
46    (-4, 0),
47    (-4, 1),
48    (-3, 2),
49    (-2, 3),
50    (-1, 4),
51];
52
53/// eFAST arc-length bounds: a corner's most-recent pixels form a contiguous arc this many pixels
54/// long. A straight edge fills ~half the ring (too long); noise fills too little.
55const INNER_ARC: (usize, usize) = (3, 6);
56const OUTER_ARC: (usize, usize) = (4, 8);
57
58/// Harris uses a fixed 9×9 window (radius 4) of the time surface around each event.
59const HARRIS_RADIUS: usize = 4;
60/// Harris' empirical sensitivity constant `k` in `det - k·trace²`.
61const HARRIS_K: f64 = 0.04;
62
63impl EventStream {
64    /// **eFAST** event corner detector (Mueggler et al., *Fast Event-based Corner Detection*,
65    /// BMVC 2017). For each event it updates its polarity's SAE, then tests two Bresenham rings
66    /// (radius 3 and 4) around the pixel: an event is a corner when, on **both** rings, the most
67    /// recent timestamps form a contiguous arc within the `INNER_ARC`/`OUTER_ARC` bounds — the
68    /// signature of a moving corner rather than a straight edge. Events too close to the border to
69    /// evaluate the outer ring are dropped. Returns the corner events as a new stream.
70    pub fn efast(&self) -> EventStream {
71        let (width, height) = self.sensor_size();
72        let (xs, ys, ts, ps) = (self.xs(), self.ys(), self.ts(), self.ps());
73        let mut builder =
74            EventStreamBuilder::with_capacity(width, height, self.timestamp_scale_ms(), self.len());
75        if width == 0 || height == 0 {
76            return builder.build();
77        }
78        // Separate surfaces per polarity, as in the paper.
79        let mut sae_on = vec![i64::MIN; width * height];
80        let mut sae_off = vec![i64::MIN; width * height];
81
82        for index in 0..self.len() {
83            let (x, y, t, p) = (xs[index] as usize, ys[index] as usize, ts[index], ps[index]);
84            let sae = if p { &mut sae_on } else { &mut sae_off };
85            sae[y * width + x] = t; // the current pixel is the newest by construction
86
87            // Need radius 4 clear of every border to sample the outer ring.
88            if x < 4 || y < 4 || x + 4 >= width || y + 4 >= height {
89                continue;
90            }
91            if ring_is_corner(sae, x, y, width, &INNER_CIRCLE, INNER_ARC)
92                && ring_is_corner(sae, x, y, width, &OUTER_CIRCLE, OUTER_ARC)
93            {
94                builder.push(xs[index], ys[index], t, p);
95            }
96        }
97        builder.build()
98    }
99
100    /// **Harris corner score on the Surface of Active Events.** For each event it updates a
101    /// merged SAE of raw latest timestamps, then computes the Harris response
102    /// `det(M) - k·trace(M)²` of the structure tensor `M = Σ ∇T ∇Tᵀ` of the SAE's spatial
103    /// gradient over a 9×9 window. Because the SAE is a local time *ramp*, a straight moving edge
104    /// has a constant gradient direction (rank-1 `M`, `R < 0`) while a corner mixes gradient
105    /// directions (rank-2 `M`, `R > 0`) — so the default `threshold = 0` keeps corners and rejects
106    /// edges. Raise `threshold` to be stricter. Returns the corner events as a new stream; a
107    /// score-based complement to [`Self::efast`].
108    pub fn harris_corners(&self, threshold: f64) -> EventStream {
109        let (width, height) = self.sensor_size();
110        let (xs, ys, ts, ps) = (self.xs(), self.ys(), self.ts(), self.ps());
111        let scale = self.timestamp_scale_ms();
112        let mut builder =
113            EventStreamBuilder::with_capacity(width, height, self.timestamp_scale_ms(), self.len());
114        let margin = HARRIS_RADIUS + 1; // central differences need one pixel beyond the window
115        if width < 2 * margin + 1 || height < 2 * margin + 1 {
116            return builder.build();
117        }
118        // One merged ramp — flow/corner geometry is independent of contrast polarity.
119        let mut sae = vec![i64::MIN; width * height];
120
121        for index in 0..self.len() {
122            let (x, y, t, p) = (xs[index] as usize, ys[index] as usize, ts[index], ps[index]);
123            sae[y * width + x] = t;
124
125            if x < margin || y < margin || x + margin >= width || y + margin >= height {
126                continue;
127            }
128            if harris_response(&sae, x, y, width, scale) > threshold {
129                builder.push(xs[index], ys[index], t, p);
130            }
131        }
132        builder.build()
133    }
134}
135
136/// Tests whether the ring's most-recent timestamps form a contiguous arc whose length lies in
137/// `[min_arc, max_arc]` — i.e. some rotation of the ring has a run of pixels all strictly newer
138/// than every pixel outside the run. `circle` offsets are contiguous around the ring so wrap-around
139/// runs are handled by indexing modulo the ring length.
140fn ring_is_corner(
141    sae: &[i64],
142    cx: usize,
143    cy: usize,
144    width: usize,
145    circle: &[(i32, i32)],
146    (min_arc, max_arc): (usize, usize),
147) -> bool {
148    let n = circle.len();
149    let mut times = [i64::MIN; 20]; // 20 = the largest ring
150    for (slot, &(dx, dy)) in times.iter_mut().zip(circle) {
151        let px = (cx as i32 + dx) as usize;
152        let py = (cy as i32 + dy) as usize;
153        *slot = sae[py * width + px];
154    }
155    let times = &times[..n];
156
157    for length in min_arc..=max_arc {
158        for start in 0..n {
159            let arc_min = (0..length)
160                .map(|k| times[(start + k) % n])
161                .min()
162                .expect("arc length is at least 1");
163            let rest_max = (length..n)
164                .map(|k| times[(start + k) % n])
165                .max()
166                .expect("ring is longer than the arc");
167            if arc_min > rest_max {
168                return true;
169            }
170        }
171    }
172    false
173}
174
175/// Minimum number of valid gradient samples in the window for a Harris score to be meaningful.
176const HARRIS_MIN_SAMPLES: usize = 3;
177
178/// Harris response of the raw SAE ramp over the `(2R+1)²` window around `(cx, cy)`. Reads the SAE
179/// as a time surface `T` (in ms), takes central-difference gradients wherever both neighbours have
180/// fired, and returns `det(M) - k·trace(M)²` for the structure tensor `M = Σ ∇T ∇Tᵀ`. Unfired
181/// pixels are skipped (their gradient is undefined), so a window with too little structure returns
182/// `-∞` (never a corner). The caller guarantees the window lies `HARRIS_RADIUS + 1` inside every
183/// border, so the `x ± 1` / `y ± 1` reads are in bounds.
184fn harris_response(sae: &[i64], cx: usize, cy: usize, width: usize, scale: f64) -> f64 {
185    let t_at = |x: usize, y: usize| -> Option<f64> {
186        let t = sae[y * width + x];
187        (t != i64::MIN).then_some(t as f64 * scale)
188    };
189    // Gradient at a fired pixel from central differences, needing both neighbours on each axis.
190    let gradient = |x: usize, y: usize| -> Option<(f64, f64)> {
191        t_at(x, y)?; // the pixel itself must have fired
192        let gx = (t_at(x + 1, y)? - t_at(x - 1, y)?) / 2.0;
193        let gy = (t_at(x, y + 1)? - t_at(x, y - 1)?) / 2.0;
194        Some((gx, gy))
195    };
196
197    let (mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0);
198    let mut samples = 0;
199    for y in cy - HARRIS_RADIUS..=cy + HARRIS_RADIUS {
200        for x in cx - HARRIS_RADIUS..=cx + HARRIS_RADIUS {
201            if let Some((ix, iy)) = gradient(x, y) {
202                sxx += ix * ix;
203                syy += iy * iy;
204                sxy += ix * iy;
205                samples += 1;
206            }
207        }
208    }
209    if samples < HARRIS_MIN_SAMPLES {
210        return f64::NEG_INFINITY;
211    }
212    let det = sxx * syy - sxy * sxy;
213    let trace = sxx + syy;
214    det - HARRIS_K * trace * trace
215}
216
217#[cfg(test)]
218mod tests {
219    use ndarray::{array, Array2};
220
221    use super::{ring_is_corner, INNER_ARC, INNER_CIRCLE};
222    use crate::EventStream;
223
224    fn empty(width: usize, height: usize) -> EventStream {
225        EventStream::from_array2(Array2::zeros((0, 4)), width, height, 0.001)
226    }
227
228    // A tiny SAE laid out so a 4-pixel recent arc appears on the 16-pixel inner ring.
229    fn sae_with_arc(recent: &[usize]) -> Vec<i64> {
230        // 7×7 grid, centre at (3,3); ring pixels default old, `recent` indices set newest.
231        let width = 7;
232        let mut sae = vec![0_i64; width * width];
233        for (k, &(dx, dy)) in INNER_CIRCLE.iter().enumerate() {
234            let x = (3 + dx) as usize;
235            let y = (3 + dy) as usize;
236            sae[y * width + x] = if recent.contains(&k) { 100 } else { 1 };
237        }
238        sae
239    }
240
241    #[test]
242    fn ring_detects_contiguous_arc_and_rejects_scattered_or_long() {
243        let width = 7;
244        // All ring pixels equal → flat, no corner.
245        let flat = sae_with_arc(&[]);
246        assert!(!ring_is_corner(
247            &flat,
248            3,
249            3,
250            width,
251            &INNER_CIRCLE,
252            INNER_ARC
253        ));
254        // 4 contiguous newest pixels → corner.
255        let corner = sae_with_arc(&[2, 3, 4, 5]);
256        assert!(ring_is_corner(
257            &corner,
258            3,
259            3,
260            width,
261            &INNER_CIRCLE,
262            INNER_ARC
263        ));
264        // Half the ring newest (8 pixels) → straight edge, exceeds max arc.
265        let edge = sae_with_arc(&[0, 1, 2, 3, 4, 5, 6, 7]);
266        assert!(!ring_is_corner(
267            &edge,
268            3,
269            3,
270            width,
271            &INNER_CIRCLE,
272            INNER_ARC
273        ));
274        // Two isolated newest pixels on opposite sides → not contiguous.
275        let scattered = sae_with_arc(&[0, 8]);
276        assert!(!ring_is_corner(
277            &scattered,
278            3,
279            3,
280            width,
281            &INNER_CIRCLE,
282            INNER_ARC
283        ));
284    }
285
286    #[test]
287    fn efast_empty_stream_is_empty() {
288        assert_eq!(empty(20, 20).efast().len(), 0);
289        assert_eq!(empty(0, 0).efast().len(), 0);
290    }
291
292    #[test]
293    fn efast_and_harris_return_a_subset() {
294        // A moving L-corner: a horizontal then vertical arm sweeping across time.
295        let mut rows = Vec::new();
296        let mut t = 0_u64;
297        for x in 0..20u64 {
298            rows.push([x, 10, t, 1]);
299            t += 10;
300        }
301        for y in 0..20u64 {
302            rows.push([10, y, t, 1]);
303            t += 10;
304        }
305        let events = Array2::from_shape_vec((rows.len(), 4), rows.concat()).unwrap();
306        let stream = EventStream::from_array2(events, 20, 20, 0.001);
307
308        let corners = stream.efast();
309        assert!(corners.len() <= stream.len());
310        let harris = stream.harris_corners(0.0);
311        assert!(harris.len() <= stream.len());
312    }
313
314    #[test]
315    fn harris_empty_and_tiny_sensor_are_empty() {
316        assert_eq!(empty(20, 20).harris_corners(0.0).len(), 0);
317        // Sensor smaller than the Harris window → nothing evaluable.
318        assert_eq!(
319            EventStream::from_array2(array![[1, 1, 5, 1]], 4, 4, 0.001)
320                .harris_corners(0.0)
321                .len(),
322            0
323        );
324    }
325
326    /// A straight edge sweeping across the sensor is rank-1 on the SAE ramp (`R < 0`), so the
327    /// `threshold = 0` Harris keeps almost nothing — the property that makes it a corner detector
328    /// rather than an edge detector.
329    #[test]
330    fn harris_rejects_a_straight_moving_edge() {
331        let mut rows = Vec::new();
332        let mut t = 0_u64;
333        for step in 0..30u64 {
334            for y in 2..28u64 {
335                rows.push([5 + step, y, t, 1]); // vertical edge column advancing in +x
336            }
337            t += 100;
338        }
339        let events = Array2::from_shape_vec((rows.len(), 4), rows.concat()).unwrap();
340        let stream = EventStream::from_array2(events, 40, 30, 0.001);
341        let kept = stream.harris_corners(0.0).len();
342        // Far below 5% of the edge events survive (no true corner is present).
343        assert!(
344            kept * 20 < stream.len(),
345            "straight edge should yield ~no corners, got {kept}/{}",
346            stream.len()
347        );
348    }
349}