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 as i64 - 1;
18        self.remap(width, height, |x, y, t, p| Some((max_x - x, y, t, p)))
19    }
20
21    /// Mirrors vertically (`y → height-1-y`). Sensor size unchanged.
22    pub fn flip_y(&self) -> EventStream {
23        let (width, height) = self.sensor_size();
24        let max_y = height as i64 - 1;
25        self.remap(width, height, |x, y, t, p| Some((x, max_y - y, t, p)))
26    }
27
28    /// Rotates by `k * 90°` clockwise. `k` is taken mod 4; quarter turns swap the sensor dims.
29    pub fn rotate90(&self, k: i32) -> EventStream {
30        let (width, height) = self.sensor_size();
31        let (max_x, max_y) = (width as i64 - 1, height as i64 - 1);
32        match k.rem_euclid(4) {
33            0 => self.remap(width, height, |x, y, t, p| Some((x, y, t, p))),
34            1 => self.remap(height, width, |x, y, t, p| Some((max_y - y, x, t, p))),
35            2 => self.remap(width, height, |x, y, t, p| {
36                Some((max_x - x, max_y - y, t, p))
37            }),
38            _ => self.remap(height, width, |x, y, t, p| Some((y, max_x - x, t, p))),
39        }
40    }
41
42    /// Reflects across the main diagonal (`(x, y) → (y, x)`); swaps the sensor dims.
43    pub fn transpose(&self) -> EventStream {
44        let (width, height) = self.sensor_size();
45        self.remap(height, width, |x, y, t, p| Some((y, x, t, p)))
46    }
47
48    /// Translates by `(dx, dy)`; events shifted off the sensor are dropped. Sensor unchanged.
49    pub fn translate(&self, dx: i64, dy: i64) -> EventStream {
50        let (width, height) = self.sensor_size();
51        self.remap(width, height, |x, y, t, p| Some((x + dx, y + dy, t, p)))
52    }
53
54    /// Resizes the sensor grid to `w`×`h`, rebinning each coordinate proportionally (floored —
55    /// the destination bin, no interpolation). Every event maps into `[0, w)×[0, h)`, so the
56    /// count is conserved; on downscale several events may share a pixel (lossless).
57    pub fn resize(&self, w: usize, h: usize) -> EventStream {
58        let (width, height) = self.sensor_size();
59        let sx = if width > 0 {
60            w as f64 / width as f64
61        } else {
62            0.0
63        };
64        let sy = if height > 0 {
65            h as f64 / height as f64
66        } else {
67            0.0
68        };
69        self.remap(w, h, |x, y, t, p| {
70            Some((
71                (x as f64 * sx).floor() as i64,
72                (y as f64 * sy).floor() as i64,
73                t,
74                p,
75            ))
76        })
77    }
78
79    /// Scales the sensor by `(sx, sy)`, rounding the new dimensions. See [`Self::resize`].
80    pub fn scale(&self, sx: f64, sy: f64) -> EventStream {
81        let (width, height) = self.sensor_size();
82        let w = (width as f64 * sx).round().max(0.0) as usize;
83        let h = (height as f64 * sy).round().max(0.0) as usize;
84        self.resize(w, h)
85    }
86
87    /// Applies a 2×3 affine matrix `[[a,b,c],[d,e,f]]` (`x' = a·x+b·y+c`, rounded). Sensor
88    /// size unchanged; events warped off the sensor are dropped.
89    pub fn warp_affine(&self, m: [[f64; 3]; 2]) -> EventStream {
90        let (width, height) = self.sensor_size();
91        self.remap(width, height, |x, y, t, p| {
92            let (xf, yf) = (x as f64, y as f64);
93            let nx = m[0][0] * xf + m[0][1] * yf + m[0][2];
94            let ny = m[1][0] * xf + m[1][1] * yf + m[1][2];
95            Some((nx.round() as i64, ny.round() as i64, t, p))
96        })
97    }
98
99    /// Applies a 3×3 perspective (homography) matrix, dividing by the homogeneous coordinate.
100    /// Events whose denominator is zero, or that warp off the sensor, are dropped.
101    pub fn warp_perspective(&self, m: [[f64; 3]; 3]) -> EventStream {
102        let (width, height) = self.sensor_size();
103        self.remap(width, height, |x, y, t, p| {
104            let (xf, yf) = (x as f64, y as f64);
105            let w = m[2][0] * xf + m[2][1] * yf + m[2][2];
106            if w == 0.0 {
107                return None;
108            }
109            let nx = (m[0][0] * xf + m[0][1] * yf + m[0][2]) / w;
110            let ny = (m[1][0] * xf + m[1][1] * yf + m[1][2]) / w;
111            Some((nx.round() as i64, ny.round() as i64, t, p))
112        })
113    }
114
115    /// Rectifies events with a [`Camera`]'s intrinsics + distortion, mapping each event from its
116    /// distorted pixel to the undistorted location on the same grid. Builds a per-pixel lookup
117    /// once (the sensor grid is small), then remaps every event through it; events landing off
118    /// the sensor after rectification are dropped. Sensor size unchanged.
119    pub fn undistort(&self, camera: &Camera) -> EventStream {
120        let (width, height) = self.sensor_size();
121        if width == 0 || height == 0 {
122            return self.clone();
123        }
124        let lut: Vec<(i64, i64)> = (0..width * height)
125            .map(|i| {
126                let (u, v) = camera.undistort_point((i % width) as f64, (i / width) as f64);
127                (u.round() as i64, v.round() as i64)
128            })
129            .collect();
130        self.remap(width, height, |x, y, t, p| {
131            let (nx, ny) = lut[y as usize * width + x as usize];
132            Some((nx, ny, t, p))
133        })
134    }
135
136    /// Keeps only events where the `mask_w`×`mask_h` row-major boolean grid is `true`. Events
137    /// outside the mask are dropped. Sensor size unchanged.
138    pub fn mask(&self, mask: &[bool], mask_w: usize, mask_h: usize) -> EventStream {
139        let (width, height) = self.sensor_size();
140        self.remap(width, height, |x, y, t, p| {
141            let (ux, uy) = (x as usize, y as usize);
142            let keep = ux < mask_w && uy < mask_h && mask[uy * mask_w + ux];
143            keep.then_some((x, y, t, p))
144        })
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use crate::{EventStream, EventStreamBuilder};
151
152    /// A 4×3 stream with one event per column on a diagonal-ish path.
153    fn sample() -> EventStream {
154        let mut builder = EventStreamBuilder::new(4, 3, 0.001);
155        builder.push(0, 0, 10, true);
156        builder.push(1, 1, 20, false);
157        builder.push(2, 2, 30, true);
158        builder.push(3, 0, 40, false);
159        builder.build()
160    }
161
162    fn coords(stream: &EventStream) -> Vec<(u16, u16)> {
163        stream
164            .xs()
165            .iter()
166            .copied()
167            .zip(stream.ys().iter().copied())
168            .collect()
169    }
170
171    #[test]
172    fn crop_subsets_and_shifts_to_new_origin() {
173        let cropped = sample().crop(1, 1, 2, 2);
174        assert_eq!(cropped.sensor_size(), (2, 2));
175        assert_eq!(coords(&cropped), vec![(0, 0), (1, 1)]); // events (1,1) and (2,2)
176        assert_eq!(cropped.ts(), &[20, 30]);
177    }
178
179    #[test]
180    fn flips_are_their_own_inverse() {
181        let s = sample();
182        assert_eq!(coords(&s.flip_x().flip_x()), coords(&s));
183        assert_eq!(coords(&s.flip_y().flip_y()), coords(&s));
184        assert_eq!(s.flip_x().sensor_size(), (4, 3));
185        assert_eq!(coords(&s.flip_x()), vec![(3, 0), (2, 1), (1, 2), (0, 0)]);
186    }
187
188    #[test]
189    fn rotate90_swaps_dims_and_round_trips() {
190        let s = sample();
191        assert_eq!(s.rotate90(1).sensor_size(), (3, 4)); // W×H -> H×W
192        assert_eq!(s.rotate90(2).sensor_size(), (4, 3));
193        // Four quarter turns and a (1 then 3) pair both return to the original.
194        assert_eq!(coords(&s.rotate90(4)), coords(&s));
195        assert_eq!(coords(&s.rotate90(1).rotate90(3)), coords(&s));
196        assert_eq!(coords(&s.rotate90(-1)), coords(&s.rotate90(3)));
197    }
198
199    #[test]
200    fn transpose_swaps_axes_and_dims() {
201        let t = sample().transpose();
202        assert_eq!(t.sensor_size(), (3, 4));
203        assert_eq!(coords(&t), vec![(0, 0), (1, 1), (2, 2), (0, 3)]);
204    }
205
206    #[test]
207    fn translate_shifts_and_drops_out_of_bounds() {
208        // x + 2 = [2, 3, 4, 5]; only x = 2, 3 stay on the 4-wide sensor (the rest fall off).
209        let shifted = sample().translate(2, 0);
210        assert_eq!(shifted.xs(), &[2, 3]);
211        assert_eq!(shifted.ys(), &[0, 1]);
212        // Translating the survivors back restores their original coordinates.
213        assert_eq!(coords(&shifted.translate(-2, 0)), vec![(0, 0), (1, 1)]);
214    }
215
216    #[test]
217    fn resize_rebins_and_conserves_count() {
218        let down = sample().resize(2, 2); // 4x3 -> 2x2; floor keeps every event
219        assert_eq!(down.sensor_size(), (2, 2));
220        assert_eq!(down.len(), 4);
221        assert!(down.xs().iter().all(|&x| x < 2) && down.ys().iter().all(|&y| y < 2));
222        assert_eq!(sample().scale(2.0, 2.0).sensor_size(), (8, 6));
223    }
224
225    #[test]
226    fn warp_affine_identity_and_translation() {
227        let s = sample();
228        let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
229        assert_eq!(coords(&s.warp_affine(identity)), coords(&s));
230        let shift = [[1.0, 0.0, 1.0], [0.0, 1.0, 0.0]];
231        assert_eq!(coords(&s.warp_affine(shift)), coords(&s.translate(1, 0)));
232    }
233
234    #[test]
235    fn warp_perspective_identity_round_trips() {
236        let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
237        let s = sample();
238        assert_eq!(coords(&s.warp_perspective(identity)), coords(&s));
239    }
240
241    #[test]
242    fn mask_keeps_only_selected_pixels() {
243        let s = sample(); // 4x3
244        let mut mask = vec![false; 4 * 3];
245        mask[4 + 1] = true; // keep only pixel (1,1): row 1 (×4) + col 1
246        let masked = s.mask(&mask, 4, 3);
247        assert_eq!(coords(&masked), vec![(1, 1)]);
248    }
249
250    #[test]
251    fn undistort_without_distortion_keeps_events_in_place() {
252        use crate::camera::Camera;
253        let s = sample(); // 4×3
254        let camera = Camera::new(100.0, 100.0, 2.0, 1.5); // no distortion -> identity map
255        assert_eq!(coords(&s.undistort(&camera)), coords(&s));
256        assert_eq!(s.undistort(&camera).sensor_size(), (4, 3));
257    }
258
259    #[test]
260    fn undistort_remaps_under_distortion() {
261        use crate::camera::Camera;
262        let s = sample();
263        // Barrel distortion pulls events toward the principal point; the result stays on-grid
264        // and conserves count here (no event leaves the 4×3 sensor).
265        let camera = Camera::with_distortion(50.0, 50.0, 2.0, 1.5, -0.2, 0.0, 0.0, 0.0, 0.0);
266        let out = s.undistort(&camera);
267        assert_eq!(out.sensor_size(), (4, 3));
268        assert!(out.len() <= s.len());
269        assert!(out.xs().iter().all(|&x| x < 4) && out.ys().iter().all(|&y| y < 3));
270    }
271
272    #[test]
273    fn transforms_handle_the_empty_stream() {
274        let empty = EventStreamBuilder::new(4, 3, 0.001).build();
275        assert!(empty.flip_x().is_empty());
276        assert!(empty.rotate90(1).is_empty());
277        assert_eq!(empty.crop(0, 0, 2, 2).sensor_size(), (2, 2));
278        assert!(empty.resize(2, 2).is_empty());
279    }
280}