diff options
| author | Valentin Popov <valentin@popov.link> | 2026-02-19 04:02:26 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-02-19 04:02:26 +0300 |
| commit | 18d4c6cf9fabc18282b29d103c8d30024f66e49b (patch) | |
| tree | 7f9adfa0dcae5653f378c14b32a238c6771c1602 /crates/render-parity/src/lib.rs | |
| parent | 0e19660eb5122c8c52d5e909927884ad5c50b813 (diff) | |
| download | fparkan-18d4c6cf9fabc18282b29d103c8d30024f66e49b.tar.xz fparkan-18d4c6cf9fabc18282b29d103c8d30024f66e49b.zip | |
feat(render-parity): add deterministic frame comparison tool
- Introduced `render-parity` crate for comparing rendered frames against reference images.
- Added command-line options for specifying manifest and output directory.
- Implemented image comparison metrics: mean absolute difference, maximum absolute difference, and changed pixel ratio.
- Created a configuration file `cases.toml` for defining test cases with global defaults and specific parameters.
- Added functionality to capture frames from `render-demo` and save diff images on discrepancies.
- Updated documentation to include usage instructions and CI model for automated testing.
Diffstat (limited to 'crates/render-parity/src/lib.rs')
| -rw-r--r-- | crates/render-parity/src/lib.rs | 212 |
1 files changed, 212 insertions, 0 deletions
diff --git a/crates/render-parity/src/lib.rs b/crates/render-parity/src/lib.rs new file mode 100644 index 0000000..cb412e9 --- /dev/null +++ b/crates/render-parity/src/lib.rs @@ -0,0 +1,212 @@ +use image::{ImageBuffer, Rgba, RgbaImage}; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ManifestMeta { + pub width: Option<u32>, + pub height: Option<u32>, + pub lod: Option<usize>, + pub group: Option<usize>, + pub angle: Option<f32>, + pub diff_threshold: Option<u8>, + pub max_mean_abs: Option<f32>, + pub max_changed_ratio: Option<f32>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CaseSpec { + pub id: String, + pub archive: String, + pub model: Option<String>, + pub reference: String, + pub width: Option<u32>, + pub height: Option<u32>, + pub lod: Option<usize>, + pub group: Option<usize>, + pub angle: Option<f32>, + pub diff_threshold: Option<u8>, + pub max_mean_abs: Option<f32>, + pub max_changed_ratio: Option<f32>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ParityManifest { + #[serde(default)] + pub meta: ManifestMeta, + #[serde(rename = "case", default)] + pub cases: Vec<CaseSpec>, +} + +#[derive(Debug, Clone)] +pub struct DiffMetrics { + pub width: u32, + pub height: u32, + pub mean_abs: f32, + pub max_abs: u8, + pub changed_pixels: u64, + pub changed_ratio: f32, +} + +pub fn compare_images( + reference: &RgbaImage, + actual: &RgbaImage, + diff_threshold: u8, +) -> Result<DiffMetrics, String> { + let (rw, rh) = reference.dimensions(); + let (aw, ah) = actual.dimensions(); + if rw != aw || rh != ah { + return Err(format!( + "image size mismatch: reference={}x{}, actual={}x{}", + rw, rh, aw, ah + )); + } + + let mut diff_sum = 0u64; + let mut max_abs = 0u8; + let mut changed_pixels = 0u64; + let pixel_count = u64::from(rw).saturating_mul(u64::from(rh)); + + for (ref_px, act_px) in reference.pixels().zip(actual.pixels()) { + let mut pixel_changed = false; + for chan in 0..3 { + let a = i16::from(ref_px[chan]); + let b = i16::from(act_px[chan]); + let diff = (a - b).unsigned_abs() as u8; + diff_sum = diff_sum.saturating_add(u64::from(diff)); + if diff > max_abs { + max_abs = diff; + } + if diff > diff_threshold { + pixel_changed = true; + } + } + if pixel_changed { + changed_pixels = changed_pixels.saturating_add(1); + } + } + + let channels = pixel_count.saturating_mul(3); + let mean_abs = if channels == 0 { + 0.0 + } else { + diff_sum as f32 / channels as f32 + }; + let changed_ratio = if pixel_count == 0 { + 0.0 + } else { + changed_pixels as f32 / pixel_count as f32 + }; + + Ok(DiffMetrics { + width: rw, + height: rh, + mean_abs, + max_abs, + changed_pixels, + changed_ratio, + }) +} + +pub fn build_diff_image(reference: &RgbaImage, actual: &RgbaImage) -> Result<RgbaImage, String> { + let (rw, rh) = reference.dimensions(); + let (aw, ah) = actual.dimensions(); + if rw != aw || rh != ah { + return Err(format!( + "image size mismatch: reference={}x{}, actual={}x{}", + rw, rh, aw, ah + )); + } + + let mut out: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(rw, rh); + for (dst, (ref_px, act_px)) in out + .pixels_mut() + .zip(reference.pixels().zip(actual.pixels())) + { + let dr = (i16::from(ref_px[0]) - i16::from(act_px[0])).unsigned_abs() as u8; + let dg = (i16::from(ref_px[1]) - i16::from(act_px[1])).unsigned_abs() as u8; + let db = (i16::from(ref_px[2]) - i16::from(act_px[2])).unsigned_abs() as u8; + *dst = Rgba([dr, dg, db, 255]); + } + Ok(out) +} + +pub fn evaluate_metrics( + metrics: &DiffMetrics, + max_mean_abs: f32, + max_changed_ratio: f32, +) -> Vec<String> { + let mut violations = Vec::new(); + if metrics.mean_abs > max_mean_abs { + violations.push(format!( + "mean_abs {:.4} > allowed {:.4}", + metrics.mean_abs, max_mean_abs + )); + } + if metrics.changed_ratio > max_changed_ratio { + violations.push(format!( + "changed_ratio {:.4}% > allowed {:.4}%", + metrics.changed_ratio * 100.0, + max_changed_ratio * 100.0 + )); + } + violations +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> RgbaImage { + let mut img = RgbaImage::new(w, h); + for px in img.pixels_mut() { + *px = Rgba([r, g, b, 255]); + } + img + } + + #[test] + fn compare_identical_images() { + let ref_img = solid(4, 3, 10, 20, 30); + let act_img = solid(4, 3, 10, 20, 30); + let metrics = compare_images(&ref_img, &act_img, 2).expect("comparison must succeed"); + assert_eq!(metrics.width, 4); + assert_eq!(metrics.height, 3); + assert_eq!(metrics.max_abs, 0); + assert_eq!(metrics.changed_pixels, 0); + assert_eq!(metrics.mean_abs, 0.0); + assert_eq!(metrics.changed_ratio, 0.0); + } + + #[test] + fn compare_detects_changes_and_thresholds() { + let mut ref_img = solid(2, 2, 100, 100, 100); + let mut act_img = solid(2, 2, 100, 100, 100); + ref_img.put_pixel(1, 1, Rgba([120, 100, 100, 255])); + act_img.put_pixel(1, 1, Rgba([100, 100, 100, 255])); + + let metrics = compare_images(&ref_img, &act_img, 5).expect("comparison must succeed"); + assert_eq!(metrics.max_abs, 20); + assert_eq!(metrics.changed_pixels, 1); + assert!((metrics.changed_ratio - 0.25).abs() < 1e-6); + assert!(metrics.mean_abs > 0.0); + + let violations = evaluate_metrics(&metrics, 2.0, 0.20); + assert_eq!(violations.len(), 1); + assert!(violations[0].contains("changed_ratio")); + } + + #[test] + fn build_diff_image_returns_per_channel_abs_diff() { + let mut ref_img = solid(1, 1, 100, 150, 200); + let mut act_img = solid(1, 1, 90, 180, 170); + ref_img.put_pixel(0, 0, Rgba([100, 150, 200, 255])); + act_img.put_pixel(0, 0, Rgba([90, 180, 170, 255])); + + let diff = build_diff_image(&ref_img, &act_img).expect("diff image must build"); + let px = diff.get_pixel(0, 0); + assert_eq!(px[0], 10); + assert_eq!(px[1], 30); + assert_eq!(px[2], 30); + assert_eq!(px[3], 255); + } +} |
