Skip to main content

outline/
geometry.rs

1use image::{GenericImage, GrayImage, Rgb, RgbImage, Rgba, RgbaImage};
2
3/// Integer bounding box around image content.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct BoundingBox {
6    /// Left edge of the bounding box in pixels.
7    pub x: u32,
8    /// Top edge of the bounding box in pixels.
9    pub y: u32,
10    /// Width of the bounding box in pixels.
11    pub width: u32,
12    /// Height of the bounding box in pixels.
13    pub height: u32,
14}
15
16impl BoundingBox {
17    /// Create a bounding box from its top-left corner and size.
18    pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
19        Self {
20            x,
21            y,
22            width,
23            height,
24        }
25    }
26
27    /// Return the exclusive right edge of the bounding box.
28    pub const fn right(self) -> u32 {
29        self.x
30            .checked_add(self.width)
31            .expect("bounding box right edge exceeds u32::MAX")
32    }
33
34    /// Return the exclusive bottom edge of the bounding box.
35    pub const fn bottom(self) -> u32 {
36        self.y
37            .checked_add(self.height)
38            .expect("bounding box bottom edge exceeds u32::MAX")
39    }
40}
41
42/// Per-edge image padding.
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub struct Padding {
45    /// Padding added to the left edge.
46    pub left: u32,
47    /// Padding added to the top edge.
48    pub top: u32,
49    /// Padding added to the right edge.
50    pub right: u32,
51    /// Padding added to the bottom edge.
52    pub bottom: u32,
53}
54
55impl Padding {
56    /// Create padding from individual left, top, right, and bottom values.
57    pub const fn new(left: u32, top: u32, right: u32, bottom: u32) -> Self {
58        Self {
59            left,
60            top,
61            right,
62            bottom,
63        }
64    }
65
66    /// Create equal padding for all edges.
67    pub const fn uniform(all: u32) -> Self {
68        Self::new(all, all, all, all)
69    }
70
71    /// Create padding from horizontal and vertical edge values.
72    pub const fn symmetric(horizontal: u32, vertical: u32) -> Self {
73        Self::new(horizontal, vertical, horizontal, vertical)
74    }
75
76    /// Return the combined left and right padding.
77    pub const fn horizontal(self) -> u32 {
78        self.left
79            .checked_add(self.right)
80            .expect("horizontal padding exceeds u32::MAX")
81    }
82
83    /// Return the combined top and bottom padding.
84    pub const fn vertical(self) -> u32 {
85        self.top
86            .checked_add(self.bottom)
87            .expect("vertical padding exceeds u32::MAX")
88    }
89}
90
91impl From<u32> for Padding {
92    fn from(value: u32) -> Self {
93        Self::uniform(value)
94    }
95}
96
97/// Compute the smallest rectangle that contains all mask pixels at or above `threshold`.
98pub fn mask_bounding_box(mask: &GrayImage, threshold: u8) -> Option<BoundingBox> {
99    let (w, h) = mask.dimensions();
100    let mut min_x = w;
101    let mut min_y = h;
102    let mut max_x = 0;
103    let mut max_y = 0;
104    let mut found = false;
105
106    for (x, y, pixel) in mask.enumerate_pixels() {
107        if pixel[0] < threshold {
108            continue;
109        }
110        found = true;
111        min_x = min_x.min(x);
112        min_y = min_y.min(y);
113        max_x = max_x.max(x);
114        max_y = max_y.max(y);
115    }
116
117    found.then(|| BoundingBox::new(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1))
118}
119
120/// Compute the smallest rectangle that contains all pixels whose alpha is at or above `threshold`.
121pub fn alpha_bounding_box(image: &RgbaImage, threshold: u8) -> Option<BoundingBox> {
122    let (w, h) = image.dimensions();
123    let mut min_x = w;
124    let mut min_y = h;
125    let mut max_x = 0;
126    let mut max_y = 0;
127    let mut found = false;
128
129    for (x, y, pixel) in image.enumerate_pixels() {
130        if pixel[3] < threshold {
131            continue;
132        }
133        found = true;
134        min_x = min_x.min(x);
135        min_y = min_y.min(y);
136        max_x = max_x.max(x);
137        max_y = max_y.max(y);
138    }
139
140    found.then(|| BoundingBox::new(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1))
141}
142
143pub(crate) fn crop_bounds_fit_image(
144    bounds: BoundingBox,
145    image_width: u32,
146    image_height: u32,
147) -> bool {
148    if bounds.width == 0 || bounds.height == 0 {
149        return false;
150    }
151
152    let Some(right) = bounds.x.checked_add(bounds.width) else {
153        return false;
154    };
155    let Some(bottom) = bounds.y.checked_add(bounds.height) else {
156        return false;
157    };
158
159    right <= image_width && bottom <= image_height
160}
161
162pub fn pad_gray_image(image: &GrayImage, padding: Padding, fill: u8) -> GrayImage {
163    let (w, h) = image.dimensions();
164    let out_width = w
165        .checked_add(padding.horizontal())
166        .expect("padded image width exceeds u32::MAX");
167    let out_height = h
168        .checked_add(padding.vertical())
169        .expect("padded image height exceeds u32::MAX");
170    let mut out = GrayImage::from_pixel(out_width, out_height, image::Luma([fill]));
171    out.copy_from(image, padding.left, padding.top)
172        .expect("source image should fit in padded image");
173
174    out
175}
176
177pub fn pad_rgb_image(image: &RgbImage, padding: Padding, fill: [u8; 3]) -> RgbImage {
178    let (w, h) = image.dimensions();
179    let out_width = w
180        .checked_add(padding.horizontal())
181        .expect("padded image width exceeds u32::MAX");
182    let out_height = h
183        .checked_add(padding.vertical())
184        .expect("padded image height exceeds u32::MAX");
185    let mut out = RgbImage::from_pixel(out_width, out_height, Rgb(fill));
186    out.copy_from(image, padding.left, padding.top)
187        .expect("source image should fit in padded image");
188
189    out
190}
191
192pub fn pad_rgba_image(image: &RgbaImage, padding: Padding, fill: [u8; 4]) -> RgbaImage {
193    let (w, h) = image.dimensions();
194    let out_width = w
195        .checked_add(padding.horizontal())
196        .expect("padded image width exceeds u32::MAX");
197    let out_height = h
198        .checked_add(padding.vertical())
199        .expect("padded image height exceeds u32::MAX");
200    let mut out = RgbaImage::from_pixel(out_width, out_height, Rgba(fill));
201    out.copy_from(image, padding.left, padding.top)
202        .expect("source image should fit in padded image");
203
204    out
205}
206
207pub fn crop_gray_image(image: &GrayImage, bounds: BoundingBox) -> GrayImage {
208    GrayImage::from_fn(bounds.width, bounds.height, |x, y| {
209        *image.get_pixel(bounds.x + x, bounds.y + y)
210    })
211}
212
213pub fn crop_rgb_image(image: &RgbImage, bounds: BoundingBox) -> RgbImage {
214    RgbImage::from_fn(bounds.width, bounds.height, |x, y| {
215        *image.get_pixel(bounds.x + x, bounds.y + y)
216    })
217}
218
219pub fn crop_rgba_image(image: &RgbaImage, bounds: BoundingBox) -> RgbaImage {
220    RgbaImage::from_fn(bounds.width, bounds.height, |x, y| {
221        *image.get_pixel(bounds.x + x, bounds.y + y)
222    })
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use image::Luma;
229
230    #[test]
231    fn mask_bounding_box_finds_smallest_box() {
232        let mut image = GrayImage::from_pixel(6, 5, Luma([0]));
233        image.put_pixel(2, 1, Luma([255]));
234        image.put_pixel(4, 3, Luma([255]));
235
236        let bounds = mask_bounding_box(&image, 1).expect("expected bounding box");
237        assert_eq!(bounds, BoundingBox::new(2, 1, 3, 3));
238    }
239
240    #[test]
241    fn alpha_bounding_box_uses_alpha_channel() {
242        let mut image = RgbaImage::from_pixel(4, 4, Rgba([0, 0, 0, 0]));
243        image.put_pixel(1, 2, Rgba([10, 20, 30, 200]));
244
245        let bounds = alpha_bounding_box(&image, 1).expect("expected alpha bounds");
246        assert_eq!(bounds, BoundingBox::new(1, 2, 1, 1));
247    }
248
249    #[test]
250    fn crop_bounds_fit_image_rejects_empty_and_out_of_bounds_boxes() {
251        assert!(crop_bounds_fit_image(BoundingBox::new(1, 1, 2, 2), 4, 4));
252        assert!(!crop_bounds_fit_image(BoundingBox::new(1, 1, 0, 2), 4, 4));
253        assert!(!crop_bounds_fit_image(BoundingBox::new(1, 1, 2, 0), 4, 4));
254        assert!(!crop_bounds_fit_image(BoundingBox::new(3, 1, 2, 2), 4, 4));
255        assert!(!crop_bounds_fit_image(BoundingBox::new(1, 3, 2, 2), 4, 4));
256        assert!(!crop_bounds_fit_image(
257            BoundingBox::new(u32::MAX, 1, 2, 2),
258            4,
259            4
260        ));
261    }
262
263    #[test]
264    fn pad_gray_image_expands_canvas_without_changing_content() {
265        let mut image = GrayImage::from_pixel(2, 2, Luma([0]));
266        image.put_pixel(1, 1, Luma([255]));
267
268        let padded = pad_gray_image(&image, Padding::new(1, 2, 3, 4), 0);
269        assert_eq!(padded.dimensions(), (6, 8));
270        assert_eq!(padded.get_pixel(2, 3)[0], 255);
271    }
272
273    #[test]
274    #[should_panic(expected = "horizontal padding exceeds u32::MAX")]
275    fn padding_horizontal_overflow_panics() {
276        let _ = Padding::new(u32::MAX, 0, 1, 0).horizontal();
277    }
278
279    #[test]
280    #[should_panic(expected = "padded image width exceeds u32::MAX")]
281    fn pad_gray_image_width_overflow_panics() {
282        let image = GrayImage::from_pixel(1, 1, Luma([0]));
283        let _ = pad_gray_image(&image, Padding::new(u32::MAX, 0, 0, 0), 0);
284    }
285
286    #[test]
287    fn crop_rgba_image_returns_requested_region() {
288        let mut image = RgbaImage::from_pixel(4, 4, Rgba([0, 0, 0, 0]));
289        image.put_pixel(2, 1, Rgba([1, 2, 3, 4]));
290
291        let cropped = crop_rgba_image(&image, BoundingBox::new(1, 1, 2, 2));
292        assert_eq!(cropped.dimensions(), (2, 2));
293        assert_eq!(cropped.get_pixel(1, 0).0, [1, 2, 3, 4]);
294    }
295}