use image::{ImageBuffer, Rgba, RgbaImage}; use serde::Deserialize; #[derive(Debug, Clone, Deserialize, Default)] pub struct ManifestMeta { pub width: Option, pub height: Option, pub lod: Option, pub group: Option, pub angle: Option, pub diff_threshold: Option, pub max_mean_abs: Option, pub max_changed_ratio: Option, } #[derive(Debug, Clone, Deserialize)] pub struct CaseSpec { pub id: String, pub archive: String, pub model: Option, pub reference: String, pub width: Option, pub height: Option, pub lod: Option, pub group: Option, pub angle: Option, pub diff_threshold: Option, pub max_mean_abs: Option, pub max_changed_ratio: Option, } #[derive(Debug, Clone, Deserialize)] pub struct ParityManifest { #[serde(default)] pub meta: ManifestMeta, #[serde(rename = "case", default)] pub cases: Vec, } #[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 { 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 { 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, Vec> = 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 { 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); } }