1use std::path::Path;
2
3use image::{GrayImage, RgbImage, Rgba, RgbaImage};
4
5use crate::geometry::{BoundingBox, Padding, alpha_bounding_box, crop_rgba_image, pad_rgba_image};
6use crate::{OutlineError, OutlineResult};
7
8pub fn compose_foreground(rgb: &RgbImage, alpha: &GrayImage) -> OutlineResult<RgbaImage> {
10 let expected = rgb.dimensions();
11 let found = alpha.dimensions();
12 if expected != found {
13 return Err(OutlineError::AlphaMismatch { expected, found });
14 }
15
16 let (w, h) = rgb.dimensions();
17 let mut rgba = RgbaImage::new(w, h);
18 for ((rgb_px, alpha_px), out_px) in rgb.pixels().zip(alpha.pixels()).zip(rgba.pixels_mut()) {
20 *out_px = Rgba([rgb_px[0], rgb_px[1], rgb_px[2], alpha_px[0]]);
21 }
22
23 Ok(rgba)
24}
25
26#[derive(Debug)]
54pub struct ForegroundHandle {
55 image: RgbaImage,
56}
57
58impl ForegroundHandle {
59 pub(crate) fn new(image: RgbaImage) -> Self {
60 Self { image }
61 }
62
63 pub fn image(&self) -> &RgbaImage {
65 &self.image
66 }
67
68 pub fn dimensions(&self) -> (u32, u32) {
70 self.image.dimensions()
71 }
72
73 pub fn into_image(self) -> RgbaImage {
75 self.image
76 }
77
78 pub fn save(&self, path: impl AsRef<Path>) -> OutlineResult<()> {
80 self.image.save(path)?;
81 Ok(())
82 }
83
84 pub fn bounding_box(&self) -> Option<BoundingBox> {
86 self.bounding_box_with(1)
87 }
88
89 pub fn bounding_box_with(&self, threshold: u8) -> Option<BoundingBox> {
91 alpha_bounding_box(&self.image, threshold)
92 }
93
94 pub fn pad(self, padding: impl Into<Padding>) -> Self {
96 let image = pad_rgba_image(&self.image, padding.into(), [0, 0, 0, 0]);
97 Self { image }
98 }
99
100 pub fn crop(self, bounds: BoundingBox) -> Option<Self> {
104 if !crate::geometry::crop_bounds_fit_image(bounds, self.image.width(), self.image.height())
105 {
106 return None;
107 }
108
109 let image = crop_rgba_image(&self.image, bounds);
110 Some(Self { image })
111 }
112
113 pub fn crop_to_content(self) -> Option<Self> {
117 self.crop_to_content_with(1)
118 }
119
120 pub fn crop_to_content_with(self, threshold: u8) -> Option<Self> {
124 let bounds = alpha_bounding_box(&self.image, threshold)?;
125 self.crop(bounds)
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use image::{Luma, Rgb};
133
134 fn rgb_image(w: u32, h: u32, color: [u8; 3]) -> RgbImage {
135 RgbImage::from_pixel(w, h, Rgb(color))
136 }
137
138 fn gray_image(w: u32, h: u32, value: u8) -> GrayImage {
139 GrayImage::from_pixel(w, h, Luma([value]))
140 }
141
142 mod compose_foreground {
143 use super::*;
144
145 mod unit {
146 use super::*;
147
148 #[test]
149 fn dimensions_match_success() {
150 let rgb = rgb_image(2, 2, [255, 0, 0]);
151 let alpha = gray_image(2, 2, 128);
152
153 let result = compose_foreground(&rgb, &alpha).unwrap();
154 assert_eq!(result.dimensions(), (2, 2));
155 }
156
157 #[test]
158 fn alpha_too_small_returns_error() {
159 let rgb = rgb_image(4, 4, [255, 0, 0]);
160 let alpha = gray_image(2, 2, 128);
161
162 let err = compose_foreground(&rgb, &alpha).unwrap_err();
163 match err {
164 OutlineError::AlphaMismatch { expected, found } => {
165 assert_eq!(expected, (4, 4));
166 assert_eq!(found, (2, 2));
167 }
168 other => panic!("unexpected error: {other:?}"),
169 }
170 }
171
172 #[test]
173 fn alpha_too_large_returns_error() {
174 let rgb = rgb_image(2, 2, [255, 0, 0]);
175 let alpha = gray_image(4, 4, 128);
176
177 let err = compose_foreground(&rgb, &alpha).unwrap_err();
178 match err {
179 OutlineError::AlphaMismatch { expected, found } => {
180 assert_eq!(expected, (2, 2));
181 assert_eq!(found, (4, 4));
182 }
183 other => panic!("unexpected error: {other:?}"),
184 }
185 }
186
187 #[test]
188 fn full_transparency() {
189 let rgb = rgb_image(2, 2, [255, 128, 64]);
190 let alpha = gray_image(2, 2, 0);
191
192 let result = compose_foreground(&rgb, &alpha).unwrap();
193 for px in result.pixels() {
194 assert_eq!(px.0, [255, 128, 64, 0]);
195 }
196 }
197
198 #[test]
199 fn full_opacity() {
200 let rgb = rgb_image(2, 2, [100, 150, 200]);
201 let alpha = gray_image(2, 2, 255);
202
203 let result = compose_foreground(&rgb, &alpha).unwrap();
204 for px in result.pixels() {
205 assert_eq!(px.0, [100, 150, 200, 255]);
206 }
207 }
208
209 #[test]
210 fn rgb_channels_preserved() {
211 let rgb = rgb_image(1, 1, [10, 20, 30]);
212 let alpha = gray_image(1, 1, 128);
213
214 let result = compose_foreground(&rgb, &alpha).unwrap();
215 let px = result.get_pixel(0, 0);
216 assert_eq!(px.0[0], 10); assert_eq!(px.0[1], 20); assert_eq!(px.0[2], 30); assert_eq!(px.0[3], 128); }
221
222 #[test]
223 fn per_pixel_values_correct() {
224 let mut rgb = RgbImage::new(2, 2);
226 rgb.put_pixel(0, 0, Rgb([10, 20, 30]));
227 rgb.put_pixel(1, 0, Rgb([40, 50, 60]));
228 rgb.put_pixel(0, 1, Rgb([70, 80, 90]));
229 rgb.put_pixel(1, 1, Rgb([100, 110, 120]));
230
231 let mut alpha = GrayImage::new(2, 2);
232 alpha.put_pixel(0, 0, Luma([0]));
233 alpha.put_pixel(1, 0, Luma([85]));
234 alpha.put_pixel(0, 1, Luma([170]));
235 alpha.put_pixel(1, 1, Luma([255]));
236
237 let result = compose_foreground(&rgb, &alpha).unwrap();
238
239 assert_eq!(result.get_pixel(0, 0).0, [10, 20, 30, 0]);
240 assert_eq!(result.get_pixel(1, 0).0, [40, 50, 60, 85]);
241 assert_eq!(result.get_pixel(0, 1).0, [70, 80, 90, 170]);
242 assert_eq!(result.get_pixel(1, 1).0, [100, 110, 120, 255]);
243 }
244 }
245
246 mod prop {
247 use super::*;
248 use proptest::prelude::*;
249
250 proptest! {
251 #[test]
253 fn dimensions_preserved(
254 w in 1u32..20,
255 h in 1u32..20,
256 rgb_val in proptest::num::u8::ANY,
257 alpha_val in proptest::num::u8::ANY
258 ) {
259 let rgb = RgbImage::from_pixel(w, h, Rgb([rgb_val, rgb_val, rgb_val]));
260 let alpha = GrayImage::from_pixel(w, h, Luma([alpha_val]));
261 let result = compose_foreground(&rgb, &alpha).unwrap();
262
263 prop_assert_eq!(result.dimensions(), (w, h));
264 }
265
266 #[test]
268 fn rgb_channels_preserved(
269 r in proptest::num::u8::ANY,
270 g in proptest::num::u8::ANY,
271 b in proptest::num::u8::ANY,
272 alpha_val in proptest::num::u8::ANY
273 ) {
274 let rgb = RgbImage::from_pixel(1, 1, Rgb([r, g, b]));
275 let alpha = GrayImage::from_pixel(1, 1, Luma([alpha_val]));
276 let result = compose_foreground(&rgb, &alpha).unwrap();
277 let px = result.get_pixel(0, 0);
278
279 prop_assert_eq!(px.0[0], r);
280 prop_assert_eq!(px.0[1], g);
281 prop_assert_eq!(px.0[2], b);
282 }
283
284 #[test]
286 fn alpha_from_grayscale(
287 alpha_val in proptest::num::u8::ANY
288 ) {
289 let rgb = RgbImage::from_pixel(1, 1, Rgb([128, 128, 128]));
290 let alpha = GrayImage::from_pixel(1, 1, Luma([alpha_val]));
291 let result = compose_foreground(&rgb, &alpha).unwrap();
292 let px = result.get_pixel(0, 0);
293
294 prop_assert_eq!(px.0[3], alpha_val);
295 }
296
297 #[test]
299 fn dimension_mismatch_errors(
300 rgb_w in 1u32..10,
301 rgb_h in 1u32..10,
302 alpha_w in 1u32..10,
303 alpha_h in 1u32..10
304 ) {
305 prop_assume!(rgb_w != alpha_w || rgb_h != alpha_h);
307
308 let rgb = RgbImage::from_pixel(rgb_w, rgb_h, Rgb([128, 128, 128]));
309 let alpha = GrayImage::from_pixel(alpha_w, alpha_h, Luma([128]));
310 let result = compose_foreground(&rgb, &alpha);
311
312 prop_assert!(result.is_err());
313 if let Err(crate::OutlineError::AlphaMismatch { expected, found }) = result {
314 prop_assert_eq!(expected, (rgb_w, rgb_h));
315 prop_assert_eq!(found, (alpha_w, alpha_h));
316 }
317 }
318
319 #[test]
321 fn output_matches_inputs_exactly(
322 r in proptest::num::u8::ANY,
323 g in proptest::num::u8::ANY,
324 b in proptest::num::u8::ANY,
325 a in proptest::num::u8::ANY
326 ) {
327 let rgb = RgbImage::from_pixel(1, 1, Rgb([r, g, b]));
328 let alpha = GrayImage::from_pixel(1, 1, Luma([a]));
329 let result = compose_foreground(&rgb, &alpha).unwrap();
330 let px = result.get_pixel(0, 0);
331
332 prop_assert_eq!(px.0, [r, g, b, a]);
334 }
335 }
336 }
337 }
338
339 #[test]
340 fn foreground_handle_dimensions_reports_current_canvas() {
341 let foreground = ForegroundHandle {
342 image: RgbaImage::from_pixel(4, 3, image::Rgba([0, 0, 0, 0])),
343 };
344 assert_eq!(foreground.dimensions(), (4, 3));
345
346 let cropped = foreground
347 .crop(BoundingBox::new(1, 1, 2, 2))
348 .expect("bounds are inside the image");
349 assert_eq!(cropped.dimensions(), (2, 2));
350
351 let padded = cropped.pad(Padding::new(1, 2, 3, 4));
352 assert_eq!(padded.dimensions(), (6, 8));
353 }
354
355 #[test]
356 fn foreground_handle_bounding_box_and_crop_work_on_alpha() {
357 let mut foreground = ForegroundHandle {
358 image: RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 0])),
359 };
360 foreground
361 .image
362 .put_pixel(2, 1, image::Rgba([10, 20, 30, 200]));
363
364 let bounds = foreground.bounding_box().expect("alpha content");
365 assert_eq!(bounds, BoundingBox::new(2, 1, 1, 1));
366
367 let cropped = foreground.crop_to_content().expect("still has content");
368 assert_eq!(cropped.dimensions(), (1, 1));
369 assert_eq!(cropped.image().get_pixel(0, 0).0, [10, 20, 30, 200]);
370 }
371
372 #[test]
373 fn foreground_handle_crop_uses_requested_bounds() {
374 let mut foreground = ForegroundHandle {
375 image: RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 0])),
376 };
377 foreground
378 .image
379 .put_pixel(2, 1, image::Rgba([10, 20, 30, 200]));
380
381 let cropped = foreground
382 .crop(BoundingBox::new(1, 1, 2, 2))
383 .expect("bounds are inside the image");
384
385 assert_eq!(cropped.dimensions(), (2, 2));
386 assert_eq!(cropped.image().get_pixel(1, 0).0, [10, 20, 30, 200]);
387 }
388
389 #[test]
390 fn foreground_handle_crop_rejects_invalid_bounds() {
391 let foreground = ForegroundHandle {
392 image: RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 0])),
393 };
394
395 assert!(foreground.crop(BoundingBox::new(4, 4, 2, 2)).is_none());
396
397 let foreground = ForegroundHandle {
398 image: RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 0])),
399 };
400
401 assert!(foreground.crop(BoundingBox::new(0, 0, 0, 1)).is_none());
402 }
403
404 #[test]
405 fn foreground_handle_crop_to_content_with_ignores_low_alpha() {
406 let mut foreground = ForegroundHandle {
407 image: RgbaImage::from_pixel(5, 3, image::Rgba([0, 0, 0, 0])),
408 };
409 foreground
410 .image
411 .put_pixel(1, 1, image::Rgba([10, 20, 30, 32]));
412 foreground
413 .image
414 .put_pixel(3, 1, image::Rgba([40, 50, 60, 200]));
415
416 let cropped = foreground
417 .crop_to_content_with(128)
418 .expect("foreground has content above threshold");
419
420 assert_eq!(cropped.dimensions(), (1, 1));
421 assert_eq!(cropped.image().get_pixel(0, 0).0, [40, 50, 60, 200]);
422 }
423}