Skip to main content

outline/
foreground.rs

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
8/// Compose an RGBA foreground image from an RGB image and a grayscale alpha matte.
9pub 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    // Use iterators instead of nested loops for better performance
19    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/// Composed RGBA foreground image with transparent background.
27///
28/// Final output produced by composing the original RGB image with a mask as the alpha channel.
29/// The mask's grayscale values map to alpha, producing smooth or hard edges depending on processing.
30/// Obtain by calling [`foreground`](crate::MatteHandle::foreground) on a [`crate::MatteHandle`]
31/// or [`crate::MaskHandle`].
32///
33/// # Example
34/// ```no_run
35/// use outline::Outline;
36///
37/// let outline = Outline::new("model.onnx");
38/// let session = outline.for_image("input.jpg")?;
39///
40/// // Soft edges from raw matte
41/// let soft = session.matte().foreground()?;
42/// soft.save("soft-cutout.png")?;
43///
44/// // Hard edges from processed mask
45/// let hard = session.matte()
46///     .blur()
47///     .threshold()
48///     .processed()?
49///     .foreground()?;
50/// hard.save("hard-cutout.png")?;
51/// # Ok::<_, outline::OutlineError>(())
52/// ```
53#[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    /// Get a reference to the RGBA foreground image.
64    pub fn image(&self) -> &RgbaImage {
65        &self.image
66    }
67
68    /// Return the foreground image dimensions.
69    pub fn dimensions(&self) -> (u32, u32) {
70        self.image.dimensions()
71    }
72
73    /// Consume the handle and return the RGBA foreground image.
74    pub fn into_image(self) -> RgbaImage {
75        self.image
76    }
77
78    /// Save the RGBA foreground image to the specified path.
79    pub fn save(&self, path: impl AsRef<Path>) -> OutlineResult<()> {
80        self.image.save(path)?;
81        Ok(())
82    }
83
84    /// Compute the bounding box of non-transparent content using a non-zero alpha threshold.
85    pub fn bounding_box(&self) -> Option<BoundingBox> {
86        self.bounding_box_with(1)
87    }
88
89    /// Compute the bounding box of content whose alpha is at or above `threshold`.
90    pub fn bounding_box_with(&self, threshold: u8) -> Option<BoundingBox> {
91        alpha_bounding_box(&self.image, threshold)
92    }
93
94    /// Expand the canvas by the given padding, filling new pixels with transparency.
95    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    /// Crop the image to `bounds`.
101    ///
102    /// Returns `None` when `bounds` is empty or outside the current image canvas.
103    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    /// Crop the image to its non-transparent content.
114    ///
115    /// Returns `None` when the image has no non-transparent pixels.
116    pub fn crop_to_content(self) -> Option<Self> {
117        self.crop_to_content_with(1)
118    }
119
120    /// Crop the image to content whose alpha is at or above `threshold`.
121    ///
122    /// Returns `None` when the image is fully transparent at the given threshold.
123    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); // R
217                assert_eq!(px.0[1], 20); // G
218                assert_eq!(px.0[2], 30); // B
219                assert_eq!(px.0[3], 128); // A
220            }
221
222            #[test]
223            fn per_pixel_values_correct() {
224                // 2x2 image with different RGB and alpha per pixel
225                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                /// compose_foreground: dimensions preserved when inputs match
252                #[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                /// compose_foreground: RGB channels are always preserved from input
267                #[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                /// compose_foreground: alpha channel equals the input grayscale value
285                #[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                /// compose_foreground: dimension mismatch always returns error
298                #[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                    // Skip cases where dimensions match
306                    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                /// compose_foreground: output RGBA matches input RGB + alpha exactly
320                #[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                    // Output should be exactly [r, g, b, a]
333                    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}