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
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 errorCompares 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 errorThe 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 errorWhether 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.qweOr 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.pngThen, in your program, all of it or only what you call:
use image_diff.qweruse image_diff, image_diff_save from image_diff // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }Example
// 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
//! 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)}Related
gif.qwer · 1.0.0Animated GIFs made from a list of pictures with the delay and size you choose, and GIFs read back: frames, size and length.6 views · 7 downloadsASCII art ascii_art.qwer · 1.0.0Pictures turned into ASCII art: characters from light to dark, plain for terminals and emails, or coloured as HTML.6 views · 10 downloadsBarcodes barcode.qwer · 1.0.0EAN-13, EAN-8, UPC-A, Code 128 and Code 39 barcodes as SVG or PNG, with the digits underneath and check digits worked out.6 views · 5 downloadsBlurHash blurhash.qwer · 1.0.0A photo as a short BlurHash, shown blurred while the real one loads, and back as a small PNG data URL.7 views · 6 downloadsCaptions on images caption.qwer · 1.0.0Text drawn on pictures with a built-in font: captions at the top, middle or bottom, wrapped to fit, with an outline or a band behind them, meme-style top and bottom lines, and text measured before drawing.7 views · 7 downloadsCollage collage.qwer · 1.0.0Several pictures made into one: a grid of equal cells with gaps, or justified rows that keep each picture's shape, on your background.7 views · 7 downloadsThread 0 posts
On Image diff: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.