use image::{imageops, ImageBuffer, Rgba, RgbaImage};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PxRect {
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
}
pub fn decode_png(bytes: &[u8]) -> Result<RgbaImage, String> {
image::load_from_memory_with_format(bytes, image::ImageFormat::Png)
.map(|i| i.to_rgba8())
.map_err(|e| format!("png decode: {e}"))
}
pub fn encode_png(img: &RgbaImage) -> Result<Vec<u8>, String> {
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png)
.map_err(|e| format!("png encode: {e}"))?;
Ok(out.into_inner())
}
pub fn crop(img: &RgbaImage, rect: PxRect) -> RgbaImage {
let x = rect.x.min(img.width().saturating_sub(1));
let y = rect.y.min(img.height().saturating_sub(1));
let w = rect.w.min(img.width() - x).max(1);
let h = rect.h.min(img.height() - y).max(1);
imageops::crop_imm(img, x, y, w, h).to_image()
}
pub fn scale_to_width(img: &RgbaImage, max_width: u32) -> RgbaImage {
if img.width() <= max_width || max_width == 0 {
return img.clone();
}
let h = (img.height() as f64 * max_width as f64 / img.width() as f64).round().max(1.0) as u32;
imageops::resize(img, max_width, h, imageops::FilterType::Triangle)
}
pub struct Diff {
pub ratio: f64,
pub bbox: Option<PxRect>,
pub mask: RgbaImage,
}
pub fn diff(a: &RgbaImage, b: &RgbaImage, threshold: u8) -> Diff {
let b = if b.dimensions() != a.dimensions() {
imageops::resize(b, a.width(), a.height(), imageops::FilterType::Triangle)
} else {
b.clone()
};
let (w, h) = a.dimensions();
let mut mask: RgbaImage = ImageBuffer::from_pixel(w, h, Rgba([0, 0, 0, 255]));
let mut count = 0u64;
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);
for y in 0..h {
for x in 0..w {
let pa = a.get_pixel(x, y).0;
let pb = b.get_pixel(x, y).0;
let differs = (0..3).any(|c| pa[c].abs_diff(pb[c]) > threshold);
if differs {
count += 1;
mask.put_pixel(x, y, Rgba([255, 0, 255, 255]));
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
}
}
let bbox = (count > 0).then(|| PxRect { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 });
Diff { ratio: count as f64 / (w as f64 * h as f64), bbox, mask }
}
pub fn draw_cursor(img: &mut RgbaImage, x: i64, y: i64, pressed: bool) {
const SHAPE: [&str; 12] = [
"X...........",
"XX..........",
"XOX.........",
"XOOX........",
"XOOOX.......",
"XOOOOX......",
"XOOOOOX.....",
"XOOOOOOX....",
"XOOOOOOOX...",
"XOOOOXXXXX..",
"XOOXOX......",
"XX..XOX.....",
];
let fill = if pressed { Rgba([255, 220, 0, 255]) } else { Rgba([255, 255, 255, 255]) };
for (dy, row) in SHAPE.iter().enumerate() {
for (dx, ch) in row.chars().enumerate() {
let px = x + dx as i64;
let py = y + dy as i64;
if px < 0 || py < 0 || px >= img.width() as i64 || py >= img.height() as i64 {
continue;
}
match ch {
'X' => img.put_pixel(px as u32, py as u32, Rgba([0, 0, 0, 255])),
'O' => img.put_pixel(px as u32, py as u32, fill),
_ => {}
}
}
}
}
pub fn is_flat(img: &RgbaImage) -> bool {
let Some(first) = img.pixels().next() else { return true };
img.pixels().all(|p| p.0[..3] == first.0[..3])
}