Skip to main content

eventcv_core/representation/
countmask.rs

1use super::{
2    frame_len, polarity::polarity_counts_on, EventFrame, EventFrameData, Representation,
3    RepresentationError, RepresentationKind,
4};
5use crate::EventStream;
6
7/// Count-mask image (GEPT, Sec. 3.2 "Event Accumulation", Eq. 2): a three-channel RGB encoding
8/// where red and blue hold the per-pixel positive and negative event counts and green is a binary
9/// activity mask — full scale wherever an event of either polarity landed. Timestamps are not
10/// used at all, which is what separates it from [`Tencode`](super::Tencode) (latest polarity plus
11/// event age) and from [`EventCount`](super::EventCount) (one plane of raw totals).
12///
13/// Both count planes are clipped and rescaled by a **single** `alpha`: the `pct`-th percentile of
14/// the non-zero counts of the two planes **pooled together**. A per-channel percentile would let a
15/// quiet polarity saturate against a busy one, so the joint scale is what keeps red and blue
16/// comparable.
17///
18/// `white_frame` inverts the image to a white background. The black-background default is the form
19/// downstream descriptor models are trained on; the inverted one is offered for parity with the
20/// reference renderer, not for feeding a model.
21#[derive(Clone, Copy, Debug)]
22pub struct CountMask {
23    pct: f64,
24    white_frame: bool,
25}
26
27impl CountMask {
28    pub fn new(pct: f64, white_frame: bool) -> Self {
29        Self { pct, white_frame }
30    }
31
32    /// The clip bound and divisor for both count planes, as `f32` — see [`percentile_linear`] for
33    /// why the percentile itself is computed in `f64` and narrowed only at the end.
34    fn alpha(&self, counts: &[u64]) -> f32 {
35        // Counts convert to `f32` exactly below 2^24, so pooling them through `f64` here matches
36        // the reference's float32 accumulator for any physically plausible slice. It would only
37        // diverge if a single pixel saw more than 16.7M events in one slice.
38        let mut nonzero: Vec<f64> = counts
39            .iter()
40            .copied()
41            .filter(|&count| count > 0)
42            .map(|count| count as f64)
43            .collect();
44        if nonzero.is_empty() {
45            return 1.0;
46        }
47
48        let alpha = percentile_linear(&mut nonzero, self.pct);
49        if alpha > 0.0 {
50            alpha as f32
51        } else {
52            // Unreachable while every pooled sample is at least 1, but kept so the scale can never
53            // become zero and blow up the division below.
54            counts.iter().copied().max().unwrap_or(0).max(1) as f32
55        }
56    }
57}
58
59impl Default for CountMask {
60    fn default() -> Self {
61        Self::new(99.0, false)
62    }
63}
64
65impl Representation for CountMask {
66    type Output = EventFrame;
67
68    fn generate(&self, stream: &EventStream) -> Result<EventFrame, RepresentationError> {
69        self.generate_on(stream, crate::accel::Device::Cpu)
70    }
71
72    /// Only the two count planes move to the GPU. The percentile that sets the scale stays on the
73    /// CPU: it is `O(pixels)` rather than `O(events)`, it is already the cheap half, and it is the
74    /// part that has to agree with the reference NumPy pipeline bit for bit.
75    fn generate_on(
76        &self,
77        stream: &EventStream,
78        device: crate::accel::Device,
79    ) -> Result<EventFrame, RepresentationError> {
80        if !self.pct.is_finite() || !(0.0..=100.0).contains(&self.pct) {
81            return Err(RepresentationError::InvalidParameter("pct"));
82        }
83        let (width, height, length) = frame_len(stream, 3)?;
84        let plane_len = width * height;
85        let (_, _, counts) = polarity_counts_on(stream, device)?;
86
87        let alpha = self.alpha(&counts);
88        // Everything from here on runs in `f32`, matching the reference NumPy pipeline: a Python
89        // float scalar never upcasts a float32 array, so the clip, the divide and the `* 255` are
90        // all single precision. Computing them in `f64` shifts one grey level on ~0.02% of alphas.
91        let level = |count: u64| {
92            let value = (count as f32).min(alpha) / alpha;
93            if self.white_frame {
94                1.0 - value
95            } else {
96                value
97            }
98        };
99        // `as u8` truncates toward zero, which is what `.astype(np.uint8)` does — a count of 1 at
100        // `alpha = 2` is 127.5 and must land on 127, not 128.
101        let byte = |value: f32| (value * 255.0).clamp(0.0, 255.0) as u8;
102
103        let mut values = vec![0_u8; length];
104        for index in 0..plane_len {
105            let positive = counts[index];
106            let negative = counts[plane_len + index];
107            let active = f32::from(u8::from(positive + negative > 0));
108            values[index] = byte(level(positive));
109            values[plane_len + index] = byte(if self.white_frame { 1.0 - active } else { active });
110            values[2 * plane_len + index] = byte(level(negative));
111        }
112
113        Ok(EventFrame {
114            data: EventFrameData::U8(values),
115            channels: 3,
116            width,
117            height,
118            kind: RepresentationKind::CountMask,
119            channel_names: vec![
120                "positive".to_owned(),
121                "activity".to_owned(),
122                "negative".to_owned(),
123            ],
124        })
125    }
126}
127
128/// NumPy's default `linear` percentile (`np.percentile(values, pct)`), reproduced exactly.
129///
130/// Byte-for-byte agreement with the reference renderer depends on two details that a
131/// "close enough" percentile gets wrong. The first is interpolation: the result sits *between*
132/// two order statistics, so `percentile([1, 2, 3, 4, 7], 99)` is `6.88` — nearest-rank would say
133/// `7`. The second is the endpoint switch in NumPy's `_lerp`, which the `gamma >= 0.5` branch
134/// below mirrors; the naive `lower + delta * gamma` form disagrees on roughly 1 input in 50 000.
135///
136/// Reorders `values` in place (two partial selections rather than a full sort).
137pub(super) fn percentile_linear(values: &mut [f64], pct: f64) -> f64 {
138    let last = values.len() - 1;
139    let virtual_index = (pct / 100.0) * last as f64;
140
141    if virtual_index >= last as f64 {
142        // NumPy pins both order statistics to the maximum here, making the interpolation a no-op.
143        let (_, maximum, _) = values.select_nth_unstable_by(last, f64::total_cmp);
144        return *maximum;
145    }
146
147    let gamma = virtual_index - virtual_index.floor();
148    let previous = virtual_index.floor() as usize;
149    let (_, lower, rest) = values.select_nth_unstable_by(previous, f64::total_cmp);
150    // Everything after `previous` is `>= lower`, so its minimum is the next order statistic.
151    let (lower, upper) = (*lower, rest.iter().copied().fold(f64::INFINITY, f64::min));
152    let delta = upper - lower;
153
154    if gamma >= 0.5 {
155        upper - delta * (1.0 - gamma)
156    } else {
157        lower + delta * gamma
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use ndarray::{array, Array2};
164
165    use super::{CountMask, Representation};
166    use crate::{
167        representation::{EventFrameData, RepresentationError},
168        EventStream,
169    };
170
171    /// 40 pseudo-random events on a 6x8 sensor, from the reference renderer's
172    /// `np.random.default_rng(12345)` fixture. Every non-zero count here is 1 or 2 and
173    /// `alpha == 2`, so a count of 1 must land on **127** — the rounding-vs-truncation check.
174    fn golden_stream() -> EventStream {
175        EventStream::from_array2(
176            array![
177                [5, 4, 0, 1],
178                [1, 0, 1, 0],
179                [6, 2, 2, 0],
180                [2, 0, 3, 0],
181                [1, 4, 4, 1],
182                [6, 2, 5, 0],
183                [5, 2, 6, 1],
184                [5, 2, 7, 0],
185                [7, 2, 8, 1],
186                [3, 1, 9, 0],
187                [6, 3, 10, 1],
188                [2, 4, 11, 0],
189                [4, 2, 12, 1],
190                [4, 1, 13, 0],
191                [1, 0, 14, 1],
192                [1, 0, 15, 0],
193                [1, 0, 16, 1],
194                [5, 0, 17, 1],
195                [4, 0, 18, 0],
196                [7, 3, 19, 0],
197                [5, 4, 20, 0],
198                [1, 5, 21, 1],
199                [7, 3, 22, 1],
200                [7, 3, 23, 0],
201                [5, 1, 24, 0],
202                [5, 5, 25, 1],
203                [1, 3, 26, 0],
204                [0, 4, 27, 1],
205                [2, 4, 28, 1],
206                [3, 5, 29, 0],
207                [0, 4, 30, 1],
208                [7, 5, 31, 1],
209                [3, 3, 32, 0],
210                [5, 3, 33, 0],
211                [1, 1, 34, 0],
212                [2, 5, 35, 0],
213                [0, 3, 36, 0],
214                [5, 2, 37, 1],
215                [6, 1, 38, 1],
216                [1, 1, 39, 1],
217            ],
218            8,
219            6,
220            0.001,
221        )
222    }
223
224    #[rustfmt::skip]
225    const GOLDEN: [u8; 144] = [
226        // red — positive counts, clipped and normalised by alpha = 2
227        0, 255,   0,   0,   0, 127,   0,   0,
228        0, 127,   0,   0,   0,   0, 127,   0,
229        0,   0,   0,   0, 127, 255,   0, 127,
230        0,   0,   0,   0,   0,   0, 127, 127,
231      255, 127, 127,   0,   0, 127,   0,   0,
232        0, 127,   0,   0,   0, 127,   0, 127,
233        // green — binary activity mask, either polarity
234        0, 255, 255,   0, 255, 255,   0,   0,
235        0, 255,   0, 255, 255, 255, 255,   0,
236        0,   0,   0,   0, 255, 255, 255, 255,
237      255, 255,   0, 255,   0, 255, 255, 255,
238      255, 255, 255,   0,   0, 255,   0,   0,
239        0, 255, 255, 255,   0, 255,   0, 255,
240        // blue — negative counts, same alpha
241        0, 255, 127,   0, 127,   0,   0,   0,
242        0, 127,   0, 127, 127, 127,   0,   0,
243        0,   0,   0,   0,   0, 127, 255,   0,
244      127, 127,   0, 127,   0, 127,   0, 255,
245        0,   0, 127,   0,   0, 127,   0,   0,
246        0,   0, 127, 127,   0,   0,   0,   0,
247    ];
248
249    #[test]
250    fn matches_the_reference_renderer() {
251        let frame = CountMask::default().generate(&golden_stream()).unwrap();
252
253        assert_eq!(frame.shape(), (3, 6, 8));
254        assert_eq!(frame.data(), &EventFrameData::U8(GOLDEN.to_vec()));
255    }
256
257    /// Timestamps are not part of the encoding, so rescaling them must not move a single byte.
258    #[test]
259    fn ignores_timestamps() {
260        let mut rows = golden_stream().to_array2();
261        rows.column_mut(2).map_inplace(|t| *t = *t * 1_000_000 + 7);
262        let shifted = EventStream::from_array2(rows, 8, 6, 0.001);
263
264        let frame = CountMask::default().generate(&shifted).unwrap();
265
266        assert_eq!(frame.data(), &EventFrameData::U8(GOLDEN.to_vec()));
267    }
268
269    /// `alpha` pools the non-zero counts of **both** planes before taking the percentile. Here the
270    /// pooled 99th percentile is 11.8, while a per-channel one would be 1.0 for red (turning its
271    /// three pixels into 255 instead of 21) and 11.92 for blue. Including the zero-valued pixels
272    /// would give 11.56, which is different again. The fixture also exercises a fractional alpha
273    /// and NumPy's `gamma >= 0.5` interpolation branch, neither of which the 6x8 golden reaches.
274    #[test]
275    fn normalizes_both_planes_by_one_pooled_percentile() {
276        // Positive: one event each at x = 0, 1, 2. Negative: 4 at x = 3, 8 at x = 4, 12 at x = 5.
277        let mut rows = Vec::new();
278        let pixels = [(0, 1, 1), (1, 1, 1), (2, 1, 1), (3, 4, 0), (4, 8, 0), (5, 12, 0)];
279        for (x, count, polarity) in pixels {
280            for _ in 0..count {
281                let timestamp = rows.len() as u64;
282                rows.push([x, 0, timestamp, polarity]);
283            }
284        }
285        let stream = EventStream::from_array2(
286            Array2::from_shape_fn((rows.len(), 4), |(row, column)| rows[row][column]),
287            6,
288            1,
289            0.001,
290        );
291
292        let frame = CountMask::default().generate(&stream).unwrap();
293
294        assert_eq!(
295            frame.data(),
296            &EventFrameData::U8(vec![
297                21, 21, 21, 0, 0, 0, // red: 1 / 11.8 -> 21, not 255
298                255, 255, 255, 255, 255, 255, // green: every pixel saw an event
299                0, 0, 0, 86, 172, 255, // blue: 8 / 11.8 * 255 = 172.88 -> 172
300            ])
301        );
302
303        let inverted = CountMask::new(99.0, true).generate(&stream).unwrap();
304
305        assert_eq!(
306            inverted.data(),
307            &EventFrameData::U8(vec![
308                233, 233, 233, 255, 255, 255, //
309                0, 0, 0, 0, 0, 0, //
310                255, 255, 255, 168, 82, 0,
311            ])
312        );
313    }
314
315    #[test]
316    fn rejects_out_of_bounds_events() {
317        let stream = EventStream::from_array2(array![[2, 0, 10, 1]], 2, 2, 0.001);
318
319        let error = CountMask::default().generate(&stream).unwrap_err();
320
321        assert_eq!(
322            error.to_string(),
323            "event coordinate (2, 0) exceeds sensor size 2x2"
324        );
325    }
326
327    #[test]
328    fn rejects_percentiles_outside_zero_to_one_hundred() {
329        let stream = golden_stream();
330
331        for pct in [-1.0, 100.5, f64::NAN] {
332            assert_eq!(
333                CountMask::new(pct, false).generate(&stream).unwrap_err(),
334                RepresentationError::InvalidParameter("pct")
335            );
336        }
337        assert_eq!(
338            CountMask::new(150.0, false)
339                .generate(&stream)
340                .unwrap_err()
341                .to_string(),
342            "pct must be between 0 and 100"
343        );
344    }
345}