use image::{Rgba, RgbaImage};
use crate::backend::BlobPair;
const CHECK_SIZE: u32 = 8;
const CHECK_LIGHT: [u8; 3] = [0xFF, 0xFF, 0xFF];
const CHECK_DARK: [u8; 3] = [0xCC, 0xCC, 0xCC];
const TWO_UP_SEP: u32 = 4;
const SEP_COLOR: Rgba<u8> = Rgba([0x80, 0x80, 0x80, 0xFF]);
const SWIPE_DIVIDER: Rgba<u8> = Rgba([0xFF, 0xDC, 0x00, 0xFF]);
const IMAGE_EXTENSIONS: &[&str] = &[
"png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "tif", "ico", "qoi", "tga", "pnm", "ppm",
"pgm", "pbm",
];
pub fn is_image_path(path: &str) -> bool {
path.rsplit('.')
.next()
.filter(|_| path.contains('.'))
.map(|ext| ext.to_ascii_lowercase())
.is_some_and(|ext| IMAGE_EXTENSIONS.contains(&ext.as_str()))
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CompareMode {
TwoUp,
Swipe,
Onion,
Difference,
Left,
Right,
}
impl CompareMode {
pub const ALL: [CompareMode; 6] = [
CompareMode::TwoUp,
CompareMode::Swipe,
CompareMode::Onion,
CompareMode::Difference,
CompareMode::Left,
CompareMode::Right,
];
pub fn label(self) -> &'static str {
match self {
CompareMode::TwoUp => "2-Up",
CompareMode::Swipe => "Swipe",
CompareMode::Onion => "Onion",
CompareMode::Difference => "Diff",
CompareMode::Left => "Left",
CompareMode::Right => "Right",
}
}
pub fn next(self) -> Self {
match self {
CompareMode::TwoUp => CompareMode::Swipe,
CompareMode::Swipe => CompareMode::Onion,
CompareMode::Onion => CompareMode::Difference,
CompareMode::Difference => CompareMode::TwoUp,
CompareMode::Left | CompareMode::Right => CompareMode::TwoUp,
}
}
pub fn uses_slider(self) -> bool {
matches!(self, CompareMode::Swipe | CompareMode::Onion)
}
pub fn is_single(self) -> bool {
matches!(self, CompareMode::Left | CompareMode::Right)
}
}
pub struct Canvas {
pub w: u32,
pub h: u32,
pub argb: Vec<u32>,
}
impl Canvas {
fn empty() -> Self {
Canvas {
w: 0,
h: 0,
argb: Vec::new(),
}
}
}
const SLIDER_STEPS: u32 = 1000;
struct RenderCache {
mode: CompareMode,
slider_key: u32,
box_w: u32,
box_h: u32,
canvas: Canvas,
}
struct FitCache {
box_w: u32,
box_h: u32,
norm_old: RgbaImage,
norm_new: RgbaImage,
diff: RgbaImage,
}
pub struct ImageComparison {
old: Option<RgbaImage>,
new: Option<RgbaImage>,
meta: String,
cache: Option<FitCache>,
render_cache: Option<RenderCache>,
}
impl ImageComparison {
pub fn from_blobs(blobs: &BlobPair) -> Option<Self> {
let old = blobs.old.as_deref().and_then(decode);
let new = blobs.new.as_deref().and_then(decode);
if old.is_none() && new.is_none() {
return None;
}
let meta = meta_line(blobs, old.as_ref(), new.as_ref());
Some(ImageComparison {
old,
new,
meta,
cache: None,
render_cache: None,
})
}
pub fn meta(&self) -> &str {
&self.meta
}
pub fn render(&mut self, mode: CompareMode, slider: f32, box_w: u32, box_h: u32) -> &Canvas {
let slider_key = if mode.uses_slider() {
(slider.clamp(0.0, 1.0) * SLIDER_STEPS as f32).round() as u32
} else {
0
};
let hit = self.render_cache.as_ref().is_some_and(|c| {
c.mode == mode && c.slider_key == slider_key && c.box_w == box_w && c.box_h == box_h
});
if !hit {
let canvas = self.compose(mode, slider, box_w, box_h);
self.render_cache = Some(RenderCache {
mode,
slider_key,
box_w,
box_h,
canvas,
});
}
&self
.render_cache
.as_ref()
.expect("cache just populated")
.canvas
}
fn compose(&mut self, mode: CompareMode, slider: f32, box_w: u32, box_h: u32) -> Canvas {
if box_w == 0 || box_h == 0 {
return Canvas::empty();
}
let composed = match mode {
CompareMode::TwoUp => self.two_up(box_w, box_h),
CompareMode::Left => single(self.old.as_ref(), box_w, box_h),
CompareMode::Right => single(self.new.as_ref(), box_w, box_h),
CompareMode::Swipe | CompareMode::Onion | CompareMode::Difference => {
self.ensure_cache(box_w, box_h);
let c = self.cache.as_ref().expect("cache just built");
match mode {
CompareMode::Swipe => compose_swipe(&c.norm_old, &c.norm_new, slider),
CompareMode::Onion => compose_onion(&c.norm_old, &c.norm_new, slider),
CompareMode::Difference => c.diff.clone(),
_ => unreachable!(),
}
}
};
flatten_to_canvas(&composed)
}
fn two_up(&self, box_w: u32, box_h: u32) -> RgbaImage {
let half = box_w.saturating_sub(TWO_UP_SEP) / 2;
let left = single(self.old.as_ref(), half.max(1), box_h);
let right = single(self.new.as_ref(), half.max(1), box_h);
let h = left.height().max(right.height());
let w = left.width() + TWO_UP_SEP + right.width();
let mut canvas = RgbaImage::new(w, h);
for y in 0..h {
for x in left.width()..(left.width() + TWO_UP_SEP) {
canvas.put_pixel(x, y, SEP_COLOR);
}
}
let lw = left.width();
image::imageops::overlay(&mut canvas, &left, 0, 0);
image::imageops::overlay(&mut canvas, &right, (lw + TWO_UP_SEP) as i64, 0);
canvas
}
fn ensure_cache(&mut self, box_w: u32, box_h: u32) {
if self
.cache
.as_ref()
.is_some_and(|c| c.box_w == box_w && c.box_h == box_h)
{
return;
}
let (na, nb) = normalize_pair(self.old.as_ref(), self.new.as_ref());
let (norm_old, norm_new) = scale_pair(&na, &nb, box_w, box_h);
let diff = scale_fit(
&build_diff_heatmap(self.old.as_ref(), self.new.as_ref()),
box_w,
box_h,
);
self.cache = Some(FitCache {
box_w,
box_h,
norm_old,
norm_new,
diff,
});
}
}
fn decode(bytes: &[u8]) -> Option<RgbaImage> {
image::load_from_memory(bytes)
.ok()
.map(|img| img.to_rgba8())
}
fn scale_fit(img: &RgbaImage, max_w: u32, max_h: u32) -> RgbaImage {
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 || (w <= max_w && h <= max_h) {
return img.clone();
}
let scale = (max_w as f64 / w as f64).min(max_h as f64 / h as f64);
let nw = ((w as f64 * scale) as u32).max(1);
let nh = ((h as f64 * scale) as u32).max(1);
image::imageops::resize(img, nw, nh, image::imageops::FilterType::Triangle)
}
fn single(img: Option<&RgbaImage>, box_w: u32, box_h: u32) -> RgbaImage {
match img {
Some(img) => scale_fit(img, box_w, box_h),
None => RgbaImage::new(box_w.max(1), box_h.max(1)),
}
}
fn normalize_pair(a: Option<&RgbaImage>, b: Option<&RgbaImage>) -> (RgbaImage, RgbaImage) {
let w = a
.map_or(0, |i| i.width())
.max(b.map_or(0, |i| i.width()))
.max(1);
let h = a
.map_or(0, |i| i.height())
.max(b.map_or(0, |i| i.height()))
.max(1);
let mut out_a = RgbaImage::new(w, h);
let mut out_b = RgbaImage::new(w, h);
if let Some(a) = a {
image::imageops::overlay(&mut out_a, a, 0, 0);
}
if let Some(b) = b {
image::imageops::overlay(&mut out_b, b, 0, 0);
}
(out_a, out_b)
}
fn scale_pair(a: &RgbaImage, b: &RgbaImage, max_w: u32, max_h: u32) -> (RgbaImage, RgbaImage) {
let (w, h) = (a.width(), a.height());
if w == 0 || h == 0 || (w <= max_w && h <= max_h) {
return (a.clone(), b.clone());
}
let scale = (max_w as f64 / w as f64).min(max_h as f64 / h as f64);
let nw = ((w as f64 * scale) as u32).max(1);
let nh = ((h as f64 * scale) as u32).max(1);
let f = image::imageops::FilterType::Triangle;
(
image::imageops::resize(a, nw, nh, f),
image::imageops::resize(b, nw, nh, f),
)
}
fn compose_swipe(a: &RgbaImage, b: &RgbaImage, t: f32) -> RgbaImage {
let (w, h) = a.dimensions();
let split = ((w as f32) * t.clamp(0.0, 1.0)) as u32;
let mut out = RgbaImage::new(w, h);
for y in 0..h {
for x in 0..w {
let px = if x < split { a } else { b }.get_pixel(x, y);
out.put_pixel(x, y, *px);
}
if split < w {
out.put_pixel(split, y, SWIPE_DIVIDER);
}
}
out
}
fn compose_onion(a: &RgbaImage, b: &RgbaImage, t: f32) -> RgbaImage {
let t = (t.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
let inv = 255 - t;
let (w, h) = a.dimensions();
let ra = a.as_raw();
let rb = b.as_raw();
let mut out = vec![0u8; ra.len()];
for i in 0..ra.len() {
out[i] = ((ra[i] as u32 * inv + rb[i] as u32 * t + 127) / 255) as u8;
}
RgbaImage::from_raw(w, h, out).expect("onion buffer matches dimensions")
}
fn build_diff_heatmap(a: Option<&RgbaImage>, b: Option<&RgbaImage>) -> RgbaImage {
let aw = a.map_or(0, |i| i.width());
let ah = a.map_or(0, |i| i.height());
let bw = b.map_or(0, |i| i.width());
let bh = b.map_or(0, |i| i.height());
let w = aw.max(bw).max(1);
let h = ah.max(bh).max(1);
let mut out = RgbaImage::from_pixel(w, h, Rgba([0xFF, 0x00, 0xFF, 0xFF]));
let (Some(a), Some(b)) = (a, b) else {
return out;
};
let common_w = aw.min(bw);
let common_h = ah.min(bh);
for y in 0..common_h {
for x in 0..common_w {
let pa = a.get_pixel(x, y).0;
let pb = b.get_pixel(x, y).0;
let dr = (pa[0] as i16 - pb[0] as i16).unsigned_abs() as u32;
let dg = (pa[1] as i16 - pb[1] as i16).unsigned_abs() as u32;
let db = (pa[2] as i16 - pb[2] as i16).unsigned_abs() as u32;
let diff = (77 * dr + 151 * dg + 28 * db) as f32 / (255.0 * 256.0);
out.put_pixel(x, y, heatmap_color(diff));
}
}
out
}
fn heatmap_color(t: f32) -> Rgba<u8> {
let t = t.clamp(0.0, 1.0);
let (r, g, b) = if t < 0.25 {
(0.0, 0.0, t / 0.25)
} else if t < 0.5 {
let s = (t - 0.25) / 0.25;
(0.0, s, 1.0 - s)
} else if t < 0.75 {
let s = (t - 0.5) / 0.25;
(s, 1.0, 0.0)
} else {
let s = (t - 0.75) / 0.25;
(1.0, 1.0 - s, 0.0)
};
Rgba([(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255])
}
fn flatten_to_canvas(img: &RgbaImage) -> Canvas {
let (w, h) = img.dimensions();
let mut argb = vec![0u32; (w * h) as usize];
for y in 0..h {
for x in 0..w {
let px = img.get_pixel(x, y).0;
let a = px[3] as u32;
let bg = checker(x, y);
let blend = |s: u8, d: u8| (s as u32 * a + d as u32 * (255 - a)) / 255;
let r = blend(px[0], bg[0]);
let g = blend(px[1], bg[1]);
let b = blend(px[2], bg[2]);
argb[(y * w + x) as usize] = 0xFF00_0000 | (r << 16) | (g << 8) | b;
}
}
Canvas { w, h, argb }
}
fn checker(x: u32, y: u32) -> [u8; 3] {
if ((x / CHECK_SIZE) + (y / CHECK_SIZE)).is_multiple_of(2) {
CHECK_LIGHT
} else {
CHECK_DARK
}
}
fn meta_line(blobs: &BlobPair, old: Option<&RgbaImage>, new: Option<&RgbaImage>) -> String {
let fmt = pair_str(
blobs.old.as_deref().and_then(format_name),
blobs.new.as_deref().and_then(format_name),
);
let dims = pair_str(
old.map(|i| format!("{}x{}", i.width(), i.height())),
new.map(|i| format!("{}x{}", i.width(), i.height())),
);
let size = pair_str(
blobs.old.as_ref().map(|b| human_size(b.len())),
blobs.new.as_ref().map(|b| human_size(b.len())),
);
[fmt, dims, size]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ")
}
fn pair_str(a: Option<String>, b: Option<String>) -> Option<String> {
match (a, b) {
(Some(a), Some(b)) if a == b => Some(a),
(Some(a), Some(b)) => Some(format!("{a}→{b}")),
(Some(a), None) | (None, Some(a)) => Some(a),
(None, None) => None,
}
}
fn format_name(bytes: &[u8]) -> Option<String> {
use image::ImageFormat as F;
let name = match image::guess_format(bytes).ok()? {
F::Png => "PNG",
F::Jpeg => "JPEG",
F::Gif => "GIF",
F::WebP => "WebP",
F::Bmp => "BMP",
F::Tiff => "TIFF",
F::Ico => "ICO",
F::Pnm => "PNM",
F::Tga => "TGA",
F::Qoi => "QOI",
_ => return None,
};
Some(name.to_string())
}
fn human_size(bytes: usize) -> String {
const KIB: f64 = 1024.0;
const MIB: f64 = KIB * 1024.0;
let b = bytes as f64;
if b >= MIB {
format!("{:.1}MiB", b / MIB)
} else if b >= KIB {
format!("{:.1}KiB", b / KIB)
} else {
format!("{bytes}B")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn png(w: u32, h: u32, color: [u8; 4]) -> Vec<u8> {
let img = RgbaImage::from_pixel(w, h, Rgba(color));
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(img)
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.unwrap();
bytes
}
#[test]
fn recognizes_image_extensions() {
assert!(is_image_path("a/b/logo.png"));
assert!(is_image_path("ICON.PNG"));
assert!(is_image_path("photo.jpeg"));
assert!(!is_image_path("src/main.rs"));
assert!(!is_image_path("drawing.svg")); assert!(!is_image_path("Makefile"));
}
#[test]
fn from_blobs_none_when_undecodable() {
let blobs = BlobPair {
old: Some(b"not an image".to_vec()),
new: Some(b"still not".to_vec()),
};
assert!(ImageComparison::from_blobs(&blobs).is_none());
}
#[test]
fn renders_every_mode_to_opaque_canvas() {
let blobs = BlobPair {
old: Some(png(8, 8, [255, 0, 0, 255])),
new: Some(png(8, 8, [0, 0, 255, 255])),
};
let mut cmp = ImageComparison::from_blobs(&blobs).expect("decodes");
for mode in CompareMode::ALL {
let canvas = cmp.render(mode, 0.5, 64, 64);
assert!(canvas.w > 0 && canvas.h > 0, "{mode:?} produced no canvas");
assert_eq!(canvas.argb.len(), (canvas.w * canvas.h) as usize);
assert!(
canvas.argb.iter().all(|p| p >> 24 == 0xFF),
"{mode:?} left transparent pixels"
);
}
}
#[test]
fn difference_of_identical_images_is_black() {
let bytes = png(8, 8, [10, 200, 30, 255]);
let blobs = BlobPair {
old: Some(bytes.clone()),
new: Some(bytes),
};
let mut cmp = ImageComparison::from_blobs(&blobs).expect("decodes");
let canvas = cmp.render(CompareMode::Difference, 0.0, 32, 32);
assert!(canvas.argb.iter().all(|&p| p == 0xFF00_0000));
}
#[test]
fn render_reuses_the_cached_canvas_until_the_key_changes() {
let blobs = BlobPair {
old: Some(png(8, 8, [255, 0, 0, 255])),
new: Some(png(8, 8, [0, 0, 255, 255])),
};
let mut cmp = ImageComparison::from_blobs(&blobs).expect("decodes");
let ptr = |c: &mut ImageComparison, m, s, w, h| c.render(m, s, w, h).argb.as_ptr();
let a = ptr(&mut cmp, CompareMode::TwoUp, 0.5, 64, 64);
assert_eq!(
a,
ptr(&mut cmp, CompareMode::TwoUp, 0.5, 64, 64),
"same key reuses the cached canvas"
);
assert_eq!(
a,
ptr(&mut cmp, CompareMode::TwoUp, 0.9, 64, 64),
"the slider is irrelevant in 2-up, so the cache stays warm"
);
let b = ptr(&mut cmp, CompareMode::TwoUp, 0.5, 80, 64);
assert_ne!(a, b, "a different box size recomposes");
let c = ptr(&mut cmp, CompareMode::Swipe, 0.5, 80, 64);
assert_ne!(b, c, "a different mode recomposes");
assert_eq!(
c,
ptr(&mut cmp, CompareMode::Swipe, 0.5, 80, 64),
"a slider mode caches at a fixed slider position"
);
assert_ne!(
c,
ptr(&mut cmp, CompareMode::Swipe, 0.95, 80, 64),
"moving the slider in a slider mode recomposes"
);
}
#[test]
fn added_image_has_one_side_and_collapsed_meta() {
let blobs = BlobPair {
old: None,
new: Some(png(16, 16, [0, 0, 0, 255])),
};
let mut cmp = ImageComparison::from_blobs(&blobs).expect("decodes");
assert!(cmp.meta().contains("16x16"));
let left = cmp.render(CompareMode::Left, 0.0, 32, 32);
assert!(left.w > 0 && left.h > 0);
}
}