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 = ×[..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}