Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

1use bone_types::Tolerance; 2 3use crate::camera::ViewportExtent; 4use crate::snapshot::SnapshotFrame; 5 6#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] 7pub struct PixelDiffThreshold(f64); 8 9impl PixelDiffThreshold { 10 pub const EXACT: Self = Self(0.0); 11 12 #[must_use] 13 pub fn new(channel_fraction: f64) -> Self { 14 Self(channel_fraction.clamp(0.0, 1.0)) 15 } 16 17 #[must_use] 18 pub const fn value(self) -> f64 { 19 self.0 20 } 21 22 #[must_use] 23 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] 24 pub fn as_u8(self) -> u8 { 25 (self.0.clamp(0.0, 1.0) * 255.0).round() as u8 26 } 27} 28 29impl From<Tolerance> for PixelDiffThreshold { 30 fn from(t: Tolerance) -> Self { 31 Self::new(t.value()) 32 } 33} 34 35#[derive(Copy, Clone, Debug, PartialEq, Eq)] 36pub struct PixelMismatch { 37 x: u32, 38 y: u32, 39 channel_delta: [u8; 4], 40} 41 42impl PixelMismatch { 43 #[must_use] 44 pub const fn x(self) -> u32 { 45 self.x 46 } 47 48 #[must_use] 49 pub const fn y(self) -> u32 { 50 self.y 51 } 52 53 #[must_use] 54 pub const fn channel_delta(self) -> [u8; 4] { 55 self.channel_delta 56 } 57 58 #[must_use] 59 pub fn max_delta(self) -> u8 { 60 self.channel_delta.into_iter().max().unwrap_or(0) 61 } 62} 63 64#[derive(Clone, Debug, Default)] 65pub struct PixelDiffReport { 66 worst: Option<PixelMismatch>, 67 over_threshold: u32, 68} 69 70impl PixelDiffReport { 71 #[must_use] 72 pub const fn worst(&self) -> Option<PixelMismatch> { 73 self.worst 74 } 75 76 #[must_use] 77 pub const fn over_threshold(&self) -> u32 { 78 self.over_threshold 79 } 80 81 #[must_use] 82 pub const fn is_clean(&self) -> bool { 83 self.over_threshold == 0 84 } 85} 86 87#[derive(Debug, thiserror::Error)] 88pub enum PixelDiffError { 89 #[error( 90 "pixel buffer length mismatch: actual={actual}, expected={expected}, required={required}" 91 )] 92 ShapeMismatch { 93 actual: usize, 94 expected: usize, 95 required: usize, 96 }, 97 #[error("viewport dimension is zero")] 98 ZeroExtent, 99 #[error("pixel count {pixels} exceeds u32::MAX")] 100 ExtentOverflow { pixels: u64 }, 101} 102 103pub struct PixelDiff; 104 105impl PixelDiff { 106 pub fn compare_bytes( 107 extent: ViewportExtent, 108 actual: &[u8], 109 expected: &[u8], 110 threshold: PixelDiffThreshold, 111 ) -> core::result::Result<PixelDiffReport, PixelDiffError> { 112 let width = extent.width().value(); 113 let height = extent.height().value(); 114 if width == 0 || height == 0 { 115 return Err(PixelDiffError::ZeroExtent); 116 } 117 let pixel_count = extent.pixel_count(); 118 if pixel_count > u64::from(u32::MAX) { 119 return Err(PixelDiffError::ExtentOverflow { 120 pixels: pixel_count, 121 }); 122 } 123 let Ok(required) = usize::try_from(pixel_count * 4) else { 124 return Err(PixelDiffError::ExtentOverflow { 125 pixels: pixel_count, 126 }); 127 }; 128 if actual.len() != required || expected.len() != required { 129 return Err(PixelDiffError::ShapeMismatch { 130 actual: actual.len(), 131 expected: expected.len(), 132 required, 133 }); 134 } 135 let limit = threshold.as_u8(); 136 let report = actual 137 .chunks_exact(4) 138 .zip(expected.chunks_exact(4)) 139 .zip(0_u32..) 140 .filter_map(|((a, e), idx)| { 141 let delta: [u8; 4] = [ 142 a[0].abs_diff(e[0]), 143 a[1].abs_diff(e[1]), 144 a[2].abs_diff(e[2]), 145 a[3].abs_diff(e[3]), 146 ]; 147 let max = delta[0].max(delta[1]).max(delta[2]).max(delta[3]); 148 (max > limit).then_some((idx, delta)) 149 }) 150 .fold(PixelDiffReport::default(), |acc, (idx, delta)| { 151 let m = PixelMismatch { 152 x: idx % width, 153 y: idx / width, 154 channel_delta: delta, 155 }; 156 let worst = match acc.worst { 157 Some(prev) if prev.max_delta() >= m.max_delta() => Some(prev), 158 _ => Some(m), 159 }; 160 PixelDiffReport { 161 worst, 162 over_threshold: acc.over_threshold + 1, 163 } 164 }); 165 Ok(report) 166 } 167 168 pub fn compare( 169 actual: &SnapshotFrame, 170 expected: &[u8], 171 threshold: PixelDiffThreshold, 172 ) -> core::result::Result<PixelDiffReport, PixelDiffError> { 173 Self::compare_bytes(actual.extent(), actual.rgba(), expected, threshold) 174 } 175} 176 177#[cfg(test)] 178mod tests { 179 use super::*; 180 use crate::camera::{ViewportExtent, ViewportPx}; 181 182 fn extent(w: u32, h: u32) -> ViewportExtent { 183 ViewportExtent::new(ViewportPx::new(w), ViewportPx::new(h)) 184 } 185 186 fn run( 187 extent: ViewportExtent, 188 actual: &[u8], 189 expected: &[u8], 190 threshold: PixelDiffThreshold, 191 ) -> PixelDiffReport { 192 let Ok(report) = PixelDiff::compare_bytes(extent, actual, expected, threshold) else { 193 panic!("compare_bytes rejected inputs"); 194 }; 195 report 196 } 197 198 #[test] 199 fn exact_match_is_clean() { 200 let buf = vec![10_u8, 20, 30, 255, 10, 20, 30, 255]; 201 let report = run(extent(2, 1), &buf, &buf, PixelDiffThreshold::EXACT); 202 assert!(report.is_clean()); 203 assert!(report.worst().is_none()); 204 } 205 206 #[test] 207 fn single_channel_delta_under_threshold_is_clean() { 208 let a = vec![10_u8, 20, 30, 255]; 209 let b = vec![12_u8, 20, 30, 255]; 210 let report = run(extent(1, 1), &a, &b, PixelDiffThreshold::new(4.0 / 255.0)); 211 assert!(report.is_clean()); 212 } 213 214 #[test] 215 fn over_threshold_reports_worst_pixel_coords() { 216 let a = vec![0_u8, 0, 0, 255, 200, 0, 0, 255]; 217 let b = vec![0_u8, 0, 0, 255, 50, 0, 0, 255]; 218 let report = run(extent(2, 1), &a, &b, PixelDiffThreshold::EXACT); 219 let Some(worst) = report.worst() else { 220 panic!("worst pixel present"); 221 }; 222 assert_eq!(worst.x(), 1); 223 assert_eq!(worst.y(), 0); 224 assert_eq!(worst.max_delta(), 150); 225 assert_eq!(report.over_threshold(), 1); 226 } 227 228 #[test] 229 fn tolerance_maps_to_threshold() { 230 let t = Tolerance::new(8.0 / 255.0); 231 let th: PixelDiffThreshold = t.into(); 232 assert_eq!(th.as_u8(), 8); 233 } 234 235 #[test] 236 fn coords_index_by_row_major() { 237 let a = vec![0_u8; 32]; 238 let mut b = a.clone(); 239 let pixel = 5_usize; 240 b[pixel * 4] = 200; 241 let report = run(extent(4, 2), &a, &b, PixelDiffThreshold::EXACT); 242 let Some(worst) = report.worst() else { 243 panic!("worst present"); 244 }; 245 assert_eq!(worst.x(), 1); 246 assert_eq!(worst.y(), 1); 247 } 248 249 #[test] 250 fn shape_mismatch_is_rejected() { 251 let a = vec![0_u8; 12]; 252 let b = vec![0_u8; 8]; 253 let Err(err) = PixelDiff::compare_bytes(extent(2, 1), &a, &b, PixelDiffThreshold::EXACT) 254 else { 255 panic!("expected ShapeMismatch"); 256 }; 257 let PixelDiffError::ShapeMismatch { 258 actual, 259 expected, 260 required, 261 } = err 262 else { 263 panic!("wrong error variant: {err:?}"); 264 }; 265 assert_eq!(actual, 12); 266 assert_eq!(expected, 8); 267 assert_eq!(required, 8); 268 } 269 270 #[test] 271 fn zero_extent_is_rejected() { 272 let buf: Vec<u8> = Vec::new(); 273 let Err(err) = 274 PixelDiff::compare_bytes(extent(0, 4), &buf, &buf, PixelDiffThreshold::EXACT) 275 else { 276 panic!("expected ZeroExtent"); 277 }; 278 assert!(matches!(err, PixelDiffError::ZeroExtent)); 279 } 280}