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 normalised Harris response
102    /// `det(M)/trace(M)² - k` 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. The score is bounded to `[-k, 0.25 - k]` = `[-0.04, 0.21]`, so raising `threshold`
107    /// within that range is what makes it stricter; anything above `0.21` keeps nothing. Returns
108    /// the corner events as a new stream; a score-based complement to [`Self::efast`].
109    pub fn harris_corners(&self, threshold: f64) -> EventStream {
110        let (width, height) = self.sensor_size();
111        let (xs, ys, ts, ps) = (self.xs(), self.ys(), self.ts(), self.ps());
112        let scale = self.timestamp_scale_ms();
113        let mut builder =
114            EventStreamBuilder::with_capacity(width, height, self.timestamp_scale_ms(), self.len());
115        let margin = HARRIS_RADIUS + 1; // central differences need one pixel beyond the window
116        if width < 2 * margin + 1 || height < 2 * margin + 1 {
117            return builder.build();
118        }
119        // One merged ramp — flow/corner geometry is independent of contrast polarity.
120        let mut sae = vec![i64::MIN; width * height];
121
122        for index in 0..self.len() {
123            let (x, y, t, p) = (xs[index] as usize, ys[index] as usize, ts[index], ps[index]);
124            sae[y * width + x] = t;
125
126            if x < margin || y < margin || x + margin >= width || y + margin >= height {
127                continue;
128            }
129            if harris_response(&sae, x, y, width, scale) > threshold {
130                builder.push(xs[index], ys[index], t, p);
131            }
132        }
133        builder.build()
134    }
135}
136
137/// Tests whether the ring's most-recent timestamps form a contiguous arc whose length lies in
138/// `[min_arc, max_arc]` — i.e. some rotation of the ring has a run of pixels all strictly newer
139/// than every pixel outside the run. `circle` offsets are contiguous around the ring so wrap-around
140/// runs are handled by indexing modulo the ring length.
141fn ring_is_corner(
142    sae: &[i64],
143    cx: usize,
144    cy: usize,
145    width: usize,
146    circle: &[(i32, i32)],
147    (min_arc, max_arc): (usize, usize),
148) -> bool {
149    let n = circle.len();
150    let mut times = [i64::MIN; 20]; // 20 = the largest ring
151    for (slot, &(dx, dy)) in times.iter_mut().zip(circle) {
152        let px = (cx as i32 + dx) as usize;
153        let py = (cy as i32 + dy) as usize;
154        *slot = sae[py * width + px];
155    }
156    let times = &times[..n];
157
158    for length in min_arc..=max_arc {
159        for start in 0..n {
160            let arc_min = (0..length)
161                .map(|k| times[(start + k) % n])
162                .min()
163                .expect("arc length is at least 1");
164            let rest_max = (length..n)
165                .map(|k| times[(start + k) % n])
166                .max()
167                .expect("ring is longer than the arc");
168            if arc_min > rest_max {
169                return true;
170            }
171        }
172    }
173    false
174}
175
176/// Minimum number of valid gradient samples in the window for a Harris score to be meaningful.
177const HARRIS_MIN_SAMPLES: usize = 3;
178
179/// Harris response of the raw SAE ramp over the `(2R+1)²` window around `(cx, cy)`. Reads the SAE
180/// as a time surface `T` (in ms), takes central-difference gradients wherever both neighbours have
181/// fired, and returns `det(M)/trace(M)² - k` for the structure tensor `M = Σ ∇T ∇Tᵀ`. Unfired
182/// pixels are skipped (their gradient is undefined), so a window with too little structure returns
183/// `-∞` (never a corner). The caller guarantees the window lies `HARRIS_RADIUS + 1` inside every
184/// border, so the `x ± 1` / `y ± 1` reads are in bounds.
185///
186/// Dividing by `trace²` is what makes the score a usable *knob* rather than only a sign. `M` is a
187/// plain sum over up to 81 samples of a gradient measured in milliseconds, so `det - k·trace²` is
188/// quartic in that gradient and unbounded — in practice `1e3`..`1e7`, which no caller can guess.
189/// `det/trace²` is `λ₁λ₂/(λ₁+λ₂)²`: dimensionless, independent of how many pixels in the window
190/// happened to have fired, and bounded by `0.25`. The score therefore lands in `[-k, 0.25 - k]`.
191/// The sign is unchanged — for `trace > 0`, `det - k·trace² > 0` iff `det/trace² - k > 0` — so the
192/// default `threshold = 0` selects exactly the same events it always did.
193fn harris_response(sae: &[i64], cx: usize, cy: usize, width: usize, scale: f64) -> f64 {
194    let t_at = |x: usize, y: usize| -> Option<f64> {
195        let t = sae[y * width + x];
196        (t != i64::MIN).then_some(t as f64 * scale)
197    };
198    // Gradient at a fired pixel from central differences, needing both neighbours on each axis.
199    let gradient = |x: usize, y: usize| -> Option<(f64, f64)> {
200        t_at(x, y)?; // the pixel itself must have fired
201        let gx = (t_at(x + 1, y)? - t_at(x - 1, y)?) / 2.0;
202        let gy = (t_at(x, y + 1)? - t_at(x, y - 1)?) / 2.0;
203        Some((gx, gy))
204    };
205
206    let (mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0);
207    let mut samples = 0;
208    for y in cy - HARRIS_RADIUS..=cy + HARRIS_RADIUS {
209        for x in cx - HARRIS_RADIUS..=cx + HARRIS_RADIUS {
210            if let Some((ix, iy)) = gradient(x, y) {
211                sxx += ix * ix;
212                syy += iy * iy;
213                sxy += ix * iy;
214                samples += 1;
215            }
216        }
217    }
218    if samples < HARRIS_MIN_SAMPLES {
219        return f64::NEG_INFINITY;
220    }
221    let det = sxx * syy - sxy * sxy;
222    let trace = sxx + syy;
223    if trace <= 0.0 {
224        return f64::NEG_INFINITY; // every gradient in the window was zero
225    }
226    det / (trace * trace) - HARRIS_K
227}
228
229#[cfg(test)]
230mod tests {
231    use ndarray::{array, Array2};
232
233    use super::{ring_is_corner, INNER_ARC, INNER_CIRCLE};
234    use crate::EventStream;
235
236    fn empty(width: usize, height: usize) -> EventStream {
237        EventStream::from_array2(Array2::zeros((0, 4)), width, height, 0.001)
238    }
239
240    // A tiny SAE laid out so a 4-pixel recent arc appears on the 16-pixel inner ring.
241    fn sae_with_arc(recent: &[usize]) -> Vec<i64> {
242        // 7×7 grid, centre at (3,3); ring pixels default old, `recent` indices set newest.
243        let width = 7;
244        let mut sae = vec![0_i64; width * width];
245        for (k, &(dx, dy)) in INNER_CIRCLE.iter().enumerate() {
246            let x = (3 + dx) as usize;
247            let y = (3 + dy) as usize;
248            sae[y * width + x] = if recent.contains(&k) { 100 } else { 1 };
249        }
250        sae
251    }
252
253    #[test]
254    fn ring_detects_contiguous_arc_and_rejects_scattered_or_long() {
255        let width = 7;
256        // All ring pixels equal → flat, no corner.
257        let flat = sae_with_arc(&[]);
258        assert!(!ring_is_corner(
259            &flat,
260            3,
261            3,
262            width,
263            &INNER_CIRCLE,
264            INNER_ARC
265        ));
266        // 4 contiguous newest pixels → corner.
267        let corner = sae_with_arc(&[2, 3, 4, 5]);
268        assert!(ring_is_corner(
269            &corner,
270            3,
271            3,
272            width,
273            &INNER_CIRCLE,
274            INNER_ARC
275        ));
276        // Half the ring newest (8 pixels) → straight edge, exceeds max arc.
277        let edge = sae_with_arc(&[0, 1, 2, 3, 4, 5, 6, 7]);
278        assert!(!ring_is_corner(
279            &edge,
280            3,
281            3,
282            width,
283            &INNER_CIRCLE,
284            INNER_ARC
285        ));
286        // Two isolated newest pixels on opposite sides → not contiguous.
287        let scattered = sae_with_arc(&[0, 8]);
288        assert!(!ring_is_corner(
289            &scattered,
290            3,
291            3,
292            width,
293            &INNER_CIRCLE,
294            INNER_ARC
295        ));
296    }
297
298    #[test]
299    fn efast_empty_stream_is_empty() {
300        assert_eq!(empty(20, 20).efast().len(), 0);
301        assert_eq!(empty(0, 0).efast().len(), 0);
302    }
303
304    #[test]
305    fn efast_and_harris_return_a_subset() {
306        // A moving L-corner: a horizontal then vertical arm sweeping across time.
307        let mut rows = Vec::new();
308        let mut t = 0_u64;
309        for x in 0..20u64 {
310            rows.push([x, 10, t, 1]);
311            t += 10;
312        }
313        for y in 0..20u64 {
314            rows.push([10, y, t, 1]);
315            t += 10;
316        }
317        let events = Array2::from_shape_vec((rows.len(), 4), rows.concat()).unwrap();
318        let stream = EventStream::from_array2(events, 20, 20, 0.001);
319
320        let corners = stream.efast();
321        assert!(corners.len() <= stream.len());
322        let harris = stream.harris_corners(0.0);
323        assert!(harris.len() <= stream.len());
324    }
325
326    #[test]
327    fn harris_empty_and_tiny_sensor_are_empty() {
328        assert_eq!(empty(20, 20).harris_corners(0.0).len(), 0);
329        // Sensor smaller than the Harris window → nothing evaluable.
330        assert_eq!(
331            EventStream::from_array2(array![[1, 1, 5, 1]], 4, 4, 0.001)
332                .harris_corners(0.0)
333                .len(),
334            0
335        );
336    }
337
338    /// An L-shaped corner sweeping across the sensor, as `(events, sensor_size)`.
339    fn moving_corner() -> (Array2<u64>, usize) {
340        let mut rows = Vec::new();
341        let mut t = 0_u64;
342        for step in 0..24u64 {
343            let c = 6 + step;
344            for offset in 0..10u64 {
345                rows.push([c, c + offset, t, 1]); // vertical arm
346                rows.push([c + offset, c, t, 1]); // horizontal arm
347            }
348            t += 100;
349        }
350        let len = rows.len();
351        (Array2::from_shape_vec((len, 4), rows.concat()).unwrap(), 48)
352    }
353
354    /// The response is `det/trace² - k`, which is a ratio of two quantities that scale together —
355    /// so re-expressing the same recording in different time units must not move the score. Before
356    /// it was normalised, the raw `det - k·trace²` differed by `10¹²` between these two, and any
357    /// non-zero threshold meant something different for each.
358    #[test]
359    fn harris_is_invariant_to_the_timestamp_unit() {
360        let (events, size) = moving_corner();
361        let in_ms = EventStream::from_array2(events.clone(), size, size, 1.0);
362        // The same instants in microseconds: 1000× the raw ticks, each worth a thousandth as much.
363        let in_us = EventStream::from_array2(events, size, size, 0.001).time_scale(1000.0);
364
365        for threshold in [0.0, 0.05, 0.1] {
366            assert_eq!(
367                in_ms.harris_corners(threshold).len(),
368                in_us.harris_corners(threshold).len(),
369                "threshold {threshold} disagreed between ms and µs timestamps"
370            );
371        }
372
373        // And the knob has to actually discriminate somewhere inside its range.
374        let all = in_ms.harris_corners(-0.04).len();
375        let some = in_ms.harris_corners(0.0).len();
376        let none = in_ms.harris_corners(0.25).len();
377        assert!(some > 0 && some < all, "got {some} of {all}");
378        assert_eq!(none, 0);
379    }
380
381    /// A straight edge sweeping across the sensor is rank-1 on the SAE ramp (`R < 0`), so the
382    /// `threshold = 0` Harris keeps almost nothing — the property that makes it a corner detector
383    /// rather than an edge detector.
384    #[test]
385    fn harris_rejects_a_straight_moving_edge() {
386        let mut rows = Vec::new();
387        let mut t = 0_u64;
388        for step in 0..30u64 {
389            for y in 2..28u64 {
390                rows.push([5 + step, y, t, 1]); // vertical edge column advancing in +x
391            }
392            t += 100;
393        }
394        let events = Array2::from_shape_vec((rows.len(), 4), rows.concat()).unwrap();
395        let stream = EventStream::from_array2(events, 40, 30, 0.001);
396        let kept = stream.harris_corners(0.0).len();
397        // Far below 5% of the edge events survive (no true corner is present).
398        assert!(
399            kept * 20 < stream.len(),
400            "straight edge should yield ~no corners, got {kept}/{}",
401            stream.len()
402        );
403    }
404}