Media

Image diff

Two screenshots compared pixel by pixel: how many changed, where, and a picture with the changes in red, for visual regression tests.

Version 1.0.0 · 3 functions · 1 crate

#images#testing#screenshots

Functions

What use image_diff.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use image_diff, image_diff_save from image_diff (Quillo 0.6).

image_diff(a: str, b: str, tolerance: int) → anyor an error

Compares the pictures a and b: { changed, total, percent, same_size, box }, changed the pixels that differ by more than tolerance (0 to 255) in any channel, percent of all, box the rectangle { x, y, w, h } holding every change (null when none). Pictures of different sizes are compared over the larger; the rest counts as changed.

image_diff_save(a: str, b: str, out: str, tolerance: int) → anyor an error

The same, and a picture of the differences saved at out (.png is best): the first picture faded to grey, the changed pixels in red.

image_diff_same(a: str, b: str, max_percent: float) → boolor an error

Whether the pictures look the same: at most max_percent of their pixels changed (tolerance 16), and the same size. For a test that fails on a real change only.

Get it

$ quillo install image_diff               # image_diff.qwer into this folder$ quillo install image_diff --example     # and its example, image_diff.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/image_diff.qwer$ curl -O https://quillolang.org/plugins/files/image_diff.qwe$ curl -O https://quillolang.org/plugins/files/image_diff_sample.png$ curl -O https://quillolang.org/plugins/files/image_diff_sample2.png

Then, in your program, all of it or only what you call:

main.qwe
use image_diff.qweruse image_diff, image_diff_save from image_diff      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }

Example

image_diff.qwe
// A visual regression check: today's screenshot of the order page against yesterday's.////   quillo run image_diff.qwe      then open http://localhost:8080/checkuse "image_diff.qwer"route GET /check = {    diff: image_diff_save("image_diff_sample.png", "image_diff_sample2.png", "data/diff/order-page.png", 8)?,    passes: image_diff_same("image_diff_sample.png", "image_diff_sample2.png", 0.5)?,    unchanged: image_diff("image_diff_sample.png", "image_diff_sample.png", 0)?,}route GET /diff.png {    image_diff_save("image_diff_sample.png", "image_diff_sample2.png", "data/diff/order-page.png", 8)?    file("data/diff/order-page.png")}
$ quillo run image_diff.qwe$ curl 'localhost:8080/check'

Source

image_diff.qwer
//! Two screenshots compared pixel by pixel: how many changed, where, and a picture with the changes in red, for visual regression tests and "what changed?" checks.//! version: 1.0.0//!//! [dependencies]//! image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }use image::{DynamicImage, ImageDecoder, Rgba, RgbaImage};use serde_json::{json, Value};fn reader(path: &str) -> Result<image::ImageReader<std::io::BufReader<std::fs::File>>, String> {    let mut r = image::ImageReader::open(path).map_err(|e| format!("{}: {}", path, e))?.with_guessed_format().map_err(|e| format!("{}: {}", path, e))?;    // A small file claiming to be enormous is refused before anything is allocated.    let mut l = image::Limits::default();    l.max_image_width = Some(12_000);    l.max_image_height = Some(12_000);    l.max_alloc = Some(512 * 1024 * 1024);    r.limits(l);    Ok(r)}// The picture turned upright by its EXIF orientation, as phones expect.fn open(path: &str) -> Result<DynamicImage, String> {    let mut dec = reader(path)?.into_decoder().map_err(|e| format!("{}: {}", path, e))?;    let o = dec.orientation().unwrap_or(image::metadata::Orientation::NoTransforms);    let mut img = DynamicImage::from_decoder(dec).map_err(|e| format!("{}: {}", path, e))?;    img.apply_orientation(o);    Ok(img)}// Saved in the format its extension names: .jpg (quality 88), .png, .webp (lossless) or .gif; folders are made.fn save(img: &DynamicImage, path: &str) -> Result<(), String> {    if let Some(dir) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {}", dir.display(), e))?;    }    let ext = path.rsplit_once('.').map(|(_, e)| e.to_lowercase()).unwrap_or_default();    let err = |e: image::ImageError| format!("{}: {}", path, e);    match ext.as_str() {        "jpg" | "jpeg" => {            let f = std::fs::File::create(path).map_err(|e| format!("{}: {}", path, e))?;            image::codecs::jpeg::JpegEncoder::new_with_quality(std::io::BufWriter::new(f), 88).encode_image(&img.to_rgb8()).map_err(err)        }        "png" => img.save_with_format(path, image::ImageFormat::Png).map_err(err),        "webp" => DynamicImage::ImageRgba8(img.to_rgba8()).save_with_format(path, image::ImageFormat::WebP).map_err(err),        "gif" => DynamicImage::ImageRgba8(img.to_rgba8()).save_with_format(path, image::ImageFormat::Gif).map_err(err),        _ => Err(format!("{}: save as .jpg, .png, .webp or .gif", path)),    }}// Pixels count as changed when a channel differs by more than `tolerance` (0 to 255): JPEG noise stays below 10 or so.fn compare(a: &str, b: &str, tolerance: i64) -> Result<(RgbaImage, RgbaImage, Vec<bool>, u32, u32), String> {    let (ia, ib) = (open(a)?.to_rgba8(), open(b)?.to_rgba8());    let (w, h) = (ia.width().max(ib.width()), ia.height().max(ib.height()));    let t = tolerance.clamp(0, 255) as i32;    let mut changed = vec![false; (w * h) as usize];    for y in 0..h {        for x in 0..w {            let pa = (x < ia.width() && y < ia.height()).then(|| ia.get_pixel(x, y));            let pb = (x < ib.width() && y < ib.height()).then(|| ib.get_pixel(x, y));            changed[(y * w + x) as usize] = match (pa, pb) {                (Some(p), Some(q)) => (0..4).any(|c| (p.0[c] as i32 - q.0[c] as i32).abs() > t),                _ => true,            };        }    }    Ok((ia, ib, changed, w, h))}fn summary(changed: &[bool], w: u32, h: u32, same_size: bool) -> Value {    let n = changed.iter().filter(|c| **c).count();    let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);    for (i, c) in changed.iter().enumerate() {        if *c {            let (x, y) = (i as u32 % w, i as u32 / w);            x0 = x0.min(x);            y0 = y0.min(y);            x1 = x1.max(x);            y1 = y1.max(y);        }    }    let total = (w as u64 * h as u64).max(1);    json!({        "changed": n,        "total": total,        "percent": (n as f64 / total as f64 * 10000.0).round() / 100.0,        "same_size": same_size,        "box": if n == 0 { Value::Null } else { json!({ "x": x0, "y": y0, "w": x1 - x0 + 1, "h": y1 - y0 + 1 }) },    })}/// Compares the pictures `a` and `b`: `{ changed, total, percent, same_size, box }`, `changed` the pixels that differ by more than `tolerance` (0 to 255) in any channel,/// `percent` of all, `box` the rectangle `{ x, y, w, h }` holding every change (null when none). Pictures of different sizes are compared over the larger; the rest counts as changed.pub fn image_diff(a: &str, b: &str, tolerance: i64) -> Result<Value, String> {    let (ia, ib, changed, w, h) = compare(a, b, tolerance)?;    Ok(summary(&changed, w, h, ia.dimensions() == ib.dimensions()))}/// The same, and a picture of the differences saved at `out` (.png is best): the first picture faded to grey, the changed pixels in red.pub fn image_diff_save(a: &str, b: &str, out: &str, tolerance: i64) -> Result<Value, String> {    let (ia, ib, changed, w, h) = compare(a, b, tolerance)?;    let img = RgbaImage::from_fn(w, h, |x, y| {        if changed[(y * w + x) as usize] {            return Rgba([230, 30, 40, 255]);        }        let p = ia.get_pixel(x, y);        let l = (0.299 * p.0[0] as f32 + 0.587 * p.0[1] as f32 + 0.114 * p.0[2] as f32) as u8;        let f = (l as f32 * 0.35 + 255.0 * 0.65) as u8;        Rgba([f, f, f, 255])    });    save(&DynamicImage::ImageRgba8(img), out)?;    Ok(summary(&changed, w, h, ia.dimensions() == ib.dimensions()))}/// Whether the pictures look the same: at most `max_percent` of their pixels changed (tolerance 16), and the same size. For a test that fails on a real change only.pub fn image_diff_same(a: &str, b: &str, max_percent: f64) -> Result<bool, String> {    let s = image_diff(a, b, 16)?;    Ok(s["same_size"] == true && s["percent"].as_f64().unwrap_or(100.0) <= max_percent)}
Thread
76Useful? Like it, or add to its thread below.

Thread 0 posts

On Image diff: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.