cranpose-render-common 0.0.58

Common rendering contracts for Cranpose
Documentation
use cranpose_ui_graphics::Rect;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PixelDifference {
    pub x: u32,
    pub y: u32,
    pub lhs: [u8; 4],
    pub rhs: [u8; 4],
    pub difference: u32,
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ImageDifferenceStats {
    pub differing_pixels: u32,
    pub max_difference: u32,
    pub first_difference: Option<PixelDifference>,
}

pub fn sample_pixel(pixels: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] {
    let idx = ((y * width + x) * 4) as usize;
    [
        pixels[idx],
        pixels[idx + 1],
        pixels[idx + 2],
        pixels[idx + 3],
    ]
}

pub fn pixel_difference(lhs: [u8; 4], rhs: [u8; 4]) -> u32 {
    lhs.into_iter()
        .zip(rhs)
        .map(|(left, right)| left.abs_diff(right) as u32)
        .sum()
}

pub fn normalize_rgba_region(
    pixels: &[u8],
    width: u32,
    height: u32,
    rect: Rect,
    output_width: u32,
    output_height: u32,
) -> Vec<u8> {
    assert!(output_width > 0, "normalized output_width must be positive");
    assert!(
        output_height > 0,
        "normalized output_height must be positive"
    );

    let mut out = Vec::with_capacity((output_width * output_height * 4) as usize);
    for y in 0..output_height {
        for x in 0..output_width {
            let sample_x = rect.x + ((x as f32 + 0.5) * rect.width / output_width as f32);
            let sample_y = rect.y + ((y as f32 + 0.5) * rect.height / output_height as f32);
            out.extend_from_slice(&sample_pixel_bilinear(
                pixels, width, height, sample_x, sample_y,
            ));
        }
    }
    out
}

pub fn image_difference_stats(
    lhs: &[u8],
    rhs: &[u8],
    width: u32,
    height: u32,
    tolerance: u32,
) -> ImageDifferenceStats {
    assert_eq!(
        lhs.len(),
        rhs.len(),
        "image_difference_stats requires equal buffer lengths"
    );

    let mut differing_pixels = 0;
    let mut max_difference = 0;
    let mut first_difference = None;
    for y in 0..height {
        for x in 0..width {
            let index = ((y * width + x) * 4) as usize;
            let lhs_pixel = [lhs[index], lhs[index + 1], lhs[index + 2], lhs[index + 3]];
            let rhs_pixel = [rhs[index], rhs[index + 1], rhs[index + 2], rhs[index + 3]];
            let difference = pixel_difference(lhs_pixel, rhs_pixel);
            if difference > tolerance {
                differing_pixels += 1;
                max_difference = max_difference.max(difference);
                if first_difference.is_none() {
                    first_difference = Some(PixelDifference {
                        x,
                        y,
                        lhs: lhs_pixel,
                        rhs: rhs_pixel,
                        difference,
                    });
                }
            }
        }
    }

    ImageDifferenceStats {
        differing_pixels,
        max_difference,
        first_difference,
    }
}

fn sample_pixel_bilinear(pixels: &[u8], width: u32, height: u32, x: f32, y: f32) -> [u8; 4] {
    let max_x = width.saturating_sub(1) as f32;
    let max_y = height.saturating_sub(1) as f32;
    let source_x = (x - 0.5).clamp(0.0, max_x);
    let source_y = (y - 0.5).clamp(0.0, max_y);
    let x0 = source_x.floor() as u32;
    let y0 = source_y.floor() as u32;
    let x1 = (x0 + 1).min(width.saturating_sub(1));
    let y1 = (y0 + 1).min(height.saturating_sub(1));
    let tx = source_x - x0 as f32;
    let ty = source_y - y0 as f32;
    let top_left = sample_pixel(pixels, width, x0, y0);
    let top_right = sample_pixel(pixels, width, x1, y0);
    let bottom_left = sample_pixel(pixels, width, x0, y1);
    let bottom_right = sample_pixel(pixels, width, x1, y1);

    let lerp_channel = |index: usize| {
        let top = top_left[index] as f32 * (1.0 - tx) + top_right[index] as f32 * tx;
        let bottom = bottom_left[index] as f32 * (1.0 - tx) + bottom_right[index] as f32 * tx;
        (top * (1.0 - ty) + bottom * ty).round() as u8
    };

    [
        lerp_channel(0),
        lerp_channel(1),
        lerp_channel(2),
        lerp_channel(3),
    ]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalize_rgba_region_preserves_pixel_grid_at_native_size() {
        let pixels = vec![
            10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 100, 110, 120, 255,
        ];
        let normalized = normalize_rgba_region(
            &pixels,
            2,
            2,
            Rect {
                x: 0.0,
                y: 0.0,
                width: 2.0,
                height: 2.0,
            },
            2,
            2,
        );
        assert_eq!(normalized, pixels);
    }

    #[test]
    fn image_difference_stats_reports_first_difference() {
        let lhs = vec![0, 0, 0, 255, 8, 8, 8, 255];
        let rhs = vec![0, 0, 0, 255, 18, 12, 10, 255];
        let stats = image_difference_stats(&lhs, &rhs, 2, 1, 3);

        assert_eq!(stats.differing_pixels, 1);
        assert_eq!(stats.max_difference, 16);
        assert_eq!(
            stats.first_difference,
            Some(PixelDifference {
                x: 1,
                y: 0,
                lhs: [8, 8, 8, 255],
                rhs: [18, 12, 10, 255],
                difference: 16,
            })
        );
    }
}