Skip to main content

eventcv_core/
viz.rs

1//! Frame → pixels rendering: the shared 2-D visualisation path used by PNG export and the
2//! interactive viewer's image representations. Most [`EventFrame`]s reduce to a per-pixel
3//! scalar field (signed for polarity/voxel/time-surface reprs, unsigned for count/binary),
4//! which a [`Colormap`] turns into RGB. Some kinds have their own RGB path: Tencode, CountMask
5//! and RedBlue (already RGB encodings) and Flow (Middlebury colour coding — direction → hue, speed →
6//! saturation).
7
8use crate::representation::{EventFrame, EventFrameData, RepresentationKind};
9use crate::EventStream;
10
11/// A packed 8-bit RGB image (`pixels.len() == width * height * 3`, row-major).
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct Rgb8Image {
14    pub width: usize,
15    pub height: usize,
16    pub pixels: Vec<u8>,
17}
18
19/// Colour mapping applied to a frame's scalar field. Sequential maps (`Grayscale`,
20/// `Viridis`, `Turbo`) suit unsigned reprs; `RedBlue` is a diverging map for signed
21/// reprs (negative → blue, zero → black, positive → red), matching the viewer's polarity colours.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
23pub enum Colormap {
24    Grayscale,
25    #[default]
26    Viridis,
27    Turbo,
28    RedBlue,
29}
30
31impl Colormap {
32    pub fn from_name(name: &str) -> Option<Self> {
33        Some(match name {
34            "grayscale" | "gray" | "grey" => Self::Grayscale,
35            "viridis" => Self::Viridis,
36            "turbo" => Self::Turbo,
37            "redblue" | "diverging" => Self::RedBlue,
38            _ => return None,
39        })
40    }
41}
42
43/// Renders `frame` to an RGB image. `normalize` stretches the field to its own data range
44/// (auto-contrast); when `false`, values are read at their natural scale (`f32` reprs in
45/// `[0, 1]` / `[-1, 1]`, integer counts divided by 255). Diverging kinds ignore `colormap`
46/// and use `RedBlue`; Tencode, CountMask and RedBlue ignore it entirely (already RGB encodings).
47pub fn render_frame(frame: &EventFrame, colormap: Colormap, normalize: bool) -> Rgb8Image {
48    render_frame_scaled(frame, colormap, Scale::from_normalize(normalize))
49}
50
51/// How a frame's scalar field is mapped onto the colormap's `[0, 1]` / `[-1, 1]` input range.
52///
53/// [`render_frame`] picks between the first two. The third exists for animation: auto-contrast is
54/// computed per frame, so a sequence rendered that way re-scales on every frame and the brightness
55/// visibly pumps. Holding one extent across the sequence is what makes an exported video readable.
56#[derive(Clone, Copy, Debug, PartialEq)]
57pub enum Scale {
58    /// Read values at their natural scale — `f32` reprs in `[0, 1]` / `[-1, 1]`, integer counts /255.
59    Natural,
60    /// Stretch to this frame's own robust extent (auto-contrast).
61    Auto,
62    /// Stretch to a caller-supplied extent, shared across however many frames the caller renders.
63    /// Values beyond it clamp to the end of the colormap. A non-positive extent falls back to
64    /// [`Scale::Natural`], so a calibration pass over blank frames cannot black out a whole video.
65    Fixed(f64),
66}
67
68impl Scale {
69    fn from_normalize(normalize: bool) -> Self {
70        if normalize {
71            Self::Auto
72        } else {
73            Self::Natural
74        }
75    }
76}
77
78/// [`render_frame`] with explicit control over the value scaling — see [`Scale`].
79pub fn render_frame_scaled(frame: &EventFrame, colormap: Colormap, scale: Scale) -> Rgb8Image {
80    let normalize = matches!(scale, Scale::Auto);
81    let (_, height, width) = frame.shape();
82    let plane_len = width * height;
83
84    if matches!(
85        frame.kind(),
86        RepresentationKind::Tencode | RepresentationKind::CountMask | RepresentationKind::RedBlue
87    ) {
88        return Rgb8Image {
89            width,
90            height,
91            pixels: render_rgb8_planes(frame, plane_len, normalize),
92        };
93    }
94    // Flow has its own RGB path: Middlebury colour coding (direction → hue, speed → saturation).
95    if frame.kind() == RepresentationKind::Flow {
96        return Rgb8Image {
97            width,
98            height,
99            pixels: render_flow(frame, plane_len, normalize),
100        };
101    }
102
103    let (field, signed) = scalar_field(frame, plane_len);
104    let scale = match scale {
105        Scale::Fixed(extent) if extent > 0.0 => 1.0 / extent,
106        Scale::Fixed(_) => field_scale(&field, signed, is_float(frame.data()), false),
107        _ => field_scale(&field, signed, is_float(frame.data()), normalize),
108    };
109    let colormap = if signed { Colormap::RedBlue } else { colormap };
110
111    let mut pixels = Vec::with_capacity(plane_len * 3);
112    for &value in &field {
113        let normalized = value * scale;
114        let [r, g, b] = if signed {
115            colormap.sample_signed(normalized.clamp(-1.0, 1.0))
116        } else {
117            colormap.sample(normalized.clamp(0.0, 1.0))
118        };
119        pixels.extend_from_slice(&[r, g, b]);
120    }
121    Rgb8Image {
122        width,
123        height,
124        pixels,
125    }
126}
127
128/// The display extent [`Scale::Auto`] would choose for this frame.
129///
130/// Exposed so a sequence can be calibrated before it is rendered: take this over a sample of frames,
131/// keep the largest, and pass it as [`Scale::Fixed`] to every frame. Returns `0.0` for a frame with
132/// no non-zero values, and for the kinds that bypass scalar mapping entirely
133/// (Tencode, CountMask, RedBlue, Flow) — those already carry their own scaling and are unaffected by [`Scale`].
134pub fn frame_extent(frame: &EventFrame) -> f64 {
135    if matches!(
136        frame.kind(),
137        RepresentationKind::Tencode
138            | RepresentationKind::CountMask
139            | RepresentationKind::RedBlue
140            | RepresentationKind::Flow
141    ) {
142        return 0.0;
143    }
144    let (_, height, width) = frame.shape();
145    let (field, signed) = scalar_field(frame, width * height);
146    robust_extent(&field, signed)
147}
148
149/// Collapses a frame's channels to one scalar per pixel and reports whether it is signed.
150fn scalar_field(frame: &EventFrame, plane_len: usize) -> (Vec<f64>, bool) {
151    let (channels, _, _) = frame.shape();
152    let data = frame.data();
153    match frame.kind() {
154        // One channel — the value itself, unsigned.
155        // Intensity joins these: one unsigned channel whose value is the pixel, so the default
156        // sequential colormap applies and a greyscale render is the identity.
157        RepresentationKind::Binary
158        | RepresentationKind::Count
159        | RepresentationKind::Intensity
160        | RepresentationKind::Labels => {
161            ((0..plane_len).map(|i| value_at(data, i)).collect(), false)
162        }
163        // Flow is handled by render_flow before this call (Middlebury colour coding).
164        RepresentationKind::Flow => (vec![0.0; plane_len], false),
165        // Positive/negative planes — their difference (signed).
166        RepresentationKind::Polarity
167        | RepresentationKind::TimeSurface
168        | RepresentationKind::AveragedTimeSurface => (
169            (0..plane_len)
170                .map(|i| value_at(data, i) - value_at(data, plane_len + i))
171                .collect(),
172            true,
173        ),
174        // MCTS stores negative windows then positive windows — sum each half, then their difference.
175        RepresentationKind::Mcts => {
176            let half = channels / 2;
177            (
178                (0..plane_len)
179                    .map(|i| {
180                        let neg: f64 = (0..half).map(|c| value_at(data, c * plane_len + i)).sum();
181                        let pos: f64 = (half..channels)
182                            .map(|c| value_at(data, c * plane_len + i))
183                            .sum();
184                        pos - neg
185                    })
186                    .collect(),
187                true,
188            )
189        }
190        // Voxel bins are signed contributions — sum them.
191        RepresentationKind::Voxel => (
192            (0..plane_len)
193                .map(|i| {
194                    (0..channels)
195                        .map(|c| value_at(data, c * plane_len + i))
196                        .sum()
197                })
198                .collect(),
199            true,
200        ),
201        // Tencode, CountMask and RedBlue are handled before this call (already RGB).
202        RepresentationKind::Tencode | RepresentationKind::CountMask | RepresentationKind::RedBlue => {
203            (vec![0.0; plane_len], false)
204        }
205    }
206}
207
208/// The reciprocal of the field's display extent, so `value * scale` lands in `[0, 1]` (unsigned)
209/// or `[-1, 1]` (signed). The extent is a **high percentile** of the non-zero magnitudes rather
210/// than the raw max, so a handful of outliers (e.g. spurious large flow vectors) don't crush the
211/// rest of the field to black; values above the percentile clamp to the top colour.
212fn field_scale(field: &[f64], signed: bool, is_float: bool, normalize: bool) -> f64 {
213    if normalize {
214        let extent = robust_extent(field, signed);
215        if extent > 0.0 {
216            1.0 / extent
217        } else {
218            0.0
219        }
220    } else if is_float {
221        1.0 // f32 reprs already live in [0, 1] / [-1, 1]
222    } else {
223        1.0 / 255.0 // integer counts read as 8-bit intensities
224    }
225}
226
227/// The 99th percentile of the field's non-zero magnitudes — a display max robust to a few
228/// outliers. Falls back to the raw max when there are too few non-zero samples for a percentile
229/// to be meaningful (e.g. a sparse count image), preserving the "busiest pixel → top" behaviour.
230fn robust_extent(field: &[f64], signed: bool) -> f64 {
231    let mut mags: Vec<f64> = field
232        .iter()
233        .map(|&v| if signed { v.abs() } else { v })
234        .filter(|&v| v > 0.0)
235        .collect();
236    if mags.len() < 100 {
237        return mags.iter().copied().fold(0.0_f64, f64::max);
238    }
239    let index = (((mags.len() as f64) * 0.99).ceil() as usize - 1).min(mags.len() - 1);
240    mags.select_nth_unstable_by(index, f64::total_cmp);
241    mags[index]
242}
243
244/// Renders a two-channel flow frame with the **Middlebury** colour coding (Baker et al.): the flow
245/// *direction* selects a hue from a fixed colour wheel, and the *speed* sets saturation — zero flow
246/// is white, faster flow is more vivid. Speed is normalised by a robust percentile of the field
247/// (or `1.0` when `normalize` is false, treating values as already in px/ms ≈ [0, 1]).
248/// Gamma applied to normalised flow speed before colour-coding, to lift the skewed low end.
249const FLOW_GAMMA: f64 = 0.5;
250
251fn render_flow(frame: &EventFrame, plane_len: usize, normalize: bool) -> Vec<u8> {
252    let data = frame.data();
253    let magnitude = |i: usize| value_at(data, i).hypot(value_at(data, plane_len + i));
254    let scale = if normalize {
255        let mags: Vec<f64> = (0..plane_len).map(magnitude).collect();
256        let extent = robust_extent(&mags, false);
257        if extent > 0.0 {
258            1.0 / extent
259        } else {
260            0.0
261        }
262    } else {
263        1.0
264    };
265
266    let wheel = flow_color_wheel();
267    let ncols = wheel.len();
268    let mut pixels = Vec::with_capacity(plane_len * 3);
269    for i in 0..plane_len {
270        let (fx, fy) = (value_at(data, i), value_at(data, plane_len + i));
271        // Perceptual gamma on the normalised speed: event flow is heavily skewed toward small
272        // magnitudes, so a plain linear scale leaves almost everything near white. `√` expands the
273        // low end so slow flow is still visible while the top stays vivid.
274        let rad = (magnitude(i) * scale).powf(FLOW_GAMMA);
275        // Direction → position on the colour wheel (Baker et al. use atan2(-v, -u)).
276        let angle = (-fy).atan2(-fx) / std::f64::consts::PI; // [-1, 1]
277        let fk = (angle + 1.0) / 2.0 * (ncols as f64 - 1.0);
278        let k0 = fk.floor() as usize;
279        let k1 = (k0 + 1) % ncols;
280        let f = fk - k0 as f64;
281        let mut rgb = [0_u8; 3];
282        for (channel, slot) in rgb.iter_mut().enumerate() {
283            let base = (1.0 - f) * wheel[k0][channel] + f * wheel[k1][channel];
284            // Saturate with speed: rad=0 → white, rad=1 → full colour, rad>1 → darkened.
285            let col = if rad <= 1.0 {
286                1.0 - rad * (1.0 - base)
287            } else {
288                base * 0.75
289            };
290            *slot = (255.0 * col).round().clamp(0.0, 255.0) as u8;
291        }
292        pixels.extend_from_slice(&rgb);
293    }
294    pixels
295}
296
297/// The canonical 55-entry Middlebury flow colour wheel (values in `[0, 1]`), stepping through
298/// red→yellow→green→cyan→blue→magenta→red so that each flow direction maps to a distinct hue.
299fn flow_color_wheel() -> Vec<[f64; 3]> {
300    const SEGMENTS: [(usize, [f64; 3], [f64; 3]); 6] = [
301        (15, [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]), // red → yellow
302        (6, [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]),  // yellow → green
303        (4, [0.0, 1.0, 0.0], [0.0, 1.0, 1.0]),  // green → cyan
304        (11, [0.0, 1.0, 1.0], [0.0, 0.0, 1.0]), // cyan → blue
305        (13, [0.0, 0.0, 1.0], [1.0, 0.0, 1.0]), // blue → magenta
306        (6, [1.0, 0.0, 1.0], [1.0, 0.0, 0.0]),  // magenta → red
307    ];
308    let mut wheel = Vec::with_capacity(55);
309    for (count, from, to) in SEGMENTS {
310        for step in 0..count {
311            let t = step as f64 / count as f64;
312            wheel.push([
313                from[0] + t * (to[0] - from[0]),
314                from[1] + t * (to[1] - from[1]),
315                from[2] + t * (to[2] - from[2]),
316            ]);
317        }
318    }
319    wheel
320}
321
322/// Interleaves a frame whose three planes *are* the R, G and B channels (Tencode, CountMask, RedBlue)
323/// into packed pixels. `normalize` stretches to the brightest channel value; otherwise the planes pass
324/// through at their stored 8-bit scale.
325fn render_rgb8_planes(frame: &EventFrame, plane_len: usize, normalize: bool) -> Vec<u8> {
326    let data = frame.data();
327    let scale = if normalize {
328        let max = (0..plane_len * 3)
329            .map(|i| value_at(data, i))
330            .fold(0.0, f64::max);
331        if max > 0.0 {
332            255.0 / max
333        } else {
334            0.0
335        }
336    } else {
337        1.0
338    };
339    let channel = |plane: usize, i: usize| {
340        (value_at(data, plane * plane_len + i) * scale)
341            .round()
342            .clamp(0.0, 255.0) as u8
343    };
344    let mut pixels = Vec::with_capacity(plane_len * 3);
345    for i in 0..plane_len {
346        pixels.extend_from_slice(&[channel(0, i), channel(1, i), channel(2, i)]);
347    }
348    pixels
349}
350
351fn value_at(data: &EventFrameData, index: usize) -> f64 {
352    match data {
353        EventFrameData::U8(values) => f64::from(values[index]),
354        EventFrameData::U16(values) => f64::from(values[index]),
355        EventFrameData::U64(values) => values[index] as f64,
356        EventFrameData::F32(values) => f64::from(values[index]),
357    }
358}
359
360fn is_float(data: &EventFrameData) -> bool {
361    matches!(data, EventFrameData::F32(_))
362}
363
364impl Colormap {
365    /// Maps `t ∈ [0, 1]` to RGB for a sequential colormap.
366    fn sample(self, t: f64) -> [u8; 3] {
367        match self {
368            Self::Grayscale => {
369                let v = (t * 255.0).round() as u8;
370                [v, v, v]
371            }
372            Self::Viridis => interpolate(&VIRIDIS, t),
373            Self::Turbo => interpolate(&TURBO, t),
374            // A diverging map used on unsigned data folds to its warm half.
375            Self::RedBlue => self.sample_signed(t),
376        }
377    }
378
379    /// Maps `s ∈ [-1, 1]` to RGB for the diverging red/blue map (negative → blue,
380    /// positive → red) on a black background.
381    fn sample_signed(self, s: f64) -> [u8; 3] {
382        match self {
383            Self::RedBlue => {
384                let positive = s.max(0.0);
385                let negative = (-s).max(0.0);
386                [
387                    (positive * 255.0).round() as u8,
388                    ((positive.min(negative)) * 40.0).round() as u8,
389                    (negative * 255.0).round() as u8,
390                ]
391            }
392            // Sequential maps fold the signed field onto their magnitude.
393            other => other.sample(s.abs()),
394        }
395    }
396}
397
398/// Linear interpolation over an anchor table of RGB control points.
399fn interpolate(anchors: &[[u8; 3]], t: f64) -> [u8; 3] {
400    let last = anchors.len() - 1;
401    let position = t.clamp(0.0, 1.0) * last as f64;
402    let lower = position.floor() as usize;
403    if lower >= last {
404        return anchors[last];
405    }
406    let frac = position - lower as f64;
407    let a = anchors[lower];
408    let b = anchors[lower + 1];
409    std::array::from_fn(|c| {
410        (f64::from(a[c]) + (f64::from(b[c]) - f64::from(a[c])) * frac).round() as u8
411    })
412}
413
414// Compact anchor tables (interpolated) — close enough to matplotlib's for previews.
415const VIRIDIS: [[u8; 3]; 9] = [
416    [68, 1, 84],
417    [72, 40, 120],
418    [62, 74, 137],
419    [49, 104, 142],
420    [38, 130, 142],
421    [31, 158, 137],
422    [53, 183, 121],
423    [110, 206, 88],
424    [253, 231, 37],
425];
426
427const TURBO: [[u8; 3]; 11] = [
428    [48, 18, 59],
429    [61, 79, 195],
430    [54, 138, 247],
431    [33, 192, 225],
432    [39, 232, 166],
433    [127, 251, 86],
434    [191, 235, 49],
435    [240, 190, 50],
436    [251, 128, 44],
437    [225, 58, 20],
438    [122, 4, 3],
439];
440
441// Polarity colours for the raw event view — the warm/cool pair the interactive viewer uses
442// (positive → warm red, negative → cool blue). Kept in sync with the viewer's cloud colours.
443const RAW_POSITIVE: [u8; 3] = [0xff, 0x49, 0x6c];
444const RAW_NEGATIVE: [u8; 3] = [0x27, 0xc2, 0xff];
445
446/// A persistent polarity "event image" with exponential time decay — the canonical live event-
447/// camera view. Each event lights its pixel to full intensity in its polarity colour; the intensity
448/// then fades with a time constant so moving edges leave glowing trails rather than a hard frame.
449///
450/// Feed it successive [`EventStream`] windows with [`update`](Self::update) — live from a camera or
451/// sliced from a file — and read back an [`Rgb8Image`] each display frame with
452/// [`render`](Self::render). State persists across `update`s, so decay carries over window
453/// boundaries. Ages are measured in milliseconds via each stream's own timestamp scale, so the same
454/// `decay_ms` behaves identically whatever the source's time unit.
455/// Size of [`RawSurface`]'s precomputed decay lookup table. The table spans `[0, 6.5 · decay_ms]`
456/// regardless of `decay_ms` (the cutoff scales with it), so this fixed length gives the same
457/// relative resolution at any decay setting — fine enough that quantization error stays well under
458/// one `u8` step in the rendered output (see [`RawSurface::render`]'s doc comment for the derivation).
459const DECAY_LUT_LEN: usize = 4096;
460
461#[derive(Clone, Debug)]
462pub struct RawSurface {
463    width: usize,
464    height: usize,
465    // Per-pixel time (ms) and polarity of the most recent event; `NEG_INFINITY` = never lit.
466    last_t_ms: Vec<f64>,
467    last_positive: Vec<bool>,
468    // Timestamp (ms) of the most recent event across the whole surface — the "now" decay measures
469    // ages from.
470    latest_ms: f64,
471    // Precomputed `exp(-age / decay_ms)` over quantized age buckets spanning `[0, cutoff]`, indexed
472    // by `(age / decay_lut_step) as usize`. Replaces a per-pixel `exp()` call (expensive on cores
473    // without a fast hardware transcendental path) with an array lookup — the render loop visits
474    // every pixel each frame, so this matters at real sensor resolutions and event rates.
475    decay_lut: Vec<f32>,
476    decay_cutoff: f64,
477    decay_lut_step: f64,
478}
479
480impl RawSurface {
481    /// Creates a black surface for a `width` × `height` sensor. `decay_ms` is the fade time
482    /// constant: a pixel dims to `1/e` of full brightness `decay_ms` after its last event.
483    pub fn new(width: usize, height: usize, decay_ms: f64) -> Self {
484        let pixels = width * height;
485        let decay_ms = decay_ms.max(1e-6);
486        let decay_cutoff = decay_ms * 6.5;
487        let decay_lut_step = decay_cutoff / (DECAY_LUT_LEN - 1) as f64;
488        let decay_lut = (0..DECAY_LUT_LEN)
489            .map(|i| (-(i as f64 * decay_lut_step) / decay_ms).exp() as f32)
490            .collect();
491        Self {
492            width,
493            height,
494            last_t_ms: vec![f64::NEG_INFINITY; pixels],
495            last_positive: vec![false; pixels],
496            latest_ms: 0.0,
497            decay_lut,
498            decay_cutoff,
499            decay_lut_step,
500        }
501    }
502
503    /// Sensor dimensions `(width, height)`.
504    pub fn dimensions(&self) -> (usize, usize) {
505        (self.width, self.height)
506    }
507
508    /// Forgets every stamped event, returning the surface to black.
509    ///
510    /// What a looping playback needs at the wrap point: without it the trails from the end of the
511    /// recording are still fading over the first frames of the next pass.
512    pub fn clear(&mut self) {
513        self.last_t_ms.fill(f64::NEG_INFINITY);
514        self.last_positive.fill(false);
515        self.latest_ms = 0.0;
516    }
517
518    /// Stamps every event in `stream` onto the surface, keeping the most recent event per pixel.
519    /// Events outside the surface are ignored. Call repeatedly to build up a live view.
520    pub fn update(&mut self, stream: &EventStream) {
521        let scale = stream.timestamp_scale_ms();
522        let xs = stream.xs();
523        let ys = stream.ys();
524        let ts = stream.ts();
525        let ps = stream.ps();
526        for index in 0..xs.len() {
527            self.stamp(
528                xs[index] as usize,
529                ys[index] as usize,
530                ts[index] as f64 * scale,
531                ps[index],
532            );
533        }
534    }
535
536    /// Stamps a single event (time in **milliseconds**) onto the surface, keeping the most recent
537    /// event per pixel; out-of-bounds events are ignored. The per-event entry point for the live
538    /// viewer, which decodes straight from the camera; [`update`](Self::update) is the batch form.
539    pub fn stamp(&mut self, x: usize, y: usize, t_ms: f64, positive: bool) {
540        if x >= self.width || y >= self.height {
541            return;
542        }
543        let pixel = y * self.width + x;
544        // Keep the temporally-latest event per pixel even if events aren't perfectly ordered.
545        if t_ms >= self.last_t_ms[pixel] {
546            self.last_t_ms[pixel] = t_ms;
547            self.last_positive[pixel] = positive;
548        }
549        if t_ms > self.latest_ms {
550            self.latest_ms = t_ms;
551        }
552    }
553
554    /// Renders the current surface to an RGB image: each pixel is its polarity colour scaled by
555    /// `exp(-age / decay_ms)`, where `age` is the time since its last event. Never-lit pixels are
556    /// black.
557    pub fn render(&self) -> Rgb8Image {
558        let count = self.width * self.height;
559        let mut pixels = vec![0u8; count * 3]; // default black; only lit pixels are written.
560        // Past this age the brightest channel (255) scales below 0.5 and rounds to 0, so the pixel
561        // is black anyway. Skipping the lookup for those — and for never-lit pixels (age = +inf) —
562        // makes a typical sparse scene render in a fraction of the time (most pixels are black).
563        // `6.5 · τ` clears the round-to-zero threshold (`ln(255/0.5) ≈ 6.24 · τ`) with margin.
564        //
565        // `intensity` comes from `decay_lut` rather than calling `exp()` per pixel: this loop runs
566        // over every sensor pixel each display frame (up to ~60 Hz at full sensor resolution), and a
567        // dense/fast-moving scene lights most of them, so the per-pixel cost dominates render time on
568        // cores without a fast hardware transcendental path. The table's resolution (`DECAY_LUT_LEN`
569        // buckets over `[0, cutoff]`, and `cutoff ∝ decay_ms`) keeps the quantization error in
570        // `age / decay_ms` below `1 / DECAY_LUT_LEN`; since `d(intensity)/d(age) ≤ 1 / decay_ms`, the
571        // resulting intensity error stays under `1/DECAY_LUT_LEN`, well below one `u8` step
572        // (`1/255`) after rounding — so this reproduces the same rendered bytes as the direct `exp()`
573        // form in practice.
574        for index in 0..count {
575            let age = self.latest_ms - self.last_t_ms[index];
576            // Keep only `age < cutoff`; the `partial_cmp` form also rejects the `+inf` age of
577            // never-lit pixels and any NaN (both compare as `None`/not-`Less`).
578            if !matches!(age.partial_cmp(&self.decay_cutoff), Some(std::cmp::Ordering::Less)) {
579                continue;
580            }
581            let bucket = ((age / self.decay_lut_step) as usize).min(DECAY_LUT_LEN - 1);
582            let intensity = self.decay_lut[bucket];
583            let color = if self.last_positive[index] {
584                RAW_POSITIVE
585            } else {
586                RAW_NEGATIVE
587            };
588            let base = index * 3;
589            for channel in 0..3 {
590                pixels[base + channel] = (f32::from(color[channel]) * intensity).round() as u8;
591            }
592        }
593        Rgb8Image {
594            width: self.width,
595            height: self.height,
596            pixels,
597        }
598    }
599}
600
601/// Renders a single [`EventStream`] to a raw polarity "event image" in one call — the offline twin
602/// of a live [`RawSurface`]. Decay is measured from the stream's latest event, so the newest events
603/// are brightest.
604pub fn render_raw(stream: &EventStream, decay_ms: f64) -> Rgb8Image {
605    let (width, height) = stream.sensor_size();
606    let mut surface = RawSurface::new(width, height, decay_ms);
607    surface.update(stream);
608    surface.render()
609}
610
611#[cfg(test)]
612mod tests {
613    use super::{render_frame, render_raw, Colormap, RawSurface, Rgb8Image};
614    use crate::representation::{
615        AveragedTimeSurface, Binary, EventCount, EventFrame, EventFrameData, Representation,
616        RepresentationKind, Tencode,
617    };
618    use crate::EventStream;
619    use ndarray::array;
620
621    fn pixel(image: &Rgb8Image, x: usize, y: usize) -> [u8; 3] {
622        let i = (y * image.width + x) * 3;
623        [image.pixels[i], image.pixels[i + 1], image.pixels[i + 2]]
624    }
625
626    #[test]
627    fn raw_surface_colors_pixels_by_polarity() {
628        // A positive event at (0,0) and a negative at (1,0), same instant.
629        let stream = EventStream::from_array2(array![[0, 0, 10, 1], [1, 0, 10, 0]], 2, 1, 0.001);
630        let image = render_raw(&stream, 1000.0);
631
632        let [r0, _, b0] = pixel(&image, 0, 0);
633        let [r1, _, b1] = pixel(&image, 1, 0);
634        assert!(r0 > b0, "positive pixel is warm/red-dominant");
635        assert!(b1 > r1, "negative pixel is cool/blue-dominant");
636    }
637
638    #[test]
639    fn raw_surface_fades_older_events() {
640        // Two positive events 100 ms apart; the older (t=0) renders dimmer than the newest.
641        let stream =
642            EventStream::from_array2(array![[0, 0, 0, 1], [1, 0, 100_000, 1]], 2, 1, 0.001);
643        let image = render_raw(&stream, 50.0); // τ = 50 ms → older pixel is ~e^-2 of full
644
645        let old = pixel(&image, 0, 0)[0];
646        let new = pixel(&image, 1, 0)[0];
647        assert!(new > old, "newer event brighter than older");
648        assert!(old > 0, "older event still faintly visible");
649    }
650
651    #[test]
652    fn raw_surface_untouched_pixels_are_black() {
653        let stream = EventStream::from_array2(array![[0, 0, 10, 1]], 2, 1, 0.001);
654        let image = render_raw(&stream, 1000.0);
655
656        assert_eq!(pixel(&image, 1, 0), [0, 0, 0]);
657        assert_eq!((image.width, image.height), (2, 1));
658    }
659
660    #[test]
661    fn raw_surface_persists_across_updates() {
662        let (width, height) = (2, 1);
663        let mut surface = RawSurface::new(width, height, 50.0);
664        surface.update(&EventStream::from_array2(array![[0, 0, 0, 1]], width, height, 0.001));
665        surface.update(&EventStream::from_array2(
666            array![[1, 0, 100_000, 1]],
667            width,
668            height,
669            0.001,
670        ));
671        let image = surface.render();
672
673        // The first pixel survives the second update (state persists), still lit but dimmer.
674        assert!(pixel(&image, 0, 0)[0] > 0, "earlier event persists across updates");
675        assert!(pixel(&image, 1, 0)[0] > pixel(&image, 0, 0)[0], "newest event is brightest");
676    }
677
678    #[test]
679    fn count_frame_maps_the_busiest_pixel_to_the_colormap_top() {
680        let stream = EventStream::from_array2(
681            array![[0, 0, 1, 1], [0, 0, 2, 0], [1, 0, 3, 1]],
682            2,
683            1,
684            0.001,
685        );
686        let frame = EventCount::default().generate(&stream).unwrap();
687
688        let image = render_frame(&frame, Colormap::Grayscale, true);
689
690        assert_eq!(image.width, 2);
691        assert_eq!(image.height, 1);
692        // Pixel (0,0) has the max count (2) → white; (1,0) has 1 → mid grey.
693        assert_eq!(pixel(&image, 0, 0), [255, 255, 255]);
694        assert_eq!(pixel(&image, 1, 0), [128, 128, 128]);
695    }
696
697    #[test]
698    fn a_single_outlier_does_not_black_out_the_rest_of_the_field() {
699        // A 12×12 count frame: 143 pixels at 10, one spurious outlier at 1000. With
700        // max-normalisation the typical pixels would render near-black; the robust (p99) extent
701        // keeps them bright.
702        let plane = 12 * 12;
703        let mut data = vec![10_u64; plane];
704        data[0] = 1000; // the outlier
705        let frame = EventFrame::from_parts(
706            EventFrameData::U64(data),
707            12,
708            12,
709            RepresentationKind::Count,
710            vec!["count".to_owned()],
711        );
712
713        let image = render_frame(&frame, Colormap::Grayscale, true);
714
715        // A typical (10) pixel maps to the top of the range, not the floor.
716        assert!(
717            pixel(&image, 5, 5)[0] > 200,
718            "typical value must stay visible"
719        );
720    }
721
722    #[test]
723    fn flow_middlebury_encodes_direction_as_hue_and_zero_as_white() {
724        // A 12×12 flow frame: left half flows +x, right half flows -x, plus one zero pixel.
725        let plane = 12 * 12;
726        let mut data = vec![0.0_f32; plane * 2];
727        for y in 0..12 {
728            for x in 0..12 {
729                data[y * 12 + x] = if x < 6 { 1.0 } else { -1.0 }; // flow_x; flow_y stays 0
730            }
731        }
732        data[0] = 0.0; // a zero-flow pixel at the top-left
733        let frame = EventFrame::from_parts(
734            EventFrameData::F32(data),
735            12,
736            12,
737            RepresentationKind::Flow,
738            vec!["flow_x".to_owned(), "flow_y".to_owned()],
739        );
740
741        let image = render_frame(&frame, Colormap::Viridis, true);
742
743        // Opposite directions get different colours; zero flow renders white.
744        assert_ne!(
745            pixel(&image, 3, 5),
746            pixel(&image, 9, 5),
747            "opposite flow directions must differ in colour"
748        );
749        assert_eq!(pixel(&image, 0, 0), [255, 255, 255], "zero flow is white");
750    }
751
752    #[test]
753    fn signed_reprs_use_the_diverging_red_blue_map() {
754        // One positive event at (0,0), one negative at (1,0): the averaged time surface
755        // is signed, so (0,0) reads red and (1,0) reads blue regardless of colormap arg.
756        let stream = EventStream::from_array2(array![[0, 0, 10, 1], [1, 0, 10, 0]], 2, 1, 0.001);
757        let frame = AveragedTimeSurface::default().generate(&stream).unwrap();
758
759        let image = render_frame(&frame, Colormap::Viridis, true);
760
761        let [r0, _, b0] = pixel(&image, 0, 0);
762        let [r1, _, b1] = pixel(&image, 1, 0);
763        assert!(r0 > b0, "positive pixel should be red-dominant");
764        assert!(b1 > r1, "negative pixel should be blue-dominant");
765    }
766
767    #[test]
768    fn tencode_passes_through_as_rgb() {
769        let stream = EventStream::from_array2(array![[0, 0, 10, 1]], 1, 1, 0.001);
770        let frame = Tencode::default().generate(&stream).unwrap();
771
772        let image = render_frame(&frame, Colormap::Turbo, false);
773
774        assert_eq!(image.pixels.len(), 3);
775    }
776
777    #[test]
778    fn empty_frame_renders_uniformly_at_the_colormap_floor() {
779        let stream = EventStream::from_array2(ndarray::Array2::zeros((0, 4)), 3, 2, 0.001);
780        let frame = Binary.generate(&stream).unwrap();
781
782        let image = render_frame(&frame, Colormap::Viridis, true);
783
784        // Full-size, and every pixel is the colormap's zero anchor (Viridis floor).
785        assert_eq!(image.pixels.len(), 3 * 2 * 3);
786        assert_eq!(pixel(&image, 0, 0), [68, 1, 84]);
787        assert!(image
788            .pixels
789            .as_chunks::<3>()
790            .0
791            .iter()
792            .all(|rgb| *rgb == pixel(&image, 0, 0)));
793    }
794}