const GRID_SIZE: usize = 16;
const GRID_CELLS: usize = GRID_SIZE * GRID_SIZE;
const CELL_DELTA_THRESHOLD: f32 = 0.02;
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
#[must_use]
fn fnv1a(data: &[u8]) -> u64 {
data.iter().fold(FNV_OFFSET, |h, &b| {
(h ^ u64::from(b)).wrapping_mul(FNV_PRIME)
})
}
fn try_extract_png_grid(png_bytes: &[u8]) -> Option<[f32; GRID_CELLS]> {
const SIG: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
if png_bytes.len() < 8 || &png_bytes[..8] != SIG {
return None;
}
let ihdr = read_chunk(png_bytes, 8)?;
if ihdr.chunk_type != *b"IHDR" || ihdr.data.len() < 13 {
return None;
}
let width = u32::from_be_bytes(ihdr.data[0..4].try_into().ok()?) as usize;
let height = u32::from_be_bytes(ihdr.data[4..8].try_into().ok()?) as usize;
let bit_depth = ihdr.data[8];
let colour_type = ihdr.data[9];
let interlace = ihdr.data[12];
if bit_depth != 8 || !matches!(colour_type, 2 | 6) || interlace != 0 {
return None;
}
let channels = if colour_type == 6 { 4_usize } else { 3_usize };
let idat = find_chunk(png_bytes, *b"IDAT")?;
let unfiltered = inflate_and_unfilter(idat.data, width, height, channels)?;
Some(pixels_to_grid(&unfiltered, width, height, channels))
}
fn inflate_and_unfilter(
idat: &[u8],
width: usize,
height: usize,
channels: usize,
) -> Option<Vec<u8>> {
let _ = (idat, width, height, channels);
None
}
struct PngChunk<'a> {
chunk_type: [u8; 4],
data: &'a [u8],
}
fn read_chunk(buf: &[u8], offset: usize) -> Option<PngChunk<'_>> {
if offset + 12 > buf.len() {
return None;
}
let len = u32::from_be_bytes(buf[offset..offset + 4].try_into().ok()?) as usize;
let type_bytes: [u8; 4] = buf[offset + 4..offset + 8].try_into().ok()?;
let data_end = offset + 8 + len;
if data_end + 4 > buf.len() {
return None;
}
Some(PngChunk {
chunk_type: type_bytes,
data: &buf[offset + 8..data_end],
})
}
fn find_chunk(buf: &[u8], target: [u8; 4]) -> Option<PngChunk<'_>> {
let mut offset = 8; loop {
let chunk = read_chunk(buf, offset)?;
if chunk.chunk_type == target {
return Some(chunk);
}
let len = u32::from_be_bytes(buf[offset..offset + 4].try_into().ok()?) as usize;
offset = offset.checked_add(12 + len)?;
if offset >= buf.len() {
return None;
}
}
}
#[must_use]
pub fn pixels_to_grid(
pixels: &[u8],
width: usize,
height: usize,
channels: usize,
) -> [f32; GRID_CELLS] {
let mut grid = [0.0_f32; GRID_CELLS];
let mut counts = [0_u32; GRID_CELLS];
let cell_w = width.max(1) / GRID_SIZE;
let cell_h = height.max(1) / GRID_SIZE;
let cell_w = cell_w.max(1);
let cell_h = cell_h.max(1);
let row_stride = width * channels;
for gy in 0..GRID_SIZE {
let y_start = gy * cell_h;
let y_end = ((gy + 1) * cell_h).min(height);
for gx in 0..GRID_SIZE {
let x_start = gx * cell_w;
let x_end = ((gx + 1) * cell_w).min(width);
let cell_idx = gy * GRID_SIZE + gx;
for y in y_start..y_end {
for x in x_start..x_end {
let base = y * row_stride + x * channels;
if base + 2 >= pixels.len() {
continue;
}
let r = f32::from(pixels[base]);
let g = f32::from(pixels[base + 1]);
let b = f32::from(pixels[base + 2]);
grid[cell_idx] += 0.299 * r + 0.587 * g + 0.114 * b;
counts[cell_idx] += 1;
}
}
}
}
#[allow(clippy::cast_precision_loss)]
for i in 0..GRID_CELLS {
if counts[i] > 0 {
grid[i] /= (counts[i] as f32) * 255.0;
}
}
grid
}
#[derive(Debug, Clone)]
pub struct ScreenFingerprint {
hash: u64,
grid: Option<[f32; GRID_CELLS]>,
}
impl ScreenFingerprint {
#[must_use]
pub fn from_png_bytes(bytes: &[u8]) -> Self {
Self {
hash: fnv1a(bytes),
grid: try_extract_png_grid(bytes),
}
}
#[must_use]
pub fn from_raw_pixels(pixels: &[u8], width: usize, height: usize, channels: usize) -> Self {
Self {
hash: fnv1a(pixels),
grid: Some(pixels_to_grid(pixels, width, height, channels)),
}
}
#[must_use]
pub fn hash(&self) -> u64 {
self.hash
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScreenDiff {
pub score: f32,
}
impl ScreenDiff {
#[must_use]
pub fn compare(prev: &ScreenFingerprint, next: &ScreenFingerprint) -> Self {
if prev.hash == next.hash {
return Self { score: 0.0 };
}
match (&prev.grid, &next.grid) {
(Some(a), Some(b)) => Self {
score: perceptual_score(a, b),
},
_ => Self { score: 1.0 },
}
}
#[must_use]
#[inline]
pub fn is_significant(self, threshold: f32) -> bool {
self.score >= threshold
}
}
#[must_use]
fn perceptual_score(a: &[f32; GRID_CELLS], b: &[f32; GRID_CELLS]) -> f32 {
let changed = a
.iter()
.zip(b.iter())
.filter(|(&va, &vb)| (va - vb).abs() > CELL_DELTA_THRESHOLD)
.count();
#[allow(clippy::cast_precision_loss)]
let score = changed as f32 / GRID_CELLS as f32;
score
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fnv1a_empty_slice_returns_offset_basis() {
assert_eq!(fnv1a(b""), FNV_OFFSET);
}
#[test]
fn fnv1a_same_bytes_produce_same_hash() {
assert_eq!(fnv1a(b"hello world"), fnv1a(b"hello world"));
}
#[test]
fn fnv1a_different_bytes_produce_different_hashes() {
assert_ne!(fnv1a(b"frame_a"), fnv1a(b"frame_b"));
}
#[test]
fn fingerprint_from_png_bytes_same_input_same_hash() {
let fp1 = ScreenFingerprint::from_png_bytes(b"some_png_bytes");
let fp2 = ScreenFingerprint::from_png_bytes(b"some_png_bytes");
assert_eq!(fp1.hash(), fp2.hash());
}
#[test]
fn fingerprint_from_png_bytes_different_input_different_hash() {
let fp1 = ScreenFingerprint::from_png_bytes(b"frame_1");
let fp2 = ScreenFingerprint::from_png_bytes(b"frame_2");
assert_ne!(fp1.hash(), fp2.hash());
}
#[test]
fn fingerprint_invalid_png_has_no_grid() {
let fp = ScreenFingerprint::from_png_bytes(b"not_a_png");
assert!(fp.grid.is_none());
}
#[test]
fn fingerprint_from_raw_pixels_always_has_grid() {
let pixels: Vec<u8> = vec![255u8; 4 * 4 * 4];
let fp = ScreenFingerprint::from_raw_pixels(&pixels, 4, 4, 4);
assert!(fp.grid.is_some());
}
#[test]
fn compare_identical_bytes_returns_score_zero() {
let fp = ScreenFingerprint::from_png_bytes(b"unchanged_frame");
let diff = ScreenDiff::compare(&fp, &fp);
assert_eq!(diff.score, 0.0);
}
#[test]
fn compare_different_bytes_no_grid_returns_score_one() {
let fp1 = ScreenFingerprint::from_png_bytes(b"frame_a_x");
let fp2 = ScreenFingerprint::from_png_bytes(b"frame_b_y");
let diff = ScreenDiff::compare(&fp1, &fp2);
assert_eq!(diff.score, 1.0);
}
#[test]
fn compare_identical_raw_pixels_returns_score_zero() {
let pixels: Vec<u8> = vec![128u8; 32 * 32 * 4];
let fp1 = ScreenFingerprint::from_raw_pixels(&pixels, 32, 32, 4);
let fp2 = ScreenFingerprint::from_raw_pixels(&pixels, 32, 32, 4);
let diff = ScreenDiff::compare(&fp1, &fp2);
assert_eq!(diff.score, 0.0);
}
#[test]
fn compare_completely_different_raw_pixels_returns_high_score() {
let black: Vec<u8> = vec![0u8; 32 * 32 * 4];
let white: Vec<u8> = vec![255u8; 32 * 32 * 4];
let fp1 = ScreenFingerprint::from_raw_pixels(&black, 32, 32, 4);
let fp2 = ScreenFingerprint::from_raw_pixels(&white, 32, 32, 4);
let diff = ScreenDiff::compare(&fp1, &fp2);
assert_eq!(diff.score, 1.0);
}
#[test]
fn compare_one_changed_cell_returns_low_score() {
let mut pixels_a: Vec<u8> = vec![128u8; 16 * 16 * 4];
let mut pixels_b = pixels_a.clone();
pixels_b[0] = 0;
pixels_b[1] = 0;
pixels_b[2] = 0;
pixels_a[63 * 4] = 127;
let fp1 = ScreenFingerprint::from_raw_pixels(&pixels_a, 16, 16, 4);
let fp2 = ScreenFingerprint::from_raw_pixels(&pixels_b, 16, 16, 4);
let diff = ScreenDiff::compare(&fp1, &fp2);
assert!(diff.score < 0.1, "score was {}", diff.score);
assert!(diff.score > 0.0, "score should be > 0 for changed frame");
}
#[test]
fn compare_half_changed_cells_returns_approximately_half() {
let pixels_a: Vec<u8> = vec![0u8; 16 * 16 * 4];
let mut pixels_b: Vec<u8> = vec![0u8; 16 * 16 * 4];
for y in 0..16_usize {
for x in 8..16_usize {
let base = (y * 16 + x) * 4;
pixels_b[base] = 255;
pixels_b[base + 1] = 255;
pixels_b[base + 2] = 255;
pixels_b[base + 3] = 255;
}
}
let fp1 = ScreenFingerprint::from_raw_pixels(&pixels_a, 16, 16, 4);
let fp2 = ScreenFingerprint::from_raw_pixels(&pixels_b, 16, 16, 4);
let diff = ScreenDiff::compare(&fp1, &fp2);
assert!(
(0.4..=0.6).contains(&diff.score),
"expected ~0.5, got {}",
diff.score
);
}
#[test]
fn is_significant_below_threshold_returns_false() {
let diff = ScreenDiff { score: 0.03 };
assert!(!diff.is_significant(0.05));
}
#[test]
fn is_significant_at_threshold_returns_true() {
let diff = ScreenDiff { score: 0.05 };
assert!(diff.is_significant(0.05));
}
#[test]
fn is_significant_above_threshold_returns_true() {
let diff = ScreenDiff { score: 0.8 };
assert!(diff.is_significant(0.05));
}
#[test]
fn is_significant_score_zero_threshold_zero_returns_true() {
let diff = ScreenDiff { score: 0.0 };
assert!(diff.is_significant(0.0));
}
#[test]
fn is_significant_score_zero_positive_threshold_returns_false() {
let diff = ScreenDiff { score: 0.0 };
assert!(!diff.is_significant(0.05));
}
#[test]
fn pixels_to_grid_uniform_white_all_cells_one() {
let pixels: Vec<u8> = vec![255u8; 16 * 16 * 3];
let grid = pixels_to_grid(&pixels, 16, 16, 3);
for cell in &grid {
assert!((*cell - 1.0).abs() < 0.01, "expected ~1.0, got {}", cell);
}
}
#[test]
fn pixels_to_grid_uniform_black_all_cells_zero() {
let pixels: Vec<u8> = vec![0u8; 16 * 16 * 3];
let grid = pixels_to_grid(&pixels, 16, 16, 3);
for cell in &grid {
assert_eq!(*cell, 0.0);
}
}
#[test]
fn pixels_to_grid_returns_256_cells() {
let pixels: Vec<u8> = vec![100u8; 32 * 32 * 4];
let grid = pixels_to_grid(&pixels, 32, 32, 4);
assert_eq!(grid.len(), 256);
}
#[test]
fn pixels_to_grid_values_in_zero_to_one() {
let pixels: Vec<u8> = (0..=255u8).cycle().take(32 * 32 * 3).collect();
let grid = pixels_to_grid(&pixels, 32, 32, 3);
for cell in &grid {
assert!((0.0..=1.0).contains(cell), "cell out of range: {}", cell);
}
}
#[test]
fn perceptual_score_identical_grids_returns_zero() {
let grid = [0.5_f32; GRID_CELLS];
assert_eq!(perceptual_score(&grid, &grid), 0.0);
}
#[test]
fn perceptual_score_all_cells_changed_returns_one() {
let a = [0.0_f32; GRID_CELLS];
let b = [1.0_f32; GRID_CELLS];
assert_eq!(perceptual_score(&a, &b), 1.0);
}
#[test]
fn perceptual_score_sub_threshold_changes_return_zero() {
let a = [0.5_f32; GRID_CELLS];
let mut b = [0.5_f32; GRID_CELLS];
for v in b.iter_mut() {
*v += 0.01; }
assert_eq!(perceptual_score(&a, &b), 0.0);
}
#[test]
fn fingerprint_empty_bytes_does_not_panic() {
let fp = ScreenFingerprint::from_png_bytes(b"");
assert_eq!(fp.hash(), FNV_OFFSET);
assert!(fp.grid.is_none());
}
#[test]
fn fingerprint_clone_is_independent() {
let pixels: Vec<u8> = vec![200u8; 16 * 16 * 3];
let fp1 = ScreenFingerprint::from_raw_pixels(&pixels, 16, 16, 3);
let fp2 = fp1.clone();
assert_eq!(fp1.hash(), fp2.hash());
assert_eq!(fp1.grid, fp2.grid);
}
#[test]
fn screen_diff_copy_semantics() {
let d = ScreenDiff { score: 0.42 };
let d2 = d;
assert_eq!(d.score, d2.score);
}
}