1use image::{GenericImage, GrayImage, Rgb, RgbImage, Rgba, RgbaImage};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct BoundingBox {
6 pub x: u32,
8 pub y: u32,
10 pub width: u32,
12 pub height: u32,
14}
15
16impl BoundingBox {
17 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 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub struct Padding {
45 pub left: u32,
47 pub top: u32,
49 pub right: u32,
51 pub bottom: u32,
53}
54
55impl Padding {
56 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 pub const fn uniform(all: u32) -> Self {
68 Self::new(all, all, all, all)
69 }
70
71 pub const fn symmetric(horizontal: u32, vertical: u32) -> Self {
73 Self::new(horizontal, vertical, horizontal, vertical)
74 }
75
76 pub const fn horizontal(self) -> u32 {
78 self.left
79 .checked_add(self.right)
80 .expect("horizontal padding exceeds u32::MAX")
81 }
82
83 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
97pub 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
120pub 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}