Skip to main content

eventcv_core/
augment.rs

1//! Random augmentations — the training-time counterparts of the deterministic ops in `transform`.
2//!
3//! Every augmentation takes an explicit `seed` and is a pure function of `(stream, seed)`: the same
4//! seed always produces the same output. Nothing here reads the thread RNG or the clock, because an
5//! augmentation whose result depends on *when* it ran cannot be reproduced from a training log.
6//!
7//! The ops that only sometimes fire (`random_flip_x`, `time_reversal`, …) take a probability `p` and
8//! draw once per call, so the decision applies to the whole stream rather than per event. The ops
9//! that perturb events (`event_drop`, `spatial_jitter`, …) draw per event.
10//!
11//! Where an augmentation is just "sometimes do an existing transform", it calls that transform rather
12//! than reimplementing it — the geometry lives in one place. Note that the not-firing branch returns
13//! `self.clone()`, which is a real deep copy of the column arrays; callers that augment large slices
14//! should prefer chaining over calling an augmentation they expect to be a no-op.
15
16use rand::{rngs::StdRng, Rng, SeedableRng};
17use rand_distr::{Distribution, Normal};
18
19use crate::EventStream;
20
21/// Derives an augmentation's RNG from its seed and the index of the slice being augmented.
22///
23/// Deferred reader ops see slices in whatever order the consumer asks for them — a shuffled
24/// `DataLoader`, or several worker processes at once — so an RNG carried across calls would make the
25/// augmentation depend on access order. Seeding per `(seed, index)` instead means slice `i` augments
26/// identically however it is reached. The multiply-xorshift is splitmix64's finalizer, which
27/// decorrelates the small, adjacent indices we actually pass in (0, 1, 2, …).
28pub fn slice_rng(seed: u64, index: usize) -> StdRng {
29    let mut z = seed.wrapping_add((index as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
30    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
31    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
32    StdRng::seed_from_u64(z ^ (z >> 31))
33}
34
35impl EventStream {
36    /// Mirrors the stream left-right with probability `p`.
37    pub fn random_flip_x(&self, p: f64, seed: u64) -> EventStream {
38        if fires(p, seed) {
39            self.flip_x()
40        } else {
41            self.clone()
42        }
43    }
44
45    /// Mirrors the stream top-bottom with probability `p`.
46    pub fn random_flip_y(&self, p: f64, seed: u64) -> EventStream {
47        if fires(p, seed) {
48            self.flip_y()
49        } else {
50            self.clone()
51        }
52    }
53
54    /// Inverts every polarity with probability `p`.
55    ///
56    /// Draws once for the stream, not once per event: flipping a random *subset* of polarities is
57    /// label noise, whereas flipping all of them is the physically meaningful augmentation (the same
58    /// scene with the contrast direction reversed).
59    pub fn random_polarity_flip(&self, p: f64, seed: u64) -> EventStream {
60        if fires(p, seed) {
61            self.invert_polarity()
62        } else {
63            self.clone()
64        }
65    }
66
67    /// Takes a random `width × height` crop. A window at least as large as the sensor is the
68    /// identity, so this is safe to leave in a pipeline that also runs on smaller recordings.
69    pub fn random_crop(&self, width: usize, height: usize, seed: u64) -> EventStream {
70        let (sensor_w, sensor_h) = self.sensor_size();
71        if width >= sensor_w && height >= sensor_h {
72            return self.clone();
73        }
74        let mut rng = slice_rng(seed, 0);
75        let x0 = rng.gen_range(0..=sensor_w.saturating_sub(width)) as i64;
76        let y0 = rng.gen_range(0..=sensor_h.saturating_sub(height)) as i64;
77        self.crop(x0, y0, width, height)
78    }
79
80    /// Drops each event independently with probability `p`, thinning the stream without changing its
81    /// geometry or duration. `p <= 0` is the identity; `p >= 1` empties the stream.
82    pub fn event_drop(&self, p: f64, seed: u64) -> EventStream {
83        if p <= 0.0 {
84            return self.clone();
85        }
86        let mut rng = slice_rng(seed, 0);
87        let (width, height) = self.sensor_size();
88        self.remap(width, height, move |x, y, t, polarity| {
89            (rng.gen::<f64>() >= p).then_some((x, y, t, polarity))
90        })
91    }
92
93    /// Silences a random `p` fraction of *pixels* for the whole stream.
94    ///
95    /// Unlike [`EventStream::event_drop`], which thins events independently, this removes every event
96    /// from the chosen pixels — the failure mode of a real sensor with dead pixels, and a much harder
97    /// augmentation for a model to average away.
98    pub fn pixel_dropout(&self, p: f64, seed: u64) -> EventStream {
99        if p <= 0.0 {
100            return self.clone();
101        }
102        let (width, height) = self.sensor_size();
103        let mut rng = slice_rng(seed, 0);
104        // `drop_masked_pixels` reads `true` as *drop* — the opposite of `EventStream::mask`, where
105        // `true` keeps. So this is the set to silence, and `p` is the fraction marked `true`.
106        let drop: Vec<bool> = (0..width * height).map(|_| rng.gen::<f64>() < p).collect();
107        self.drop_masked_pixels(&drop)
108    }
109
110    /// Jitters each event's position by a rounded gaussian offset with standard deviation `sigma`
111    /// pixels. Events pushed off the sensor are dropped, so a large `sigma` also thins the stream.
112    pub fn spatial_jitter(&self, sigma: f64, seed: u64) -> EventStream {
113        if sigma <= 0.0 {
114            return self.clone();
115        }
116        let normal = match Normal::new(0.0, sigma) {
117            Ok(normal) => normal,
118            Err(_) => return self.clone(),
119        };
120        let mut rng = slice_rng(seed, 0);
121        let (width, height) = self.sensor_size();
122        self.remap(width, height, move |x, y, t, polarity| {
123            let dx = normal.sample(&mut rng).round() as i64;
124            let dy = normal.sample(&mut rng).round() as i64;
125            Some((x + dx, y + dy, t, polarity))
126        })
127    }
128
129    /// Jitters each event's timestamp by a rounded gaussian offset with standard deviation `sigma`
130    /// (same units as the stored timestamps, i.e. µs).
131    ///
132    /// Re-sorts afterwards: jitter can reorder neighbouring events, and the correlation-based filters
133    /// (`background_activity_filter`, `refractory_filter`) require ascending time.
134    pub fn time_jitter(&self, sigma: f64, seed: u64) -> EventStream {
135        if sigma <= 0.0 {
136            return self.clone();
137        }
138        let normal = match Normal::new(0.0, sigma) {
139            Ok(normal) => normal,
140            Err(_) => return self.clone(),
141        };
142        let mut rng = slice_rng(seed, 0);
143        let (width, height) = self.sensor_size();
144        let jittered = self.remap(width, height, move |x, y, t, polarity| {
145            Some((x, y, t + normal.sample(&mut rng).round() as i64, polarity))
146        });
147        jittered.sort_by_time()
148    }
149
150    /// Plays the stream backwards with probability `p`, inverting polarity to match.
151    ///
152    /// Reversing time without inverting polarity would be physically wrong: an edge that brightened
153    /// as it passed darkens when the same motion is run in reverse. Timestamps are mirrored within
154    /// the stream's own span, so the result starts and ends where the original did.
155    pub fn time_reversal(&self, p: f64, seed: u64) -> EventStream {
156        if !fires(p, seed) || self.is_empty() {
157            return self.clone();
158        }
159        let ts = self.ts();
160        let (&t_min, &t_max) = match (ts.iter().min(), ts.iter().max()) {
161            (Some(min), Some(max)) => (min, max),
162            _ => return self.clone(),
163        };
164        let sum = t_min + t_max;
165        let (width, height) = self.sensor_size();
166        self.remap(width, height, |x, y, t, polarity| {
167            Some((x, y, sum - t, !polarity))
168        })
169        .sort_by_time()
170    }
171}
172
173/// Draws the single "does this augmentation apply?" decision. `p <= 0` never fires and `p >= 1`
174/// always does, so the boundaries are exact rather than left to a float comparison.
175fn fires(p: f64, seed: u64) -> bool {
176    if p <= 0.0 {
177        return false;
178    }
179    if p >= 1.0 {
180        return true;
181    }
182    slice_rng(seed, 0).gen::<f64>() < p
183}
184
185#[cfg(test)]
186mod tests {
187    use crate::{EventStream, EventStreamBuilder};
188
189    fn sample() -> EventStream {
190        let mut builder = EventStreamBuilder::new(8, 6, 0.001);
191        for i in 0..32u16 {
192            builder.push(i % 8, i % 6, 100 + i64::from(i) * 10, i % 2 == 0);
193        }
194        builder.build()
195    }
196
197    fn coords(stream: &EventStream) -> Vec<(u16, u16)> {
198        stream
199            .xs()
200            .iter()
201            .copied()
202            .zip(stream.ys().iter().copied())
203            .collect()
204    }
205
206    #[test]
207    fn probability_bounds_are_exact() {
208        let s = sample();
209        assert_eq!(coords(&s.random_flip_x(0.0, 7)), coords(&s));
210        assert_eq!(coords(&s.random_flip_x(1.0, 7)), coords(&s.flip_x()));
211        assert_eq!(s.event_drop(0.0, 7).len(), s.len());
212        assert_eq!(s.event_drop(1.0, 7).len(), 0);
213    }
214
215    #[test]
216    fn same_seed_gives_identical_output() {
217        let s = sample();
218        assert_eq!(s.event_drop(0.5, 42).ts(), s.event_drop(0.5, 42).ts());
219        assert_eq!(
220            coords(&s.spatial_jitter(1.5, 42)),
221            coords(&s.spatial_jitter(1.5, 42))
222        );
223    }
224
225    #[test]
226    fn different_seeds_give_different_output() {
227        let s = sample();
228        // Not a guarantee for every pair of seeds, but with 32 events at p=0.5 a collision is ~2^-32.
229        assert_ne!(s.event_drop(0.5, 1).len(), s.event_drop(0.5, 2).len());
230    }
231
232    #[test]
233    fn slice_rng_decorrelates_adjacent_indices() {
234        use rand::Rng;
235        // Adjacent slice indices must not produce correlated draws — that is the whole point of
236        // running the seed through the finalizer rather than adding the index to it.
237        let draws: Vec<f64> = (0..8)
238            .map(|index| super::slice_rng(0, index).gen::<f64>())
239            .collect();
240        for window in draws.windows(2) {
241            assert!((window[0] - window[1]).abs() > 1e-6);
242        }
243    }
244
245    #[test]
246    fn event_drop_thins_without_moving_events() {
247        let s = sample();
248        let dropped = s.event_drop(0.5, 3);
249        assert!(dropped.len() < s.len() && !dropped.is_empty());
250        // Every surviving event must be one of the originals, unchanged.
251        let original: Vec<_> = s
252            .ts()
253            .iter()
254            .zip(coords(&s))
255            .map(|(t, xy)| (*t, xy))
256            .collect();
257        for (t, xy) in dropped.ts().iter().zip(coords(&dropped)) {
258            assert!(original.contains(&(*t, xy)));
259        }
260    }
261
262    #[test]
263    fn pixel_dropout_removes_whole_pixels() {
264        let s = sample();
265        let dropped = s.pixel_dropout(0.5, 5);
266        let survivors: std::collections::HashSet<_> = coords(&dropped).into_iter().collect();
267        let removed: std::collections::HashSet<_> = coords(&s)
268            .into_iter()
269            .filter(|xy| !survivors.contains(xy))
270            .collect();
271        // A pixel is either kept entirely or gone entirely — the two sets cannot overlap.
272        assert!(removed.is_disjoint(&survivors));
273        assert!(!removed.is_empty());
274    }
275
276    #[test]
277    fn pixel_dropout_p_is_the_fraction_removed() {
278        // `drop_masked_pixels` reads its mask as *drop*, the opposite of `EventStream::mask`.
279        // Getting that backwards still produces a plausible-looking thinned stream, so pin the
280        // direction: a small `p` must keep most pixels, not most of them vanish.
281        let mut builder = EventStreamBuilder::new(40, 40, 0.001);
282        for x in 0..40u16 {
283            for y in 0..40u16 {
284                builder.push(x, y, i64::from(x) * 40 + i64::from(y), true);
285            }
286        }
287        // One event per pixel, so the surviving fraction of events *is* the surviving fraction
288        // of pixels.
289        let uniform = builder.build();
290        let kept = uniform.pixel_dropout(0.1, 5).len() as f64 / uniform.len() as f64;
291        assert!(kept > 0.8, "p=0.1 should keep ~90% of pixels, kept {kept}");
292    }
293
294    #[test]
295    fn time_reversal_mirrors_span_and_inverts_polarity() {
296        let s = sample();
297        let reversed = s.time_reversal(1.0, 0);
298        assert_eq!(reversed.len(), s.len());
299        // Same span, and sorted ascending after the reversal.
300        assert_eq!(reversed.ts().first(), s.ts().first());
301        assert_eq!(reversed.ts().last(), s.ts().last());
302        assert!(reversed.ts().windows(2).all(|w| w[0] <= w[1]));
303        assert_eq!(reversed.ps()[0], !s.ps()[s.len() - 1]);
304    }
305
306    #[test]
307    fn time_jitter_leaves_the_stream_sorted() {
308        let jittered = sample().time_jitter(500.0, 11);
309        assert!(jittered.ts().windows(2).all(|w| w[0] <= w[1]));
310    }
311
312    #[test]
313    fn random_crop_larger_than_sensor_is_identity() {
314        let s = sample();
315        assert_eq!(coords(&s.random_crop(64, 64, 9)), coords(&s));
316    }
317
318    #[test]
319    fn random_crop_bounds_the_result() {
320        let cropped = sample().random_crop(3, 2, 9);
321        assert_eq!(cropped.sensor_size(), (3, 2));
322        assert!(cropped.xs().iter().all(|&x| (x as usize) < 3));
323        assert!(cropped.ys().iter().all(|&y| (y as usize) < 2));
324    }
325
326    #[test]
327    fn augmentations_handle_the_empty_stream() {
328        let empty = EventStreamBuilder::new(8, 6, 0.001).build();
329        assert!(empty.random_flip_x(1.0, 0).is_empty());
330        assert!(empty.event_drop(0.5, 0).is_empty());
331        assert!(empty.pixel_dropout(0.5, 0).is_empty());
332        assert!(empty.spatial_jitter(2.0, 0).is_empty());
333        assert!(empty.time_jitter(2.0, 0).is_empty());
334        assert!(empty.time_reversal(1.0, 0).is_empty());
335        assert!(empty.random_crop(3, 2, 0).is_empty());
336    }
337}