Skip to main content

eventcv_core/transform/
spatial.rs

1//! Spatial (geometric) transforms on the event stream. Coordinates are remapped and rounded;
2//! out-of-bounds events drop and the sensor size is recomputed per op.
3
4use crate::camera::Camera;
5use crate::EventStream;
6
7impl EventStream {
8    /// Keeps events inside the `w`×`h` window at `(x0, y0)` and shifts them to a new origin.
9    /// The result is a `w`×`h` stream.
10    pub fn crop(&self, x0: i64, y0: i64, w: usize, h: usize) -> EventStream {
11        self.remap(w, h, |x, y, t, p| Some((x - x0, y - y0, t, p)))
12    }
13
14    /// Mirrors horizontally (`x → width-1-x`). Sensor size unchanged.
15    pub fn flip_x(&self) -> EventStream {
16        let (width, height) = self.sensor_size();
17        let max_x = width.saturating_sub(1) as u16;
18        self.map_columns(width, height, |out| {
19            for x in &mut out.xs {
20                *x = max_x - *x;
21            }
22        })
23    }
24
25    /// Mirrors vertically (`y → height-1-y`). Sensor size unchanged.
26    pub fn flip_y(&self) -> EventStream {
27        let (width, height) = self.sensor_size();
28        let max_y = height.saturating_sub(1) as u16;
29        self.map_columns(width, height, |out| {
30            for y in &mut out.ys {
31                *y = max_y - *y;
32            }
33        })
34    }
35
36    /// Rotates by `k * 90°` clockwise. `k` is taken mod 4; quarter turns swap the sensor dims.
37    ///
38    /// A quarter turn swaps the two coordinate columns before rewriting one of them, so it copies
39    /// one column rather than four.
40    pub fn rotate90(&self, k: i32) -> EventStream {
41        let (width, height) = self.sensor_size();
42        let (max_x, max_y) = (
43            width.saturating_sub(1) as u16,
44            height.saturating_sub(1) as u16,
45        );
46        match k.rem_euclid(4) {
47            0 => self.map_columns(width, height, |_| {}),
48            1 => self.map_columns(height, width, |out| {
49                std::mem::swap(&mut out.xs, &mut out.ys);
50                for x in &mut out.xs {
51                    *x = max_y - *x;
52                }
53            }),
54            2 => self.map_columns(width, height, |out| {
55                for x in &mut out.xs {
56                    *x = max_x - *x;
57                }
58                for y in &mut out.ys {
59                    *y = max_y - *y;
60                }
61            }),
62            _ => self.map_columns(height, width, |out| {
63                std::mem::swap(&mut out.xs, &mut out.ys);
64                for y in &mut out.ys {
65                    *y = max_x - *y;
66                }
67            }),
68        }
69    }
70
71    /// Reflects across the main diagonal (`(x, y) → (y, x)`); swaps the sensor dims.
72    pub fn transpose(&self) -> EventStream {
73        let (width, height) = self.sensor_size();
74        self.map_columns(height, width, |out| {
75            std::mem::swap(&mut out.xs, &mut out.ys)
76        })
77    }
78
79    /// Translates by `(dx, dy)`; events shifted off the sensor are dropped. Sensor unchanged.
80    pub fn translate(&self, dx: i64, dy: i64) -> EventStream {
81        let (width, height) = self.sensor_size();
82        self.remap(width, height, |x, y, t, p| Some((x + dx, y + dy, t, p)))
83    }
84
85    /// Resizes the sensor grid to `w`×`h`, rebinning each coordinate proportionally (floored —
86    /// the destination bin, no interpolation). Every event maps into `[0, w)×[0, h)`, so the
87    /// count is conserved; on downscale several events may share a pixel (lossless).
88    pub fn resize(&self, w: usize, h: usize) -> EventStream {
89        let (width, height) = self.sensor_size();
90        let sx = if width > 0 {
91            w as f64 / width as f64
92        } else {
93            0.0
94        };
95        let sy = if height > 0 {
96            h as f64 / height as f64
97        } else {
98            0.0
99        };
100        self.map_columns(w, h, |out| {
101            for x in &mut out.xs {
102                *x = (f64::from(*x) * sx).floor() as u16;
103            }
104            for y in &mut out.ys {
105                *y = (f64::from(*y) * sy).floor() as u16;
106            }
107        })
108    }
109
110    /// Scales the sensor by `(sx, sy)`, rounding the new dimensions. See [`Self::resize`].
111    pub fn scale(&self, sx: f64, sy: f64) -> EventStream {
112        let (width, height) = self.sensor_size();
113        let w = (width as f64 * sx).round().max(0.0) as usize;
114        let h = (height as f64 * sy).round().max(0.0) as usize;
115        self.resize(w, h)
116    }
117
118    /// Applies a 2×3 affine matrix `[[a,b,c],[d,e,f]]` (`x' = a·x+b·y+c`, rounded). Sensor
119    /// size unchanged; events warped off the sensor are dropped.
120    pub fn warp_affine(&self, m: [[f64; 3]; 2]) -> EventStream {
121        let (width, height) = self.sensor_size();
122        self.remap(width, height, |x, y, t, p| {
123            let (xf, yf) = (x as f64, y as f64);
124            let nx = m[0][0] * xf + m[0][1] * yf + m[0][2];
125            let ny = m[1][0] * xf + m[1][1] * yf + m[1][2];
126            Some((nx.round() as i64, ny.round() as i64, t, p))
127        })
128    }
129
130    /// Applies a 3×3 perspective (homography) matrix, dividing by the homogeneous coordinate.
131    /// Events whose denominator is zero, or that warp off the sensor, are dropped.
132    pub fn warp_perspective(&self, m: [[f64; 3]; 3]) -> EventStream {
133        let (width, height) = self.sensor_size();
134        self.remap(width, height, |x, y, t, p| {
135            let (xf, yf) = (x as f64, y as f64);
136            let w = m[2][0] * xf + m[2][1] * yf + m[2][2];
137            if w == 0.0 {
138                return None;
139            }
140            let nx = (m[0][0] * xf + m[0][1] * yf + m[0][2]) / w;
141            let ny = (m[1][0] * xf + m[1][1] * yf + m[1][2]) / w;
142            Some((nx.round() as i64, ny.round() as i64, t, p))
143        })
144    }
145
146    /// Rectifies events with a [`Camera`]'s intrinsics + distortion, mapping each event from its
147    /// distorted pixel to the undistorted location on the same grid. Builds a per-pixel lookup
148    /// once (the sensor grid is small), then remaps every event through it; events landing off
149    /// the sensor after rectification are dropped. Sensor size unchanged.
150    pub fn undistort(&self, camera: &Camera) -> EventStream {
151        let (width, height) = self.sensor_size();
152        if width == 0 || height == 0 {
153            return self.clone();
154        }
155        let lut: Vec<(i64, i64)> = (0..width * height)
156            .map(|i| {
157                let (u, v) = camera.undistort_point((i % width) as f64, (i / width) as f64);
158                (u.round() as i64, v.round() as i64)
159            })
160            .collect();
161        self.remap(width, height, |x, y, t, p| {
162            let (nx, ny) = lut[y as usize * width + x as usize];
163            Some((nx, ny, t, p))
164        })
165    }
166
167    /// Keeps only events where the `mask_w`×`mask_h` row-major boolean grid is `true`. Events
168    /// outside the mask are dropped. Sensor size unchanged.
169    pub fn mask(&self, mask: &[bool], mask_w: usize, mask_h: usize) -> EventStream {
170        let (width, height) = self.sensor_size();
171        self.remap(width, height, |x, y, t, p| {
172            let (ux, uy) = (x as usize, y as usize);
173            let keep = ux < mask_w && uy < mask_h && mask[uy * mask_w + ux];
174            keep.then_some((x, y, t, p))
175        })
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use crate::{EventStream, EventStreamBuilder};
182
183    /// A 4×3 stream with one event per column on a diagonal-ish path.
184    fn sample() -> EventStream {
185        let mut builder = EventStreamBuilder::new(4, 3, 0.001);
186        builder.push(0, 0, 10, true);
187        builder.push(1, 1, 20, false);
188        builder.push(2, 2, 30, true);
189        builder.push(3, 0, 40, false);
190        builder.build()
191    }
192
193    fn coords(stream: &EventStream) -> Vec<(u16, u16)> {
194        stream
195            .xs()
196            .iter()
197            .copied()
198            .zip(stream.ys().iter().copied())
199            .collect()
200    }
201
202    #[test]
203    fn crop_subsets_and_shifts_to_new_origin() {
204        let cropped = sample().crop(1, 1, 2, 2);
205        assert_eq!(cropped.sensor_size(), (2, 2));
206        assert_eq!(coords(&cropped), vec![(0, 0), (1, 1)]); // events (1,1) and (2,2)
207        assert_eq!(cropped.ts(), &[20, 30]);
208    }
209
210    #[test]
211    fn flips_are_their_own_inverse() {
212        let s = sample();
213        assert_eq!(coords(&s.flip_x().flip_x()), coords(&s));
214        assert_eq!(coords(&s.flip_y().flip_y()), coords(&s));
215        assert_eq!(s.flip_x().sensor_size(), (4, 3));
216        assert_eq!(coords(&s.flip_x()), vec![(3, 0), (2, 1), (1, 2), (0, 0)]);
217    }
218
219    #[test]
220    fn rotate90_swaps_dims_and_round_trips() {
221        let s = sample();
222        assert_eq!(s.rotate90(1).sensor_size(), (3, 4)); // W×H -> H×W
223        assert_eq!(s.rotate90(2).sensor_size(), (4, 3));
224        // Four quarter turns and a (1 then 3) pair both return to the original.
225        assert_eq!(coords(&s.rotate90(4)), coords(&s));
226        assert_eq!(coords(&s.rotate90(1).rotate90(3)), coords(&s));
227        assert_eq!(coords(&s.rotate90(-1)), coords(&s.rotate90(3)));
228    }
229
230    #[test]
231    fn transpose_swaps_axes_and_dims() {
232        let t = sample().transpose();
233        assert_eq!(t.sensor_size(), (3, 4));
234        assert_eq!(coords(&t), vec![(0, 0), (1, 1), (2, 2), (0, 3)]);
235    }
236
237    #[test]
238    fn translate_shifts_and_drops_out_of_bounds() {
239        // x + 2 = [2, 3, 4, 5]; only x = 2, 3 stay on the 4-wide sensor (the rest fall off).
240        let shifted = sample().translate(2, 0);
241        assert_eq!(shifted.xs(), &[2, 3]);
242        assert_eq!(shifted.ys(), &[0, 1]);
243        // Translating the survivors back restores their original coordinates.
244        assert_eq!(coords(&shifted.translate(-2, 0)), vec![(0, 0), (1, 1)]);
245    }
246
247    #[test]
248    fn resize_rebins_and_conserves_count() {
249        let down = sample().resize(2, 2); // 4x3 -> 2x2; floor keeps every event
250        assert_eq!(down.sensor_size(), (2, 2));
251        assert_eq!(down.len(), 4);
252        assert!(down.xs().iter().all(|&x| x < 2) && down.ys().iter().all(|&y| y < 2));
253        assert_eq!(sample().scale(2.0, 2.0).sensor_size(), (8, 6));
254    }
255
256    #[test]
257    fn warp_affine_identity_and_translation() {
258        let s = sample();
259        let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
260        assert_eq!(coords(&s.warp_affine(identity)), coords(&s));
261        let shift = [[1.0, 0.0, 1.0], [0.0, 1.0, 0.0]];
262        assert_eq!(coords(&s.warp_affine(shift)), coords(&s.translate(1, 0)));
263    }
264
265    #[test]
266    fn warp_perspective_identity_round_trips() {
267        let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
268        let s = sample();
269        assert_eq!(coords(&s.warp_perspective(identity)), coords(&s));
270    }
271
272    #[test]
273    fn mask_keeps_only_selected_pixels() {
274        let s = sample(); // 4x3
275        let mut mask = vec![false; 4 * 3];
276        mask[4 + 1] = true; // keep only pixel (1,1): row 1 (×4) + col 1
277        let masked = s.mask(&mask, 4, 3);
278        assert_eq!(coords(&masked), vec![(1, 1)]);
279    }
280
281    #[test]
282    fn undistort_without_distortion_keeps_events_in_place() {
283        use crate::camera::Camera;
284        let s = sample(); // 4×3
285        let camera = Camera::new(100.0, 100.0, 2.0, 1.5); // no distortion -> identity map
286        assert_eq!(coords(&s.undistort(&camera)), coords(&s));
287        assert_eq!(s.undistort(&camera).sensor_size(), (4, 3));
288    }
289
290    #[test]
291    fn undistort_remaps_under_distortion() {
292        use crate::camera::Camera;
293        let s = sample();
294        // Barrel distortion pulls events toward the principal point; the result stays on-grid
295        // and conserves count here (no event leaves the 4×3 sensor).
296        let camera = Camera::with_distortion(50.0, 50.0, 2.0, 1.5, -0.2, 0.0, 0.0, 0.0, 0.0);
297        let out = s.undistort(&camera);
298        assert_eq!(out.sensor_size(), (4, 3));
299        assert!(out.len() <= s.len());
300        assert!(out.xs().iter().all(|&x| x < 4) && out.ys().iter().all(|&y| y < 3));
301    }
302
303    /// The count-preserving transforms bypass `remap` and edit the columns directly, so the one
304    /// thing that has to stay true is that they still agree with `remap` event for event.
305    #[test]
306    fn bijective_transforms_agree_with_the_general_path() {
307        let mut builder = EventStreamBuilder::new(13, 7, 0.001);
308        for index in 0..60_u16 {
309            builder.push(index % 13, index % 7, i64::from(index) * 3, index % 3 == 0);
310        }
311        let s = builder.build();
312        let (w, h) = s.sensor_size();
313        let (max_x, max_y) = (w as i64 - 1, h as i64 - 1);
314        let (sx, sy) = (5.0 / w as f64, 4.0 / h as f64);
315
316        let cases: [(EventStream, EventStream); 6] = [
317            (
318                s.flip_x(),
319                s.remap(w, h, |x, y, t, p| Some((max_x - x, y, t, p))),
320            ),
321            (
322                s.flip_y(),
323                s.remap(w, h, |x, y, t, p| Some((x, max_y - y, t, p))),
324            ),
325            (
326                s.transpose(),
327                s.remap(h, w, |x, y, t, p| Some((y, x, t, p))),
328            ),
329            (
330                s.rotate90(1),
331                s.remap(h, w, |x, y, t, p| Some((max_y - y, x, t, p))),
332            ),
333            (
334                s.rotate90(3),
335                s.remap(h, w, |x, y, t, p| Some((y, max_x - x, t, p))),
336            ),
337            (
338                s.resize(5, 4),
339                s.remap(5, 4, |x, y, t, p| {
340                    Some((
341                        (x as f64 * sx).floor() as i64,
342                        (y as f64 * sy).floor() as i64,
343                        t,
344                        p,
345                    ))
346                }),
347            ),
348        ];
349
350        for (fast, general) in cases {
351            assert_eq!(fast.sensor_size(), general.sensor_size());
352            assert_eq!(coords(&fast), coords(&general));
353            assert_eq!(fast.ts(), general.ts());
354            assert_eq!(fast.ps(), general.ps());
355        }
356    }
357
358    /// Every transform that bypasses `remap` claims to map onto the grid it declares. If one ever
359    /// did not, it would silently produce out-of-range coordinates instead of dropping the event.
360    #[test]
361    fn bijective_transforms_keep_every_event_in_bounds() {
362        let s = sample();
363        for out in [
364            s.flip_x(),
365            s.flip_y(),
366            s.transpose(),
367            s.rotate90(1),
368            s.rotate90(2),
369            s.rotate90(3),
370            s.resize(2, 2),
371            s.resize(9, 7),
372            s.time_shift(-5),
373            s.invert_polarity(),
374        ] {
375            let (width, height) = out.sensor_size();
376            assert_eq!(out.len(), s.len());
377            assert!(out.xs().iter().all(|&x| (x as usize) < width));
378            assert!(out.ys().iter().all(|&y| (y as usize) < height));
379        }
380    }
381
382    #[test]
383    fn transforms_handle_the_empty_stream() {
384        let empty = EventStreamBuilder::new(4, 3, 0.001).build();
385        assert!(empty.flip_x().is_empty());
386        assert!(empty.rotate90(1).is_empty());
387        assert_eq!(empty.crop(0, 0, 2, 2).sensor_size(), (2, 2));
388        assert!(empty.resize(2, 2).is_empty());
389    }
390}