Skip to main content

eventcv_core/representation/
countmask.rs

1use super::{
2    frame_len, polarity::polarity_counts, 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        if !self.pct.is_finite() || !(0.0..=100.0).contains(&self.pct) {
70            return Err(RepresentationError::InvalidParameter("pct"));
71        }
72        let (width, height, length) = frame_len(stream, 3)?;
73        let plane_len = width * height;
74        let (_, _, counts) = polarity_counts(stream)?;
75
76        let alpha = self.alpha(&counts);
77        // Everything from here on runs in `f32`, matching the reference NumPy pipeline: a Python
78        // float scalar never upcasts a float32 array, so the clip, the divide and the `* 255` are
79        // all single precision. Computing them in `f64` shifts one grey level on ~0.02% of alphas.
80        let level = |count: u64| {
81            let value = (count as f32).min(alpha) / alpha;
82            if self.white_frame {
83                1.0 - value
84            } else {
85                value
86            }
87        };
88        // `as u8` truncates toward zero, which is what `.astype(np.uint8)` does — a count of 1 at
89        // `alpha = 2` is 127.5 and must land on 127, not 128.
90        let byte = |value: f32| (value * 255.0).clamp(0.0, 255.0) as u8;
91
92        let mut values = vec![0_u8; length];
93        for index in 0..plane_len {
94            let positive = counts[index];
95            let negative = counts[plane_len + index];
96            let active = f32::from(u8::from(positive + negative > 0));
97            values[index] = byte(level(positive));
98            values[plane_len + index] = byte(if self.white_frame { 1.0 - active } else { active });
99            values[2 * plane_len + index] = byte(level(negative));
100        }
101
102        Ok(EventFrame {
103            data: EventFrameData::U8(values),
104            channels: 3,
105            width,
106            height,
107            kind: RepresentationKind::CountMask,
108            channel_names: vec![
109                "positive".to_owned(),
110                "activity".to_owned(),
111                "negative".to_owned(),
112            ],
113        })
114    }
115}
116
117/// NumPy's default `linear` percentile (`np.percentile(values, pct)`), reproduced exactly.
118///
119/// Byte-for-byte agreement with the reference renderer depends on two details that a
120/// "close enough" percentile gets wrong. The first is interpolation: the result sits *between*
121/// two order statistics, so `percentile([1, 2, 3, 4, 7], 99)` is `6.88` — nearest-rank would say
122/// `7`. The second is the endpoint switch in NumPy's `_lerp`, which the `gamma >= 0.5` branch
123/// below mirrors; the naive `lower + delta * gamma` form disagrees on roughly 1 input in 50 000.
124///
125/// Reorders `values` in place (two partial selections rather than a full sort).
126fn percentile_linear(values: &mut [f64], pct: f64) -> f64 {
127    let last = values.len() - 1;
128    let virtual_index = (pct / 100.0) * last as f64;
129
130    if virtual_index >= last as f64 {
131        // NumPy pins both order statistics to the maximum here, making the interpolation a no-op.
132        let (_, maximum, _) = values.select_nth_unstable_by(last, f64::total_cmp);
133        return *maximum;
134    }
135
136    let gamma = virtual_index - virtual_index.floor();
137    let previous = virtual_index.floor() as usize;
138    let (_, lower, rest) = values.select_nth_unstable_by(previous, f64::total_cmp);
139    // Everything after `previous` is `>= lower`, so its minimum is the next order statistic.
140    let (lower, upper) = (*lower, rest.iter().copied().fold(f64::INFINITY, f64::min));
141    let delta = upper - lower;
142
143    if gamma >= 0.5 {
144        upper - delta * (1.0 - gamma)
145    } else {
146        lower + delta * gamma
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use ndarray::{array, Array2};
153
154    use super::{CountMask, Representation};
155    use crate::{
156        representation::{EventFrameData, RepresentationError},
157        EventStream,
158    };
159
160    /// 40 pseudo-random events on a 6x8 sensor, from the reference renderer's
161    /// `np.random.default_rng(12345)` fixture. Every non-zero count here is 1 or 2 and
162    /// `alpha == 2`, so a count of 1 must land on **127** — the rounding-vs-truncation check.
163    fn golden_stream() -> EventStream {
164        EventStream::from_array2(
165            array![
166                [5, 4, 0, 1],
167                [1, 0, 1, 0],
168                [6, 2, 2, 0],
169                [2, 0, 3, 0],
170                [1, 4, 4, 1],
171                [6, 2, 5, 0],
172                [5, 2, 6, 1],
173                [5, 2, 7, 0],
174                [7, 2, 8, 1],
175                [3, 1, 9, 0],
176                [6, 3, 10, 1],
177                [2, 4, 11, 0],
178                [4, 2, 12, 1],
179                [4, 1, 13, 0],
180                [1, 0, 14, 1],
181                [1, 0, 15, 0],
182                [1, 0, 16, 1],
183                [5, 0, 17, 1],
184                [4, 0, 18, 0],
185                [7, 3, 19, 0],
186                [5, 4, 20, 0],
187                [1, 5, 21, 1],
188                [7, 3, 22, 1],
189                [7, 3, 23, 0],
190                [5, 1, 24, 0],
191                [5, 5, 25, 1],
192                [1, 3, 26, 0],
193                [0, 4, 27, 1],
194                [2, 4, 28, 1],
195                [3, 5, 29, 0],
196                [0, 4, 30, 1],
197                [7, 5, 31, 1],
198                [3, 3, 32, 0],
199                [5, 3, 33, 0],
200                [1, 1, 34, 0],
201                [2, 5, 35, 0],
202                [0, 3, 36, 0],
203                [5, 2, 37, 1],
204                [6, 1, 38, 1],
205                [1, 1, 39, 1],
206            ],
207            8,
208            6,
209            0.001,
210        )
211    }
212
213    #[rustfmt::skip]
214    const GOLDEN: [u8; 144] = [
215        // red — positive counts, clipped and normalised by alpha = 2
216        0, 255,   0,   0,   0, 127,   0,   0,
217        0, 127,   0,   0,   0,   0, 127,   0,
218        0,   0,   0,   0, 127, 255,   0, 127,
219        0,   0,   0,   0,   0,   0, 127, 127,
220      255, 127, 127,   0,   0, 127,   0,   0,
221        0, 127,   0,   0,   0, 127,   0, 127,
222        // green — binary activity mask, either polarity
223        0, 255, 255,   0, 255, 255,   0,   0,
224        0, 255,   0, 255, 255, 255, 255,   0,
225        0,   0,   0,   0, 255, 255, 255, 255,
226      255, 255,   0, 255,   0, 255, 255, 255,
227      255, 255, 255,   0,   0, 255,   0,   0,
228        0, 255, 255, 255,   0, 255,   0, 255,
229        // blue — negative counts, same alpha
230        0, 255, 127,   0, 127,   0,   0,   0,
231        0, 127,   0, 127, 127, 127,   0,   0,
232        0,   0,   0,   0,   0, 127, 255,   0,
233      127, 127,   0, 127,   0, 127,   0, 255,
234        0,   0, 127,   0,   0, 127,   0,   0,
235        0,   0, 127, 127,   0,   0,   0,   0,
236    ];
237
238    #[test]
239    fn matches_the_reference_renderer() {
240        let frame = CountMask::default().generate(&golden_stream()).unwrap();
241
242        assert_eq!(frame.shape(), (3, 6, 8));
243        assert_eq!(frame.data(), &EventFrameData::U8(GOLDEN.to_vec()));
244    }
245
246    /// Timestamps are not part of the encoding, so rescaling them must not move a single byte.
247    #[test]
248    fn ignores_timestamps() {
249        let mut rows = golden_stream().to_array2();
250        rows.column_mut(2).map_inplace(|t| *t = *t * 1_000_000 + 7);
251        let shifted = EventStream::from_array2(rows, 8, 6, 0.001);
252
253        let frame = CountMask::default().generate(&shifted).unwrap();
254
255        assert_eq!(frame.data(), &EventFrameData::U8(GOLDEN.to_vec()));
256    }
257
258    /// `alpha` pools the non-zero counts of **both** planes before taking the percentile. Here the
259    /// pooled 99th percentile is 11.8, while a per-channel one would be 1.0 for red (turning its
260    /// three pixels into 255 instead of 21) and 11.92 for blue. Including the zero-valued pixels
261    /// would give 11.56, which is different again. The fixture also exercises a fractional alpha
262    /// and NumPy's `gamma >= 0.5` interpolation branch, neither of which the 6x8 golden reaches.
263    #[test]
264    fn normalizes_both_planes_by_one_pooled_percentile() {
265        // Positive: one event each at x = 0, 1, 2. Negative: 4 at x = 3, 8 at x = 4, 12 at x = 5.
266        let mut rows = Vec::new();
267        let pixels = [(0, 1, 1), (1, 1, 1), (2, 1, 1), (3, 4, 0), (4, 8, 0), (5, 12, 0)];
268        for (x, count, polarity) in pixels {
269            for _ in 0..count {
270                let timestamp = rows.len() as u64;
271                rows.push([x, 0, timestamp, polarity]);
272            }
273        }
274        let stream = EventStream::from_array2(
275            Array2::from_shape_fn((rows.len(), 4), |(row, column)| rows[row][column]),
276            6,
277            1,
278            0.001,
279        );
280
281        let frame = CountMask::default().generate(&stream).unwrap();
282
283        assert_eq!(
284            frame.data(),
285            &EventFrameData::U8(vec![
286                21, 21, 21, 0, 0, 0, // red: 1 / 11.8 -> 21, not 255
287                255, 255, 255, 255, 255, 255, // green: every pixel saw an event
288                0, 0, 0, 86, 172, 255, // blue: 8 / 11.8 * 255 = 172.88 -> 172
289            ])
290        );
291
292        let inverted = CountMask::new(99.0, true).generate(&stream).unwrap();
293
294        assert_eq!(
295            inverted.data(),
296            &EventFrameData::U8(vec![
297                233, 233, 233, 255, 255, 255, //
298                0, 0, 0, 0, 0, 0, //
299                255, 255, 255, 168, 82, 0,
300            ])
301        );
302    }
303
304    #[test]
305    fn rejects_out_of_bounds_events() {
306        let stream = EventStream::from_array2(array![[2, 0, 10, 1]], 2, 2, 0.001);
307
308        let error = CountMask::default().generate(&stream).unwrap_err();
309
310        assert_eq!(
311            error.to_string(),
312            "event coordinate (2, 0) exceeds sensor size 2x2"
313        );
314    }
315
316    #[test]
317    fn rejects_percentiles_outside_zero_to_one_hundred() {
318        let stream = golden_stream();
319
320        for pct in [-1.0, 100.5, f64::NAN] {
321            assert_eq!(
322                CountMask::new(pct, false).generate(&stream).unwrap_err(),
323                RepresentationError::InvalidParameter("pct")
324            );
325        }
326        assert_eq!(
327            CountMask::new(150.0, false)
328                .generate(&stream)
329                .unwrap_err()
330                .to_string(),
331            "pct must be between 0 and 100"
332        );
333    }
334}