use oxideav_core::{Error, PixelFormat, VideoFrame, VideoPlane};
use oxideav_image_filter::{
BrightnessContrast, Gamma, Grayscale, ImageFilter, Level, Modulate, Normalize, Posterize,
Resize, Sepia, Sharpen, Solarize, Threshold, Trim, Unsharp, VideoStreamParams,
};
use crate::op::Op;
use crate::raster_io::RgbaImage;
fn bpp(img: &RgbaImage) -> usize {
let w = img.width as usize;
if w == 0 {
return 4;
}
img.stride / w
}
pub fn apply_pixel_transform_chain(mut img: RgbaImage, ops: &[Op]) -> Result<RgbaImage, String> {
for op in ops {
match op {
Op::Rotate { degrees } => {
img = rotate(img, *degrees);
}
Op::Flip => {
flip(&mut img);
}
Op::Flop => {
flop(&mut img);
}
Op::Crop { x, y, w, h } => {
img = crop(img, *x, *y, *w, *h)?;
}
Op::Extent {
width,
height,
x,
y,
bg,
} => {
img = extent(img, *width, *height, *x, *y, *bg)?;
}
Op::Roll { dx, dy } => {
roll(&mut img, *dx, *dy);
}
Op::Negate => {
negate(&mut img);
}
Op::Sharpen { radius, sigma } => {
img = run_image_filter(img, &Sharpen::new(*radius, *sigma))?;
}
Op::Unsharp {
radius,
sigma,
amount,
threshold,
} => {
img = run_image_filter(img, &Unsharp::new(*radius, *sigma, *amount, *threshold))?;
}
Op::Gamma { value } => {
img = run_image_filter(img, &Gamma::new(*value))?;
}
Op::BrightnessContrast {
brightness,
contrast,
} => {
img = run_image_filter(img, &BrightnessContrast::new(*brightness, *contrast))?;
}
Op::Contrast { delta } => {
let pct = (*delta as f32) * 5.0;
img = run_image_filter(img, &BrightnessContrast::new(0.0, pct))?;
}
Op::Sepia { threshold } => {
img = run_image_filter(img, &Sepia::new(*threshold))?;
}
Op::Modulate {
brightness,
saturation,
hue,
} => {
let hue_degrees = (hue - 100.0) * 1.8;
img = run_image_filter(img, &Modulate::new(*brightness, *saturation, hue_degrees))?;
}
Op::Level {
black,
gamma,
white,
} => {
img = run_image_filter(img, &Level::new(*black, *white, *gamma))?;
}
Op::Normalize => {
img = run_image_filter(img, &Normalize::new(0.0, 0.0))?;
}
Op::Threshold { value } => {
img = run_image_filter(img, &Threshold::new(*value))?;
}
Op::Posterize { levels } => {
img = run_image_filter(img, &Posterize::new(*levels))?;
}
Op::Solarize { value } => {
img = run_image_filter(img, &Solarize::new(*value))?;
}
Op::Colorspace(cs) => {
let lower = cs.to_ascii_lowercase();
if lower == "gray" || lower == "grey" {
img = run_image_filter(
img,
&Grayscale::new()
.with_preserve_alpha(true)
.with_output_gray8(false),
)?;
}
}
Op::Resize {
width,
height,
mode,
} => {
let (out_w, out_h) = mode.resolve(*width, *height, img.width, img.height);
if out_w == img.width && out_h == img.height {
continue;
}
img = run_image_filter_resize(img, &Resize::new(out_w, out_h), out_w, out_h)?;
}
Op::Thumbnail {
width,
height,
mode,
} => {
let (out_w, out_h) = mode.resolve(*width, *height, img.width, img.height);
if !(out_w == img.width && out_h == img.height) {
img = run_image_filter_resize(img, &Resize::new(out_w, out_h), out_w, out_h)?;
}
}
Op::Trim { fuzz } => {
img = run_image_filter_reshape(img, &Trim::new().with_fuzz(*fuzz))?;
}
Op::Define { .. } => {}
_ => {}
}
}
Ok(img)
}
fn run_image_filter(img: RgbaImage, filter: &dyn ImageFilter) -> Result<RgbaImage, String> {
run_image_filter_inner(img, filter, None)
}
fn run_image_filter_resize(
img: RgbaImage,
filter: &dyn ImageFilter,
out_w: u32,
out_h: u32,
) -> Result<RgbaImage, String> {
run_image_filter_inner(img, filter, Some((out_w, out_h)))
}
fn run_image_filter_inner(
img: RgbaImage,
filter: &dyn ImageFilter,
out_dims: Option<(u32, u32)>,
) -> Result<RgbaImage, String> {
let format = if img.is_rgb() {
PixelFormat::Rgb24
} else {
PixelFormat::Rgba
};
let in_w = img.width;
let in_h = img.height;
let frame = VideoFrame {
pts: None,
planes: vec![VideoPlane {
stride: img.stride,
data: img.pixels,
}],
};
let params = VideoStreamParams {
format,
width: in_w,
height: in_h,
};
let out = filter
.apply(&frame, params)
.map_err(|e: Error| format!("{e:?}"))?;
let plane = out
.planes
.into_iter()
.next()
.ok_or_else(|| "image-filter returned no planes".to_string())?;
let (w, h) = out_dims.unwrap_or((in_w, in_h));
Ok(RgbaImage {
width: w,
height: h,
pixels: plane.data,
stride: plane.stride,
})
}
fn run_image_filter_reshape(img: RgbaImage, filter: &dyn ImageFilter) -> Result<RgbaImage, String> {
let bpp_in = bpp(&img);
let format = if img.is_rgb() {
PixelFormat::Rgb24
} else {
PixelFormat::Rgba
};
let in_w = img.width;
let in_h = img.height;
let frame = VideoFrame {
pts: None,
planes: vec![VideoPlane {
stride: img.stride,
data: img.pixels,
}],
};
let params = VideoStreamParams {
format,
width: in_w,
height: in_h,
};
let out = filter
.apply(&frame, params)
.map_err(|e: Error| format!("{e:?}"))?;
let plane = out
.planes
.into_iter()
.next()
.ok_or_else(|| "image-filter returned no planes".to_string())?;
if bpp_in == 0 || plane.stride == 0 {
return Err("image-filter returned a zero-stride plane".to_string());
}
if plane.stride % bpp_in != 0 {
return Err(format!(
"image-filter stride {} not a multiple of bpp {bpp_in}",
plane.stride
));
}
let new_w = (plane.stride / bpp_in) as u32;
if new_w == 0 || plane.data.len() % plane.stride != 0 {
return Err(format!(
"image-filter packed-plane mismatch: stride={}, data_len={}",
plane.stride,
plane.data.len()
));
}
let new_h = (plane.data.len() / plane.stride) as u32;
Ok(RgbaImage {
width: new_w,
height: new_h,
pixels: plane.data,
stride: plane.stride,
})
}
pub fn flip(img: &mut RgbaImage) {
let h = img.height as usize;
if h < 2 {
return;
}
let stride = img.stride;
let mut tmp = vec![0u8; stride];
for i in 0..h / 2 {
let j = h - 1 - i;
let (lo, hi) = img.pixels.split_at_mut(j * stride);
let row_i = &mut lo[i * stride..i * stride + stride];
let row_j = &mut hi[..stride];
tmp.copy_from_slice(row_i);
row_i.copy_from_slice(row_j);
row_j.copy_from_slice(&tmp);
}
}
pub fn flop(img: &mut RgbaImage) {
let w = img.width as usize;
let h = img.height as usize;
if w < 2 {
return;
}
let bpp = bpp(img);
let stride = img.stride;
for row in 0..h {
let base = row * stride;
for c in 0..w / 2 {
let lo = base + c * bpp;
let hi = base + (w - 1 - c) * bpp;
for k in 0..bpp {
img.pixels.swap(lo + k, hi + k);
}
}
}
}
pub fn roll(img: &mut RgbaImage, dx: i32, dy: i32) {
let w = img.width as usize;
let h = img.height as usize;
if w == 0 || h == 0 {
return;
}
let bpp = bpp(img);
let stride = img.stride;
let shift_x = (dx as i64).rem_euclid(w as i64) as usize;
let shift_y = (dy as i64).rem_euclid(h as i64) as usize;
if shift_x != 0 {
let byte_shift = shift_x * bpp;
let row_bytes = w * bpp;
for row in 0..h {
let base = row * stride;
img.pixels[base..base + row_bytes].rotate_right(byte_shift);
}
}
if shift_y != 0 {
let total = h * stride;
let byte_shift = shift_y * stride;
img.pixels[..total].rotate_right(byte_shift);
}
}
pub fn negate(img: &mut RgbaImage) {
let bpp = bpp(img);
if bpp == 4 {
for px in img.pixels.chunks_exact_mut(4) {
px[0] = 255 - px[0];
px[1] = 255 - px[1];
px[2] = 255 - px[2];
}
} else {
for px in img.pixels.chunks_exact_mut(3) {
px[0] = 255 - px[0];
px[1] = 255 - px[1];
px[2] = 255 - px[2];
}
}
}
pub fn rotate(img: RgbaImage, degrees: i32) -> RgbaImage {
let n = degrees.rem_euclid(360);
match n {
0 => img,
180 => rotate_180(img),
90 => rotate_90_cw(img),
270 => rotate_270_cw(img),
_ => img,
}
}
fn rotate_180(mut img: RgbaImage) -> RgbaImage {
flip(&mut img);
flop(&mut img);
img
}
fn rotate_90_cw(img: RgbaImage) -> RgbaImage {
let w = img.width as usize;
let h = img.height as usize;
let bpp = bpp(&img);
let new_w = h;
let new_h = w;
let new_stride = new_w * bpp;
let mut out = vec![0u8; new_h * new_stride];
for y in 0..h {
for x in 0..w {
let src = y * img.stride + x * bpp;
let dst_x = h - 1 - y;
let dst_y = x;
let dst = dst_y * new_stride + dst_x * bpp;
out[dst..dst + bpp].copy_from_slice(&img.pixels[src..src + bpp]);
}
}
RgbaImage {
width: new_w as u32,
height: new_h as u32,
pixels: out,
stride: new_stride,
}
}
fn rotate_270_cw(img: RgbaImage) -> RgbaImage {
let w = img.width as usize;
let h = img.height as usize;
let bpp = bpp(&img);
let new_w = h;
let new_h = w;
let new_stride = new_w * bpp;
let mut out = vec![0u8; new_h * new_stride];
for y in 0..h {
for x in 0..w {
let src = y * img.stride + x * bpp;
let dst_x = y;
let dst_y = w - 1 - x;
let dst = dst_y * new_stride + dst_x * bpp;
out[dst..dst + bpp].copy_from_slice(&img.pixels[src..src + bpp]);
}
}
RgbaImage {
width: new_w as u32,
height: new_h as u32,
pixels: out,
stride: new_stride,
}
}
pub fn extent(
img: RgbaImage,
out_w: u32,
out_h: u32,
off_x: i32,
off_y: i32,
bg: [u8; 4],
) -> Result<RgbaImage, String> {
if out_w == 0 || out_h == 0 {
return Err(format!(
"extent canvas {out_w}x{out_h} has zero width/height"
));
}
let bpp = bpp(&img);
let new_stride = (out_w as usize) * bpp;
let canvas_len = (out_h as usize)
.checked_mul(new_stride)
.ok_or_else(|| format!("extent canvas {out_w}x{out_h} byte size overflows usize"))?;
let fill: [u8; 4] = bg;
let mut out = vec![0u8; canvas_len];
let mut row0 = vec![0u8; new_stride];
for chunk in row0.chunks_exact_mut(bpp) {
chunk[0] = fill[0];
chunk[1] = fill[1];
chunk[2] = fill[2];
if bpp == 4 {
chunk[3] = fill[3];
}
}
for row in 0..(out_h as usize) {
let dst_off = row * new_stride;
out[dst_off..dst_off + new_stride].copy_from_slice(&row0);
}
let src_w = img.width as i64;
let src_h = img.height as i64;
let canvas_w = out_w as i64;
let canvas_h = out_h as i64;
let ox = off_x as i64;
let oy = off_y as i64;
let sx_start = (-ox).max(0);
let sy_start = (-oy).max(0);
let sx_end = (canvas_w - ox).min(src_w);
let sy_end = (canvas_h - oy).min(src_h);
if sx_start >= sx_end || sy_start >= sy_end {
return Ok(RgbaImage {
width: out_w,
height: out_h,
pixels: out,
stride: new_stride,
});
}
let copy_w = (sx_end - sx_start) as usize;
let copy_h = (sy_end - sy_start) as usize;
let dst_x = (sx_start + ox) as usize;
let dst_y = (sy_start + oy) as usize;
let src_x = sx_start as usize;
let src_y = sy_start as usize;
let row_bytes = copy_w * bpp;
for row in 0..copy_h {
let src_row_off = (src_y + row) * img.stride + src_x * bpp;
let dst_row_off = (dst_y + row) * new_stride + dst_x * bpp;
out[dst_row_off..dst_row_off + row_bytes]
.copy_from_slice(&img.pixels[src_row_off..src_row_off + row_bytes]);
}
Ok(RgbaImage {
width: out_w,
height: out_h,
pixels: out,
stride: new_stride,
})
}
pub fn crop(img: RgbaImage, x: u32, y: u32, w: u32, h: u32) -> Result<RgbaImage, String> {
if w == 0 || h == 0 {
return Err(format!("bbox {w}x{h}+{x}+{y} has zero width/height"));
}
let in_w = img.width;
let in_h = img.height;
let x_end = x
.checked_add(w)
.ok_or_else(|| format!("bbox {w}x{h}+{x}+{y} arithmetic overflow"))?;
let y_end = y
.checked_add(h)
.ok_or_else(|| format!("bbox {w}x{h}+{x}+{y} arithmetic overflow"))?;
if x_end > in_w || y_end > in_h {
return Err(format!("bbox {w}x{h}+{x}+{y} exceeds input {in_w}x{in_h}"));
}
let bpp = bpp(&img);
let new_stride = (w as usize) * bpp;
let mut out = vec![0u8; (h as usize) * new_stride];
for row in 0..(h as usize) {
let src_y = (y as usize) + row;
let src_x_byte = (x as usize) * bpp;
let src_off = src_y * img.stride + src_x_byte;
let dst_off = row * new_stride;
out[dst_off..dst_off + new_stride]
.copy_from_slice(&img.pixels[src_off..src_off + new_stride]);
}
Ok(RgbaImage {
width: w,
height: h,
pixels: out,
stride: new_stride,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::op::ResizeMode;
fn img_2x2_rgba() -> RgbaImage {
RgbaImage {
width: 2,
height: 2,
pixels: vec![
0x10, 0x11, 0x12, 0xff, 0x20, 0x21, 0x22, 0xff, 0x30, 0x31, 0x32, 0xff, 0x40, 0x41,
0x42, 0xff,
],
stride: 8,
}
}
fn checkerboard_4x4() -> RgbaImage {
let a = [0x10, 0x20, 0x30, 0xff];
let b = [0xc0, 0xd0, 0xe0, 0xff];
let mut data = Vec::with_capacity(64);
for y in 0..4 {
for x in 0..4 {
let p = if (x + y) % 2 == 0 { &a } else { &b };
data.extend_from_slice(p);
}
}
RgbaImage {
width: 4,
height: 4,
pixels: data,
stride: 16,
}
}
fn pixel(img: &RgbaImage, x: u32, y: u32) -> [u8; 4] {
let bpp = bpp(img);
let off = (y as usize) * img.stride + (x as usize) * bpp;
[
img.pixels[off],
img.pixels[off + 1],
img.pixels[off + 2],
img.pixels[off + 3],
]
}
#[test]
fn flip_swaps_rows() {
let mut img = img_2x2_rgba();
flip(&mut img);
assert_eq!(pixel(&img, 0, 0), [0x30, 0x31, 0x32, 0xff]);
assert_eq!(pixel(&img, 1, 0), [0x40, 0x41, 0x42, 0xff]);
assert_eq!(pixel(&img, 0, 1), [0x10, 0x11, 0x12, 0xff]);
assert_eq!(pixel(&img, 1, 1), [0x20, 0x21, 0x22, 0xff]);
}
#[test]
fn flop_swaps_columns() {
let mut img = img_2x2_rgba();
flop(&mut img);
assert_eq!(pixel(&img, 0, 0), [0x20, 0x21, 0x22, 0xff]);
assert_eq!(pixel(&img, 1, 0), [0x10, 0x11, 0x12, 0xff]);
assert_eq!(pixel(&img, 0, 1), [0x40, 0x41, 0x42, 0xff]);
assert_eq!(pixel(&img, 1, 1), [0x30, 0x31, 0x32, 0xff]);
}
#[test]
fn flip_is_self_inverse() {
let original = img_2x2_rgba().pixels.clone();
let mut img = img_2x2_rgba();
flip(&mut img);
flip(&mut img);
assert_eq!(img.pixels, original);
}
#[test]
fn flip_then_flop_equals_rotate_180() {
let original = checkerboard_4x4();
let mut a = checkerboard_4x4();
flip(&mut a);
flop(&mut a);
assert_eq!(
a.pixels, original.pixels,
"flip+flop on a checkerboard should round-trip back to itself"
);
let b = rotate(checkerboard_4x4(), 180);
assert_eq!(b.pixels, original.pixels);
}
#[test]
fn rotate_90_swaps_dims() {
let img = img_2x2_rgba();
let r = rotate(img, 90);
assert_eq!(r.width, 2);
assert_eq!(r.height, 2);
assert_eq!(pixel(&r, 1, 0), [0x10, 0x11, 0x12, 0xff]);
assert_eq!(pixel(&r, 1, 1), [0x20, 0x21, 0x22, 0xff]);
assert_eq!(pixel(&r, 0, 0), [0x30, 0x31, 0x32, 0xff]);
assert_eq!(pixel(&r, 0, 1), [0x40, 0x41, 0x42, 0xff]);
}
#[test]
fn rotate_90_then_270_round_trips() {
let original = img_2x2_rgba().pixels.clone();
let r = rotate(rotate(img_2x2_rgba(), 90), 270);
assert_eq!(r.pixels, original);
assert_eq!(r.width, 2);
assert_eq!(r.height, 2);
}
#[test]
fn rotate_180_preserves_dims() {
let r = rotate(img_2x2_rgba(), 180);
assert_eq!(r.width, 2);
assert_eq!(r.height, 2);
assert_eq!(pixel(&r, 1, 1), [0x10, 0x11, 0x12, 0xff]);
assert_eq!(pixel(&r, 0, 0), [0x40, 0x41, 0x42, 0xff]);
}
#[test]
fn rotate_negative_90_equals_270() {
let a = rotate(img_2x2_rgba(), -90);
let b = rotate(img_2x2_rgba(), 270);
assert_eq!(a.pixels, b.pixels);
assert_eq!((a.width, a.height), (b.width, b.height));
}
#[test]
fn rotate_360_is_identity() {
let original = img_2x2_rgba().pixels.clone();
let r = rotate(img_2x2_rgba(), -360);
assert_eq!(r.pixels, original);
}
#[test]
fn rotate_90_on_non_square_swaps_width_and_height() {
let img = RgbaImage {
width: 3,
height: 1,
pixels: vec![0xaa, 0, 0, 0xff, 0xbb, 0, 0, 0xff, 0xcc, 0, 0, 0xff],
stride: 12,
};
let r = rotate(img, 90);
assert_eq!(r.width, 1);
assert_eq!(r.height, 3);
assert_eq!(pixel(&r, 0, 0), [0xaa, 0, 0, 0xff]);
assert_eq!(pixel(&r, 0, 1), [0xbb, 0, 0, 0xff]);
assert_eq!(pixel(&r, 0, 2), [0xcc, 0, 0, 0xff]);
}
#[test]
fn crop_extracts_inner_pixel() {
let img = img_2x2_rgba();
let c = crop(img, 1, 1, 1, 1).unwrap();
assert_eq!(c.width, 1);
assert_eq!(c.height, 1);
assert_eq!(c.pixels, vec![0x40, 0x41, 0x42, 0xff]);
}
#[test]
fn crop_full_image_is_identity() {
let img = img_2x2_rgba();
let original = img.pixels.clone();
let c = crop(img, 0, 0, 2, 2).unwrap();
assert_eq!(c.pixels, original);
assert_eq!((c.width, c.height), (2, 2));
}
#[test]
fn crop_out_of_bounds_errors() {
let err = crop(img_2x2_rgba(), 1, 1, 2, 2).unwrap_err();
assert!(err.contains("exceeds input"));
assert!(err.contains("2x2"));
}
#[test]
fn crop_zero_size_errors() {
let err = crop(img_2x2_rgba(), 0, 0, 0, 1).unwrap_err();
assert!(err.contains("zero width/height"));
}
#[test]
fn extent_zero_offset_same_size_is_identity() {
let img = img_2x2_rgba();
let original = img.pixels.clone();
let out = extent(img, 2, 2, 0, 0, [0xff, 0xff, 0xff, 0xff]).unwrap();
assert_eq!(out.width, 2);
assert_eq!(out.height, 2);
assert_eq!(out.pixels, original);
}
#[test]
fn extent_grows_canvas_pads_background() {
let img = img_2x2_rgba();
let bg = [0xaa, 0xbb, 0xcc, 0xdd];
let out = extent(img, 4, 3, 0, 0, bg).unwrap();
assert_eq!(out.width, 4);
assert_eq!(out.height, 3);
assert_eq!(pixel(&out, 0, 0), [0x10, 0x11, 0x12, 0xff]);
assert_eq!(pixel(&out, 1, 0), [0x20, 0x21, 0x22, 0xff]);
assert_eq!(pixel(&out, 2, 0), bg);
assert_eq!(pixel(&out, 3, 0), bg);
assert_eq!(pixel(&out, 0, 2), bg);
assert_eq!(pixel(&out, 3, 2), bg);
}
#[test]
fn extent_positive_offset_shifts_source() {
let img = img_2x2_rgba();
let bg = [0x55, 0x55, 0x55, 0xff];
let out = extent(img, 4, 4, 1, 1, bg).unwrap();
assert_eq!(pixel(&out, 0, 0), bg);
assert_eq!(pixel(&out, 1, 0), bg);
assert_eq!(pixel(&out, 1, 1), [0x10, 0x11, 0x12, 0xff]);
assert_eq!(pixel(&out, 2, 1), [0x20, 0x21, 0x22, 0xff]);
assert_eq!(pixel(&out, 1, 2), [0x30, 0x31, 0x32, 0xff]);
assert_eq!(pixel(&out, 2, 2), [0x40, 0x41, 0x42, 0xff]);
assert_eq!(pixel(&out, 3, 3), bg);
}
#[test]
fn extent_negative_offset_clips_source() {
let img = img_2x2_rgba();
let bg = [0x55, 0x55, 0x55, 0xff];
let out = extent(img, 1, 1, -1, -1, bg).unwrap();
assert_eq!(out.width, 1);
assert_eq!(out.height, 1);
assert_eq!(pixel(&out, 0, 0), [0x40, 0x41, 0x42, 0xff]);
}
#[test]
fn extent_offset_off_canvas_yields_all_background() {
let img = img_2x2_rgba();
let bg = [0xab, 0xcd, 0xef, 0xff];
let out = extent(img, 2, 2, 10, 10, bg).unwrap();
for y in 0..2 {
for x in 0..2 {
assert_eq!(pixel(&out, x, y), bg);
}
}
}
#[test]
fn extent_shrinks_canvas_keeps_intersection() {
let img = img_2x2_rgba();
let out = extent(img, 1, 1, 0, 0, [0; 4]).unwrap();
assert_eq!(pixel(&out, 0, 0), [0x10, 0x11, 0x12, 0xff]);
}
#[test]
fn extent_zero_canvas_errors() {
let err = extent(img_2x2_rgba(), 0, 4, 0, 0, [0; 4]).unwrap_err();
assert!(err.contains("zero width/height"));
}
#[test]
fn extent_rgb24_drops_alpha_from_background() {
let img = RgbaImage {
width: 1,
height: 1,
pixels: vec![0x10, 0x20, 0x30],
stride: 3,
};
let out = extent(img, 2, 1, 0, 0, [0xaa, 0xbb, 0xcc, 0xff]).unwrap();
assert_eq!(out.stride, 6);
assert_eq!(out.pixels, vec![0x10, 0x20, 0x30, 0xaa, 0xbb, 0xcc]);
}
#[test]
fn negate_inverts_rgb_channels_keeps_alpha() {
let mut img = img_2x2_rgba();
negate(&mut img);
assert_eq!(pixel(&img, 0, 0), [0xef, 0xee, 0xed, 0xff]);
assert_eq!(pixel(&img, 1, 0), [0xdf, 0xde, 0xdd, 0xff]);
assert_eq!(pixel(&img, 0, 1), [0xcf, 0xce, 0xcd, 0xff]);
assert_eq!(pixel(&img, 1, 1), [0xbf, 0xbe, 0xbd, 0xff]);
}
#[test]
fn negate_is_self_inverse() {
let original = img_2x2_rgba().pixels.clone();
let mut img = img_2x2_rgba();
negate(&mut img);
negate(&mut img);
assert_eq!(img.pixels, original);
}
#[test]
fn negate_handles_rgb24_no_alpha() {
let mut img = RgbaImage {
width: 1,
height: 1,
pixels: vec![0x10, 0x20, 0x30],
stride: 3,
};
negate(&mut img);
assert_eq!(img.pixels, vec![0xef, 0xdf, 0xcf]);
}
#[test]
fn apply_chain_runs_in_source_order() {
let img = img_2x2_rgba();
let out = apply_pixel_transform_chain(img, &[Op::Negate, Op::Flip]).unwrap();
assert_eq!(pixel(&out, 0, 0), [0xcf, 0xce, 0xcd, 0xff]);
}
#[test]
fn apply_chain_flip_flop_round_trips_checkerboard() {
let original = checkerboard_4x4();
let out = apply_pixel_transform_chain(checkerboard_4x4(), &[Op::Flip, Op::Flop]).unwrap();
assert_eq!(out.pixels, original.pixels);
}
#[test]
fn apply_chain_skips_unhandled_ops() {
let img = img_2x2_rgba();
let original = img.pixels.clone();
let out = apply_pixel_transform_chain(img, &[Op::Strip]).unwrap();
assert_eq!(out.pixels, original);
}
#[test]
fn apply_chain_resize_default_aspect_fit() {
let img = img_2x2_rgba();
let out = apply_pixel_transform_chain(
img,
&[Op::Resize {
width: 4,
height: 8,
mode: ResizeMode::Default,
}],
)
.unwrap();
assert_eq!(out.width, 4);
assert_eq!(out.height, 4);
}
#[test]
fn apply_chain_resize_force_ignores_aspect() {
let img = img_2x2_rgba();
let out = apply_pixel_transform_chain(
img,
&[Op::Resize {
width: 4,
height: 8,
mode: ResizeMode::Force,
}],
)
.unwrap();
assert_eq!(out.width, 4);
assert_eq!(out.height, 8);
}
#[test]
fn apply_chain_resize_fill_picks_larger_scale() {
let img = img_2x2_rgba();
let out = apply_pixel_transform_chain(
img,
&[Op::Resize {
width: 4,
height: 8,
mode: ResizeMode::Fill,
}],
)
.unwrap();
assert_eq!(out.width, 8);
assert_eq!(out.height, 8);
}
#[test]
fn apply_chain_resize_shrink_only_passes_through_smaller() {
let img = img_2x2_rgba();
let original = img.pixels.clone();
let out = apply_pixel_transform_chain(
img,
&[Op::Resize {
width: 100,
height: 100,
mode: ResizeMode::Shrink,
}],
)
.unwrap();
assert_eq!(out.width, 2);
assert_eq!(out.height, 2);
assert_eq!(out.pixels, original);
}
#[test]
fn apply_chain_resize_percent_50_halves_dims() {
let img = img_2x2_rgba();
let out = apply_pixel_transform_chain(
img,
&[Op::Resize {
width: 50,
height: 50,
mode: ResizeMode::Percent,
}],
)
.unwrap();
assert_eq!(out.width, 1);
assert_eq!(out.height, 1);
}
#[test]
fn apply_chain_thumbnail_resizes_and_drops_metadata_op() {
let img = img_2x2_rgba();
let out = apply_pixel_transform_chain(
img,
&[Op::Thumbnail {
width: 4,
height: 4,
mode: ResizeMode::Default,
}],
)
.unwrap();
assert_eq!(out.width, 4);
assert_eq!(out.height, 4);
}
#[test]
fn apply_chain_define_is_sink_side_no_op() {
let img = img_2x2_rgba();
let original = img.pixels.clone();
let out = apply_pixel_transform_chain(
img,
&[Op::Define {
key: "jpeg:dct-method".into(),
value: Some("float".into()),
}],
)
.unwrap();
assert_eq!(out.pixels, original);
}
#[test]
fn apply_chain_propagates_crop_error() {
let err = apply_pixel_transform_chain(
img_2x2_rgba(),
&[Op::Crop {
x: 0,
y: 0,
w: 5,
h: 5,
}],
)
.unwrap_err();
assert!(err.contains("exceeds input"));
}
#[test]
fn apply_chain_trim_recovers_new_dims_from_stride() {
let bg = [0xff, 0xff, 0xff, 0xff];
let fg = [0x10, 0x20, 0x30, 0xff];
let mut data = Vec::with_capacity(64);
for y in 0..4u32 {
for x in 0..4u32 {
let p = if x == 1 && y == 1 { &fg } else { &bg };
data.extend_from_slice(p);
}
}
let img = RgbaImage {
width: 4,
height: 4,
pixels: data,
stride: 16,
};
let out = apply_pixel_transform_chain(img, &[Op::Trim { fuzz: 0 }]).unwrap();
assert_eq!(out.width, 1);
assert_eq!(out.height, 1);
assert_eq!(&out.pixels[..4], &fg[..]);
assert_eq!(out.stride, 4);
}
#[test]
fn apply_chain_trim_honours_fuzz_tolerance() {
let bg = [0xfe, 0xfe, 0xfe, 0xff]; let fg = [0x10, 0x20, 0x30, 0xff];
let mut data = Vec::with_capacity(64);
for y in 0..4u32 {
for x in 0..4u32 {
let p = if x == 2 && y == 2 { &fg } else { &bg };
data.extend_from_slice(p);
}
}
let img = RgbaImage {
width: 4,
height: 4,
pixels: data,
stride: 16,
};
let out = apply_pixel_transform_chain(img, &[Op::Trim { fuzz: 2 }]).unwrap();
assert_eq!(out.width, 1);
assert_eq!(out.height, 1);
assert_eq!(&out.pixels[..4], &fg[..]);
}
#[test]
fn apply_chain_trim_uniform_collapses_to_1x1() {
let bg = [0x80, 0x80, 0x80, 0xff];
let mut data = Vec::with_capacity(64);
for _ in 0..16 {
data.extend_from_slice(&bg);
}
let img = RgbaImage {
width: 4,
height: 4,
pixels: data,
stride: 16,
};
let out = apply_pixel_transform_chain(img, &[Op::Trim { fuzz: 0 }]).unwrap();
assert_eq!(out.width, 1);
assert_eq!(out.height, 1);
assert_eq!(&out.pixels[..4], &bg[..]);
}
#[test]
fn roll_positive_x_wraps_columns() {
let mut img = img_2x2_rgba();
roll(&mut img, 1, 0);
assert_eq!(pixel(&img, 0, 0), [0x20, 0x21, 0x22, 0xff]);
assert_eq!(pixel(&img, 1, 0), [0x10, 0x11, 0x12, 0xff]);
assert_eq!(pixel(&img, 0, 1), [0x40, 0x41, 0x42, 0xff]);
assert_eq!(pixel(&img, 1, 1), [0x30, 0x31, 0x32, 0xff]);
}
#[test]
fn roll_negative_x_shifts_left() {
let mut img = img_2x2_rgba();
roll(&mut img, -1, 0);
assert_eq!(pixel(&img, 0, 0), [0x20, 0x21, 0x22, 0xff]);
assert_eq!(pixel(&img, 1, 0), [0x10, 0x11, 0x12, 0xff]);
}
#[test]
fn roll_positive_y_wraps_rows() {
let mut img = img_2x2_rgba();
roll(&mut img, 0, 1);
assert_eq!(pixel(&img, 0, 0), [0x30, 0x31, 0x32, 0xff]);
assert_eq!(pixel(&img, 1, 0), [0x40, 0x41, 0x42, 0xff]);
assert_eq!(pixel(&img, 0, 1), [0x10, 0x11, 0x12, 0xff]);
assert_eq!(pixel(&img, 1, 1), [0x20, 0x21, 0x22, 0xff]);
}
#[test]
fn roll_zero_offset_is_identity() {
let mut img = img_2x2_rgba();
let before = img.pixels.clone();
roll(&mut img, 0, 0);
assert_eq!(img.pixels, before);
}
#[test]
fn roll_offset_larger_than_dim_is_modulo() {
let mut a = img_2x2_rgba();
let mut b = img_2x2_rgba();
roll(&mut a, 1, 0);
roll(&mut b, 3, 0);
assert_eq!(a.pixels, b.pixels);
}
#[test]
fn roll_combined_dx_dy_translates_corner() {
let mut img = checkerboard_4x4();
let orig = pixel(&img, 0, 0);
roll(&mut img, 1, 1);
assert_eq!(pixel(&img, 1, 1), orig);
}
#[test]
fn apply_chain_roll_translates_origin() {
let img = img_2x2_rgba();
let orig_0_0 = [0x10, 0x11, 0x12, 0xff];
let out = apply_pixel_transform_chain(img, &[Op::Roll { dx: 1, dy: 0 }]).unwrap();
assert_eq!(pixel(&out, 1, 0), orig_0_0);
}
#[test]
fn roll_full_cycle_is_identity() {
let mut img = checkerboard_4x4();
let before = img.pixels.clone();
roll(&mut img, 4, 4);
assert_eq!(img.pixels, before);
}
}