Skip to main content

trueno/simulation/visual/
mod.rs

1//! Visual Regression Testing (Genchi Genbutsu: Go and See)
2//!
3//! Provides pixel-perfect validation of compute outputs through
4//! heatmap rendering and golden baseline comparison.
5
6use std::path::PathBuf;
7
8/// RGB color for visualization
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct Rgb {
11    /// Red component
12    pub r: u8,
13    /// Green component
14    pub g: u8,
15    /// Blue component
16    pub b: u8,
17}
18
19impl Rgb {
20    /// Create new RGB color
21    #[must_use]
22    pub const fn new(r: u8, g: u8, b: u8) -> Self {
23        Self { r, g, b }
24    }
25
26    /// Magenta for NaN values
27    pub const NAN_COLOR: Self = Self::new(255, 0, 255);
28    /// White for +Infinity
29    pub const INF_COLOR: Self = Self::new(255, 255, 255);
30    /// Black for -Infinity
31    pub const NEG_INF_COLOR: Self = Self::new(0, 0, 0);
32}
33
34/// Color palette for heatmap rendering
35#[derive(Debug, Clone)]
36pub struct ColorPalette {
37    pub(crate) colors: Vec<Rgb>,
38}
39
40impl Default for ColorPalette {
41    fn default() -> Self {
42        Self::viridis()
43    }
44}
45
46impl ColorPalette {
47    /// Viridis colorblind-friendly palette
48    #[must_use]
49    pub fn viridis() -> Self {
50        Self {
51            colors: vec![
52                Rgb::new(68, 1, 84),
53                Rgb::new(59, 82, 139),
54                Rgb::new(33, 145, 140),
55                Rgb::new(94, 201, 98),
56                Rgb::new(253, 231, 37),
57            ],
58        }
59    }
60
61    /// Grayscale palette
62    #[must_use]
63    pub fn grayscale() -> Self {
64        Self { colors: vec![Rgb::new(0, 0, 0), Rgb::new(128, 128, 128), Rgb::new(255, 255, 255)] }
65    }
66
67    /// Interpolate color at position t (0.0 to 1.0)
68    #[must_use]
69    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
70    pub fn interpolate(&self, t: f32) -> Rgb {
71        let t = t.clamp(0.0, 1.0);
72        let n = self.colors.len() - 1;
73        let idx = (t * n as f32).floor() as usize;
74        let idx = idx.min(n - 1);
75        let local_t = t * n as f32 - idx as f32;
76
77        let c1 = &self.colors[idx];
78        let c2 = &self.colors[idx + 1];
79
80        Rgb {
81            r: (c1.r as f32 + (c2.r as f32 - c1.r as f32) * local_t) as u8,
82            g: (c1.g as f32 + (c2.g as f32 - c1.g as f32) * local_t) as u8,
83            b: (c1.b as f32 + (c2.b as f32 - c1.b as f32) * local_t) as u8,
84        }
85    }
86}
87
88/// Visual regression test configuration (Genchi Genbutsu)
89#[derive(Debug, Clone)]
90pub struct VisualRegressionConfig {
91    /// Golden baseline directory
92    pub golden_dir: PathBuf,
93    /// Output directory for test results
94    pub output_dir: PathBuf,
95    /// Maximum allowed different pixels (percentage)
96    pub max_diff_pct: f64,
97    /// Color palette for visualization
98    pub palette: ColorPalette,
99}
100
101impl Default for VisualRegressionConfig {
102    fn default() -> Self {
103        Self {
104            golden_dir: PathBuf::from("golden"),
105            output_dir: PathBuf::from("test_output"),
106            max_diff_pct: 0.0, // Exact match by default
107            palette: ColorPalette::default(),
108        }
109    }
110}
111
112impl VisualRegressionConfig {
113    /// Create new config with custom golden directory
114    #[must_use]
115    pub fn new(golden_dir: impl Into<PathBuf>) -> Self {
116        Self { golden_dir: golden_dir.into(), ..Default::default() }
117    }
118
119    /// Set output directory
120    #[must_use]
121    pub fn with_output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
122        self.output_dir = dir.into();
123        self
124    }
125
126    /// Set maximum diff percentage
127    #[must_use]
128    pub const fn with_max_diff_pct(mut self, pct: f64) -> Self {
129        self.max_diff_pct = pct;
130        self
131    }
132
133    /// Set color palette
134    #[must_use]
135    pub fn with_palette(mut self, palette: ColorPalette) -> Self {
136        self.palette = palette;
137        self
138    }
139}
140
141/// Pixel diff result for visual regression testing
142#[derive(Debug, Clone)]
143pub struct PixelDiffResult {
144    /// Number of pixels that differ
145    pub different_pixels: usize,
146    /// Total number of pixels
147    pub total_pixels: usize,
148    /// Maximum color difference found
149    pub max_diff: u32,
150}
151
152impl PixelDiffResult {
153    /// Calculate percentage of different pixels
154    #[must_use]
155    pub fn diff_percentage(&self) -> f64 {
156        if self.total_pixels == 0 {
157            0.0
158        } else {
159            (self.different_pixels as f64 / self.total_pixels as f64) * 100.0
160        }
161    }
162
163    /// Check if images match within threshold
164    #[must_use]
165    pub fn matches(&self, threshold_pct: f64) -> bool {
166        self.diff_percentage() <= threshold_pct
167    }
168
169    /// Create a passing result (no differences)
170    #[must_use]
171    pub const fn pass(total_pixels: usize) -> Self {
172        Self { different_pixels: 0, total_pixels, max_diff: 0 }
173    }
174}
175
176/// Simple buffer renderer for SIMD output visualization
177///
178/// Converts f32 buffers to raw RGBA bytes for testing
179#[derive(Debug, Clone)]
180pub struct BufferRenderer {
181    palette: ColorPalette,
182    pub(crate) range: Option<(f32, f32)>,
183}
184
185impl Default for BufferRenderer {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191impl BufferRenderer {
192    /// Create renderer with auto-normalization
193    #[must_use]
194    pub fn new() -> Self {
195        Self { palette: ColorPalette::default(), range: None }
196    }
197
198    /// Set fixed range for normalization
199    #[must_use]
200    pub const fn with_range(mut self, min: f32, max: f32) -> Self {
201        self.range = Some((min, max));
202        self
203    }
204
205    /// Set color palette
206    #[must_use]
207    pub fn with_palette(mut self, palette: ColorPalette) -> Self {
208        self.palette = palette;
209        self
210    }
211
212    /// Render f32 buffer to raw RGBA bytes
213    ///
214    /// Returns Vec<u8> with RGBA pixels (4 bytes per pixel)
215    #[must_use]
216    pub fn render_to_rgba(&self, buffer: &[f32], width: u32, height: u32) -> Vec<u8> {
217        assert_eq!(buffer.len(), (width * height) as usize);
218
219        let (min_val, max_val) = self.range.unwrap_or_else(|| {
220            let valid: Vec<f32> = buffer.iter().copied().filter(|v| v.is_finite()).collect();
221            if valid.is_empty() {
222                (0.0, 1.0)
223            } else {
224                let min = valid.iter().copied().fold(f32::INFINITY, f32::min);
225                let max = valid.iter().copied().fold(f32::NEG_INFINITY, f32::max);
226                (min, max.max(min + f32::EPSILON))
227            }
228        });
229
230        let mut rgba = Vec::with_capacity(buffer.len() * 4);
231
232        for &value in buffer {
233            let color = if value.is_nan() {
234                Rgb::NAN_COLOR
235            } else if value.is_infinite() {
236                if value > 0.0 {
237                    Rgb::INF_COLOR
238                } else {
239                    Rgb::NEG_INF_COLOR
240                }
241            } else {
242                let t = (value - min_val) / (max_val - min_val);
243                self.palette.interpolate(t)
244            };
245
246            rgba.push(color.r);
247            rgba.push(color.g);
248            rgba.push(color.b);
249            rgba.push(255); // Alpha
250        }
251
252        rgba
253    }
254
255    /// Compare two RGBA buffers and return diff result
256    #[must_use]
257    pub fn compare_rgba(&self, a: &[u8], b: &[u8], tolerance: u8) -> PixelDiffResult {
258        if a == b {
259            return PixelDiffResult::pass(a.len() / 4);
260        }
261
262        let min_len = a.len().min(b.len());
263        let mut different = 0;
264        let mut max_diff: u32 = 0;
265
266        // Compare pixels (4 bytes each: RGBA)
267        for i in (0..min_len).step_by(4) {
268            let mut pixel_diff = false;
269            for j in 0..4 {
270                if i + j < min_len {
271                    let diff = (a[i + j] as i32 - b[i + j] as i32).unsigned_abs();
272                    if diff > tolerance as u32 {
273                        pixel_diff = true;
274                        max_diff = max_diff.max(diff);
275                    }
276                }
277            }
278            if pixel_diff {
279                different += 1;
280            }
281        }
282
283        // Count size difference as pixel differences
284        if a.len() != b.len() {
285            different += a.len().abs_diff(b.len()) / 4;
286        }
287
288        PixelDiffResult {
289            different_pixels: different,
290            total_pixels: min_len.max(a.len()).max(b.len()) / 4,
291            max_diff,
292        }
293    }
294}
295
296/// Golden baseline manager for visual regression testing
297#[derive(Debug, Clone)]
298pub struct GoldenBaseline {
299    config: VisualRegressionConfig,
300}
301
302impl GoldenBaseline {
303    /// Create new golden baseline manager
304    #[must_use]
305    pub fn new(config: VisualRegressionConfig) -> Self {
306        Self { config }
307    }
308
309    /// Get path for a golden baseline file
310    #[must_use]
311    pub fn golden_path(&self, name: &str) -> PathBuf {
312        self.config.golden_dir.join(format!("{name}.golden"))
313    }
314
315    /// Get path for an output file
316    #[must_use]
317    pub fn output_path(&self, name: &str) -> PathBuf {
318        self.config.output_dir.join(format!("{name}.output"))
319    }
320
321    /// Get the config
322    #[must_use]
323    pub const fn config(&self) -> &VisualRegressionConfig {
324        &self.config
325    }
326}
327
328#[cfg(test)]
329mod tests;