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