Skip to main content

braid_sim/
render.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Render insects as Gaussian blobs into a Mono8 image that the real
5//! flydra-feature-detector reliably localizes.
6
7/// Render a `width` x `height` Mono8 image: a flat `background` with an additive
8/// Gaussian blob (peak `peak`, standard deviation `sigma` pixels) at each
9/// `(x, y)` center in `blobs`. Stride equals `width`.
10///
11/// Only a window of a few sigma around each blob is touched, so this is cheap
12/// even for large images.
13pub fn render_mono8(
14    width: usize,
15    height: usize,
16    background: u8,
17    blobs: &[(f64, f64)],
18    peak: f64,
19    sigma: f64,
20) -> Vec<u8> {
21    let mut buf = vec![background; width * height];
22    if peak <= 0.0 || sigma <= 0.0 {
23        return buf;
24    }
25    let two_sig2 = 2.0 * sigma * sigma;
26    let radius = (4.0 * sigma).ceil() as i64;
27    for &(cx, cy) in blobs {
28        let cxr = cx.round() as i64;
29        let cyr = cy.round() as i64;
30        let y0 = (cyr - radius).max(0);
31        let y1 = (cyr + radius + 1).min(height as i64);
32        let x0 = (cxr - radius).max(0);
33        let x1 = (cxr + radius + 1).min(width as i64);
34        for py in y0..y1 {
35            for px in x0..x1 {
36                let dx = px as f64 - cx;
37                let dy = py as f64 - cy;
38                let g = peak * (-(dx * dx + dy * dy) / two_sig2).exp();
39                let idx = py as usize * width + px as usize;
40                let v = (buf[idx] as f64 + g).round().clamp(0.0, 255.0) as u8;
41                buf[idx] = v;
42            }
43        }
44    }
45    buf
46}
47
48/// Render a `width` x `height` RGB8 image of the same neutral-gray scene as
49/// [`render_mono8`]: every pixel's three channels are set equal to the Mono8
50/// value, so the content (background plus Gaussian blobs) is identical, just
51/// carried in a color pixel format. Stride equals `width * 3`.
52///
53/// This lets the sim backend produce color frames so the color recording path
54/// (e.g. the ffmpeg MP4 writer) can be exercised without camera hardware.
55pub fn render_rgb8(
56    width: usize,
57    height: usize,
58    background: u8,
59    blobs: &[(f64, f64)],
60    peak: f64,
61    sigma: f64,
62) -> Vec<u8> {
63    let mono = render_mono8(width, height, background, blobs, peak, sigma);
64    let mut buf = vec![0u8; width * height * 3];
65    for (pixel, &v) in buf.chunks_exact_mut(3).zip(mono.iter()) {
66        pixel[0] = v;
67        pixel[1] = v;
68        pixel[2] = v;
69    }
70    buf
71}
72
73/// Neutral chroma value for the grayscale scene carried in a YUV format.
74const NEUTRAL_CHROMA: u8 = 128;
75
76/// Render a `width` x `height` YUV422 (UYVY-packed) image of the same
77/// neutral-gray scene as [`render_mono8`]: each pixel's luma (Y) is the Mono8
78/// value and the shared chroma (U, V) is neutral (128). The byte order is
79/// `[U, Y0, V, Y1]` per horizontal pixel pair, matching
80/// `machine_vision_formats::pixel_format::YUV422`. Stride equals `width * 2`.
81///
82/// `width` must be even (each 4-byte group encodes two pixels).
83pub fn render_yuv422_uyvy(
84    width: usize,
85    height: usize,
86    background: u8,
87    blobs: &[(f64, f64)],
88    peak: f64,
89    sigma: f64,
90) -> Vec<u8> {
91    assert!(width.is_multiple_of(2), "YUV422 requires an even width");
92    let mono = render_mono8(width, height, background, blobs, peak, sigma);
93    let mut buf = vec![0u8; width * height * 2];
94    for (out_row, in_row) in buf
95        .chunks_exact_mut(width * 2)
96        .zip(mono.chunks_exact(width))
97    {
98        for (group, pair) in out_row.chunks_exact_mut(4).zip(in_row.chunks_exact(2)) {
99            group[0] = NEUTRAL_CHROMA; // U
100            group[1] = pair[0]; // Y0
101            group[2] = NEUTRAL_CHROMA; // V
102            group[3] = pair[1]; // Y1
103        }
104    }
105    buf
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn blob_is_brightest_at_center_and_background_far_away() {
114        let (w, h) = (64usize, 48usize);
115        let buf = render_mono8(w, h, 0, &[(20.0, 15.0)], 160.0, 1.5);
116        let at = |x: usize, y: usize| buf[y * w + x] as u32;
117        // Peak at the center, dark in a far corner.
118        assert_eq!(at(20, 15), 160);
119        assert_eq!(at(60, 45), 0);
120        // Monotonic falloff moving away from center.
121        assert!(at(20, 15) > at(22, 15));
122        assert!(at(22, 15) > at(25, 15));
123    }
124
125    #[test]
126    fn empty_blobs_yield_flat_background() {
127        let buf = render_mono8(8, 8, 7, &[], 160.0, 1.5);
128        assert!(buf.iter().all(|&v| v == 7));
129    }
130
131    #[test]
132    fn yuv422_carries_mono_luma_with_neutral_chroma() {
133        let (w, h) = (64usize, 48usize);
134        let blobs = [(20.0, 15.0)];
135        let mono = render_mono8(w, h, 3, &blobs, 160.0, 1.5);
136        let yuv = render_yuv422_uyvy(w, h, 3, &blobs, 160.0, 1.5);
137        assert_eq!(yuv.len(), w * h * 2);
138        // UYVY: [U, Y0, V, Y1] per pixel pair; chroma neutral, luma == mono.
139        for (group, pair) in yuv.chunks_exact(4).zip(mono.chunks_exact(2)) {
140            assert_eq!(group[0], 128); // U
141            assert_eq!(group[1], pair[0]); // Y0
142            assert_eq!(group[2], 128); // V
143            assert_eq!(group[3], pair[1]); // Y1
144        }
145    }
146
147    #[test]
148    fn rgb8_replicates_mono_into_three_equal_channels() {
149        let (w, h) = (64usize, 48usize);
150        let blobs = [(20.0, 15.0)];
151        let mono = render_mono8(w, h, 3, &blobs, 160.0, 1.5);
152        let rgb = render_rgb8(w, h, 3, &blobs, 160.0, 1.5);
153        assert_eq!(rgb.len(), w * h * 3);
154        for (i, &v) in mono.iter().enumerate() {
155            assert_eq!(rgb[i * 3], v);
156            assert_eq!(rgb[i * 3 + 1], v);
157            assert_eq!(rgb[i * 3 + 2], v);
158        }
159    }
160}