use image::{GenericImage, GrayImage, Rgb, RgbImage, Rgba, RgbaImage};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BoundingBox {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl BoundingBox {
pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
Self {
x,
y,
width,
height,
}
}
pub const fn right(self) -> u32 {
self.x
.checked_add(self.width)
.expect("bounding box right edge exceeds u32::MAX")
}
pub const fn bottom(self) -> u32 {
self.y
.checked_add(self.height)
.expect("bounding box bottom edge exceeds u32::MAX")
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Padding {
pub left: u32,
pub top: u32,
pub right: u32,
pub bottom: u32,
}
impl Padding {
pub const fn new(left: u32, top: u32, right: u32, bottom: u32) -> Self {
Self {
left,
top,
right,
bottom,
}
}
pub const fn uniform(all: u32) -> Self {
Self::new(all, all, all, all)
}
pub const fn symmetric(horizontal: u32, vertical: u32) -> Self {
Self::new(horizontal, vertical, horizontal, vertical)
}
pub const fn horizontal(self) -> u32 {
self.left
.checked_add(self.right)
.expect("horizontal padding exceeds u32::MAX")
}
pub const fn vertical(self) -> u32 {
self.top
.checked_add(self.bottom)
.expect("vertical padding exceeds u32::MAX")
}
}
impl From<u32> for Padding {
fn from(value: u32) -> Self {
Self::uniform(value)
}
}
pub fn mask_bounding_box(mask: &GrayImage, threshold: u8) -> Option<BoundingBox> {
let (w, h) = mask.dimensions();
let mut min_x = w;
let mut min_y = h;
let mut max_x = 0;
let mut max_y = 0;
let mut found = false;
for (x, y, pixel) in mask.enumerate_pixels() {
if pixel[0] < threshold {
continue;
}
found = true;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
found.then(|| BoundingBox::new(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1))
}
pub fn alpha_bounding_box(image: &RgbaImage, threshold: u8) -> Option<BoundingBox> {
let (w, h) = image.dimensions();
let mut min_x = w;
let mut min_y = h;
let mut max_x = 0;
let mut max_y = 0;
let mut found = false;
for (x, y, pixel) in image.enumerate_pixels() {
if pixel[3] < threshold {
continue;
}
found = true;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
found.then(|| BoundingBox::new(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1))
}
pub(crate) fn crop_bounds_fit_image(
bounds: BoundingBox,
image_width: u32,
image_height: u32,
) -> bool {
if bounds.width == 0 || bounds.height == 0 {
return false;
}
let Some(right) = bounds.x.checked_add(bounds.width) else {
return false;
};
let Some(bottom) = bounds.y.checked_add(bounds.height) else {
return false;
};
right <= image_width && bottom <= image_height
}
pub fn pad_gray_image(image: &GrayImage, padding: Padding, fill: u8) -> GrayImage {
let (w, h) = image.dimensions();
let out_width = w
.checked_add(padding.horizontal())
.expect("padded image width exceeds u32::MAX");
let out_height = h
.checked_add(padding.vertical())
.expect("padded image height exceeds u32::MAX");
let mut out = GrayImage::from_pixel(out_width, out_height, image::Luma([fill]));
out.copy_from(image, padding.left, padding.top)
.expect("source image should fit in padded image");
out
}
pub fn pad_rgb_image(image: &RgbImage, padding: Padding, fill: [u8; 3]) -> RgbImage {
let (w, h) = image.dimensions();
let out_width = w
.checked_add(padding.horizontal())
.expect("padded image width exceeds u32::MAX");
let out_height = h
.checked_add(padding.vertical())
.expect("padded image height exceeds u32::MAX");
let mut out = RgbImage::from_pixel(out_width, out_height, Rgb(fill));
out.copy_from(image, padding.left, padding.top)
.expect("source image should fit in padded image");
out
}
pub fn pad_rgba_image(image: &RgbaImage, padding: Padding, fill: [u8; 4]) -> RgbaImage {
let (w, h) = image.dimensions();
let out_width = w
.checked_add(padding.horizontal())
.expect("padded image width exceeds u32::MAX");
let out_height = h
.checked_add(padding.vertical())
.expect("padded image height exceeds u32::MAX");
let mut out = RgbaImage::from_pixel(out_width, out_height, Rgba(fill));
out.copy_from(image, padding.left, padding.top)
.expect("source image should fit in padded image");
out
}
pub fn crop_gray_image(image: &GrayImage, bounds: BoundingBox) -> GrayImage {
GrayImage::from_fn(bounds.width, bounds.height, |x, y| {
*image.get_pixel(bounds.x + x, bounds.y + y)
})
}
pub fn crop_rgb_image(image: &RgbImage, bounds: BoundingBox) -> RgbImage {
RgbImage::from_fn(bounds.width, bounds.height, |x, y| {
*image.get_pixel(bounds.x + x, bounds.y + y)
})
}
pub fn crop_rgba_image(image: &RgbaImage, bounds: BoundingBox) -> RgbaImage {
RgbaImage::from_fn(bounds.width, bounds.height, |x, y| {
*image.get_pixel(bounds.x + x, bounds.y + y)
})
}
#[cfg(test)]
mod tests {
use super::*;
use image::Luma;
#[test]
fn mask_bounding_box_finds_smallest_box() {
let mut image = GrayImage::from_pixel(6, 5, Luma([0]));
image.put_pixel(2, 1, Luma([255]));
image.put_pixel(4, 3, Luma([255]));
let bounds = mask_bounding_box(&image, 1).expect("expected bounding box");
assert_eq!(bounds, BoundingBox::new(2, 1, 3, 3));
}
#[test]
fn alpha_bounding_box_uses_alpha_channel() {
let mut image = RgbaImage::from_pixel(4, 4, Rgba([0, 0, 0, 0]));
image.put_pixel(1, 2, Rgba([10, 20, 30, 200]));
let bounds = alpha_bounding_box(&image, 1).expect("expected alpha bounds");
assert_eq!(bounds, BoundingBox::new(1, 2, 1, 1));
}
#[test]
fn crop_bounds_fit_image_rejects_empty_and_out_of_bounds_boxes() {
assert!(crop_bounds_fit_image(BoundingBox::new(1, 1, 2, 2), 4, 4));
assert!(!crop_bounds_fit_image(BoundingBox::new(1, 1, 0, 2), 4, 4));
assert!(!crop_bounds_fit_image(BoundingBox::new(1, 1, 2, 0), 4, 4));
assert!(!crop_bounds_fit_image(BoundingBox::new(3, 1, 2, 2), 4, 4));
assert!(!crop_bounds_fit_image(BoundingBox::new(1, 3, 2, 2), 4, 4));
assert!(!crop_bounds_fit_image(
BoundingBox::new(u32::MAX, 1, 2, 2),
4,
4
));
}
#[test]
fn pad_gray_image_expands_canvas_without_changing_content() {
let mut image = GrayImage::from_pixel(2, 2, Luma([0]));
image.put_pixel(1, 1, Luma([255]));
let padded = pad_gray_image(&image, Padding::new(1, 2, 3, 4), 0);
assert_eq!(padded.dimensions(), (6, 8));
assert_eq!(padded.get_pixel(2, 3)[0], 255);
}
#[test]
#[should_panic(expected = "horizontal padding exceeds u32::MAX")]
fn padding_horizontal_overflow_panics() {
let _ = Padding::new(u32::MAX, 0, 1, 0).horizontal();
}
#[test]
#[should_panic(expected = "padded image width exceeds u32::MAX")]
fn pad_gray_image_width_overflow_panics() {
let image = GrayImage::from_pixel(1, 1, Luma([0]));
let _ = pad_gray_image(&image, Padding::new(u32::MAX, 0, 0, 0), 0);
}
#[test]
fn crop_rgba_image_returns_requested_region() {
let mut image = RgbaImage::from_pixel(4, 4, Rgba([0, 0, 0, 0]));
image.put_pixel(2, 1, Rgba([1, 2, 3, 4]));
let cropped = crop_rgba_image(&image, BoundingBox::new(1, 1, 2, 2));
assert_eq!(cropped.dimensions(), (2, 2));
assert_eq!(cropped.get_pixel(1, 0).0, [1, 2, 3, 4]);
}
}