Skip to main content

outline/
mask.rs

1use std::borrow::Cow;
2use std::collections::VecDeque;
3use std::path::Path;
4use std::sync::Arc;
5
6use image::{GrayImage, ImageBuffer, Luma, Rgb, RgbImage, Rgba, RgbaImage};
7use imageproc::contrast::{ThresholdType, threshold as ip_threshold};
8use imageproc::distance_transform::euclidean_squared_distance_transform;
9use imageproc::filter::gaussian_blur_f32;
10use imageproc::region_labelling::{Connectivity, connected_components};
11use ndarray::Array2;
12
13use crate::MaskVectorizer;
14use crate::OutlineResult;
15use crate::config::{ErosionBorderMode, MaskProcessingDefaults};
16use crate::foreground::{ForegroundHandle, compose_foreground};
17use crate::geometry::{
18    BoundingBox, Padding, crop_bounds_fit_image, crop_gray_image, crop_rgb_image,
19    mask_bounding_box, pad_gray_image, pad_rgb_image,
20};
21
22#[cfg(feature = "vectorizer-vtracer")]
23use vtracer::ColorImage;
24
25/// A single transformation step applied to a grayscale mask image.
26#[derive(Debug, Clone, PartialEq)]
27#[non_exhaustive]
28pub enum MaskOperation {
29    /// Apply Gaussian blur to the mask.
30    Blur {
31        /// Gaussian sigma. Must be greater than zero.
32        sigma: f32,
33    },
34    /// Threshold the mask into a binary mask.
35    Threshold {
36        /// Threshold value in the 0-255 range.
37        value: u8,
38    },
39    /// Expand white mask regions.
40    Dilate {
41        /// Dilation radius in pixels. Must be non-negative and not NaN.
42        radius: f32,
43    },
44    /// Shrink white mask regions.
45    Erode {
46        /// Erosion radius in pixels. Must be non-negative and not NaN.
47        radius: f32,
48        /// How pixels outside the image bounds are treated.
49        border_mode: ErosionBorderMode,
50    },
51    /// Fill enclosed background regions.
52    FillHoles {
53        /// Threshold used to distinguish foreground from background.
54        threshold: u8,
55    },
56    /// Keep the largest connected foreground components.
57    KeepNLargestComponents {
58        /// Number of connected components to keep.
59        count: usize,
60        /// Threshold used to distinguish foreground from background.
61        threshold: u8,
62    },
63    /// Drop connected foreground components below the requested pixel area.
64    DropComponentsBelowArea {
65        /// Minimum component area in pixels.
66        min_area: f32,
67        /// Threshold used to distinguish foreground from background.
68        threshold: u8,
69    },
70}
71
72impl MaskOperation {
73    /// Apply this operation to a grayscale mask image.
74    ///
75    /// # Panics
76    ///
77    /// Panics if this is a blur operation whose `sigma` is not greater than zero, or a dilation or
78    /// erosion operation whose radius is negative or NaN.
79    pub fn apply(&self, input: &GrayImage) -> GrayImage {
80        match self {
81            MaskOperation::Blur { sigma } => gaussian_blur_f32(input, *sigma),
82            MaskOperation::Threshold { value } => threshold_mask(input, *value),
83            MaskOperation::Dilate { radius } => dilate_euclidean(input, *radius),
84            MaskOperation::Erode {
85                radius,
86                border_mode,
87            } => erode_euclidean_with_border_mode(input, *radius, *border_mode),
88            MaskOperation::FillHoles { threshold } => fill_mask_holes(input, *threshold),
89            MaskOperation::KeepNLargestComponents { count, threshold } => {
90                keep_n_largest_components(input, *count, *threshold)
91            }
92            MaskOperation::DropComponentsBelowArea {
93                min_area,
94                threshold,
95            } => drop_components_below_area(input, *min_area, *threshold),
96        }
97    }
98}
99
100/// Run a list of operations against the provided source image, returning the transformed mask.
101///
102/// # Panics
103///
104/// Panics if `operations` contains a blur operation whose `sigma` is not greater than zero, or a
105/// dilation or erosion operation whose radius is negative or NaN.
106pub fn apply_operations(source: &GrayImage, operations: &[MaskOperation]) -> GrayImage {
107    let mut current = source.clone();
108    for op in operations {
109        current = op.apply(&current);
110    }
111    current
112}
113
114/// An ordered mask processing pipeline.
115#[derive(Debug, Clone, Default, PartialEq)]
116pub struct MaskPipeline {
117    operations: Vec<MaskOperation>,
118}
119
120impl MaskPipeline {
121    /// Create an empty mask pipeline.
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Return whether the pipeline has no operations.
127    pub fn is_empty(&self) -> bool {
128        self.operations.is_empty()
129    }
130
131    /// Return the ordered operations in this pipeline.
132    pub fn operations(&self) -> &[MaskOperation] {
133        &self.operations
134    }
135
136    /// Add a blur operation with a custom sigma.
137    ///
138    /// # Panics
139    ///
140    /// Panics if `sigma` is not greater than zero.
141    pub fn blur_with(mut self, sigma: f32) -> Self {
142        assert!(sigma > 0.0, "sigma must be > 0.0");
143        self.operations.push(MaskOperation::Blur { sigma });
144        self
145    }
146
147    /// Add a threshold operation with a custom value.
148    pub fn threshold_with(mut self, value: u8) -> Self {
149        self.operations.push(MaskOperation::Threshold { value });
150        self
151    }
152
153    /// Add a dilation operation with a custom radius.
154    ///
155    /// A radius of zero leaves the mask unchanged.
156    ///
157    /// # Panics
158    ///
159    /// Panics if `radius` is negative or NaN.
160    pub fn dilate_with(mut self, radius: f32) -> Self {
161        assert_nonnegative_radius(radius);
162        self.operations.push(MaskOperation::Dilate { radius });
163        self
164    }
165
166    /// Add an erosion operation with the default boundary behavior.
167    ///
168    /// A radius of zero leaves the mask unchanged.
169    ///
170    /// # Panics
171    ///
172    /// Panics if `radius` is negative or NaN.
173    pub fn erode_with(self, radius: f32) -> Self {
174        self.erode_with_border_mode(radius, ErosionBorderMode::default())
175    }
176
177    /// Add an erosion operation with a custom radius and boundary behavior.
178    ///
179    /// A radius of zero leaves the mask unchanged.
180    ///
181    /// # Panics
182    ///
183    /// Panics if `radius` is negative or NaN.
184    pub fn erode_with_border_mode(mut self, radius: f32, border_mode: ErosionBorderMode) -> Self {
185        assert_nonnegative_radius(radius);
186        self.operations.push(MaskOperation::Erode {
187            radius,
188            border_mode,
189        });
190        self
191    }
192
193    /// Add a hole-filling operation with a custom threshold.
194    pub fn fill_holes_with(mut self, threshold: u8) -> Self {
195        self.operations.push(MaskOperation::FillHoles { threshold });
196        self
197    }
198
199    /// Keep only the largest connected foreground component.
200    pub fn keep_largest_component(self) -> Self {
201        self.keep_n_largest_components(1)
202    }
203
204    /// Keep the largest `count` connected foreground components.
205    pub fn keep_n_largest_components(mut self, count: usize) -> Self {
206        self.operations.push(MaskOperation::KeepNLargestComponents {
207            count,
208            threshold: 1,
209        });
210        self
211    }
212
213    /// Drop connected foreground components below `min_area` pixels.
214    ///
215    /// # Panics
216    ///
217    /// Panics if `min_area` is negative or NaN.
218    pub fn drop_components_below_area(mut self, min_area: f32) -> Self {
219        assert_nonnegative_area(min_area);
220        self.operations
221            .push(MaskOperation::DropComponentsBelowArea {
222                min_area,
223                threshold: 1,
224            });
225        self
226    }
227}
228
229/// Convert a 2D array of f32 values in [0.0, 1.0] to a grayscale image.
230pub fn array_to_gray_image(array: &Array2<f32>) -> GrayImage {
231    let (h, w) = array.dim();
232    GrayImage::from_fn(w as u32, h as u32, |x, y| {
233        let value = array[[y as usize, x as usize]].clamp(0.0, 1.0);
234        let byte = (value * 255.0 + 0.5) as u8;
235        Luma([byte])
236    })
237}
238
239/// Convert a grayscale image to an RGBA color image.
240#[cfg(feature = "vectorizer-vtracer")]
241pub fn gray_to_color_image_rgba(
242    gray: &GrayImage,
243    threshold: Option<u8>,
244    invert: bool,
245) -> ColorImage {
246    let (w, h) = gray.dimensions();
247    let (w_usize, h_usize) = (w as usize, h as usize);
248    let mut rgba = vec![0u8; 4 * w_usize * h_usize];
249
250    for (i, gray_pixel) in gray.pixels().enumerate() {
251        let Luma([g]) = gray_pixel;
252        let base = if let Some(t) = threshold {
253            if *g >= t { 255 } else { 0 }
254        } else {
255            *g
256        };
257        let v = if invert {
258            255u8.saturating_sub(base)
259        } else {
260            base
261        };
262        let idx = i * 4;
263        rgba[idx] = v;
264        rgba[idx + 1] = v;
265        rgba[idx + 2] = v;
266        rgba[idx + 3] = 255;
267    }
268
269    ColorImage {
270        pixels: rgba,
271        width: w_usize,
272        height: h_usize,
273    }
274}
275
276/// Threshold the grayscale image to produce a binary mask.
277pub fn threshold_mask(gray: &GrayImage, thr: u8) -> GrayImage {
278    ip_threshold(gray, thr, ThresholdType::Binary)
279}
280
281fn assert_nonnegative_radius(radius: f32) {
282    assert!(radius >= 0.0, "radius must be >= 0.0");
283}
284
285pub(crate) fn assert_nonnegative_area(area: f32) {
286    assert!(area >= 0.0, "area must be >= 0.0");
287}
288
289fn keep_n_largest_components(mask: &GrayImage, count: usize, threshold: u8) -> GrayImage {
290    let labels = component_labels(mask, threshold);
291    let areas = component_areas(&labels);
292    if count == 0 || areas.len() <= 1 {
293        return GrayImage::new(mask.width(), mask.height());
294    }
295
296    let mut ranked_labels: Vec<_> = (1..areas.len()).collect();
297    ranked_labels.sort_by(|a, b| areas[*b].cmp(&areas[*a]).then_with(|| a.cmp(b)));
298
299    let mut keep_labels = vec![false; areas.len()];
300    for label in ranked_labels.into_iter().take(count) {
301        keep_labels[label] = true;
302    }
303
304    copy_labelled_pixels(mask, &labels, &keep_labels)
305}
306
307fn drop_components_below_area(mask: &GrayImage, min_area: f32, threshold: u8) -> GrayImage {
308    assert_nonnegative_area(min_area);
309    if min_area <= 0.0 {
310        return mask.clone();
311    }
312
313    let labels = component_labels(mask, threshold);
314    let areas = component_areas(&labels);
315    let keep_labels: Vec<_> = areas.iter().map(|area| *area as f32 >= min_area).collect();
316
317    copy_labelled_pixels(mask, &labels, &keep_labels)
318}
319
320fn component_labels(mask: &GrayImage, threshold: u8) -> ImageBuffer<Luma<u32>, Vec<u32>> {
321    let binary = GrayImage::from_fn(mask.width(), mask.height(), |x, y| {
322        if mask.get_pixel(x, y)[0] >= threshold {
323            Luma([255])
324        } else {
325            Luma([0])
326        }
327    });
328    connected_components(&binary, Connectivity::Four, Luma([0]))
329}
330
331fn component_areas(labels: &ImageBuffer<Luma<u32>, Vec<u32>>) -> Vec<usize> {
332    let max_label = labels.pixels().map(|pixel| pixel[0]).max().unwrap_or(0) as usize;
333    let mut areas = vec![0; max_label + 1];
334    for label in labels.pixels().map(|pixel| pixel[0] as usize) {
335        if label > 0 {
336            areas[label] += 1;
337        }
338    }
339    areas
340}
341
342fn copy_labelled_pixels(
343    source: &GrayImage,
344    labels: &ImageBuffer<Luma<u32>, Vec<u32>>,
345    keep_labels: &[bool],
346) -> GrayImage {
347    let mut out = GrayImage::new(source.width(), source.height());
348    for (x, y, out_pixel) in out.enumerate_pixels_mut() {
349        let label = labels.get_pixel(x, y)[0] as usize;
350        if label > 0 && keep_labels.get(label).copied().unwrap_or(false) {
351            *out_pixel = *source.get_pixel(x, y);
352        }
353    }
354
355    out
356}
357
358/// Dilate a binary mask by a Euclidean radius.
359///
360/// A radius of zero leaves the mask unchanged.
361///
362/// # Panics
363///
364/// Panics if `r` is negative or NaN.
365pub fn dilate_euclidean(mask_bin: &GrayImage, r: f32) -> GrayImage {
366    assert_nonnegative_radius(r);
367
368    if r == 0.0 {
369        return mask_bin.clone();
370    }
371
372    let d2 = euclidean_squared_distance_transform(mask_bin);
373    let r2: f64 = (r as f64) * (r as f64);
374    let (w, h) = mask_bin.dimensions();
375    let mut out = GrayImage::new(w, h);
376    for (o_pixel, d2pixel) in out.pixels_mut().zip(d2.pixels()) {
377        let d2xy: f64 = d2pixel[0];
378        let v: u8 = if d2xy <= r2 { 255 } else { 0 };
379        *o_pixel = Luma([v]);
380    }
381    out
382}
383
384/// Erode a binary mask by the provided radius using the requested boundary behavior.
385///
386/// A radius of zero leaves the mask unchanged.
387///
388/// # Panics
389///
390/// Panics if `r` is negative or NaN.
391pub fn erode_euclidean_with_border_mode(
392    mask_bin: &GrayImage,
393    r: f32,
394    border_mode: ErosionBorderMode,
395) -> GrayImage {
396    assert_nonnegative_radius(r);
397
398    if r == 0.0 {
399        return mask_bin.clone();
400    }
401
402    let inverted = invert_mask(mask_bin);
403    match border_mode {
404        ErosionBorderMode::OutsideIsUnknown => invert_mask(&dilate_euclidean(&inverted, r)),
405        ErosionBorderMode::OutsideIsBackground => {
406            let padding = Padding::uniform(1);
407            let padded = pad_gray_image(&inverted, padding, 255);
408            let dilated = dilate_euclidean(&padded, r);
409            let cropped = crop_gray_image(
410                &dilated,
411                BoundingBox::new(
412                    padding.left,
413                    padding.top,
414                    mask_bin.width(),
415                    mask_bin.height(),
416                ),
417            );
418            invert_mask(&cropped)
419        }
420    }
421}
422
423/// Invert a grayscale mask so each pixel becomes `255 - value`.
424pub fn invert_mask(mask: &GrayImage) -> GrayImage {
425    let (w, h) = mask.dimensions();
426    let mut out = GrayImage::new(w, h);
427    for (src, dst) in mask.pixels().zip(out.pixels_mut()) {
428        *dst = Luma([255u8.saturating_sub(src[0])]);
429    }
430    out
431}
432
433/// Fill holes in a binary mask using a flood-fill algorithm from the borders.
434pub fn fill_mask_holes(mask: &GrayImage, threshold: u8) -> GrayImage {
435    let (w, h) = mask.dimensions();
436    if w == 0 || h == 0 {
437        return mask.clone();
438    }
439
440    let (w_usize, h_usize) = (w as usize, h as usize);
441    let mut visited = vec![false; w_usize * h_usize];
442    let mut queue = VecDeque::new();
443
444    let idx = |x: u32, y: u32| -> usize { (y as usize) * w_usize + x as usize };
445    let mask_raw = mask.as_raw();
446
447    // Start flood-fill from all dark pixels at the image borders
448    for x in 0..w {
449        if mask_raw[idx(x, 0)] < threshold {
450            queue.push_back((x, 0));
451        }
452        if mask_raw[idx(x, h - 1)] < threshold {
453            queue.push_back((x, h - 1));
454        }
455    }
456
457    for y in 0..h {
458        if mask_raw[idx(0, y)] < threshold {
459            queue.push_back((0, y));
460        }
461        if mask_raw[idx(w - 1, y)] < threshold {
462            queue.push_back((w - 1, y));
463        }
464    }
465
466    // Use BFS to find all dark pixels connected to the borders
467    while let Some((x, y)) = queue.pop_front() {
468        let id = idx(x, y);
469        if visited[id] {
470            continue;
471        }
472        visited[id] = true;
473
474        // Check neighbors (left, right, up, down) and enqueue if dark and unvisited
475        if x > 0 {
476            let nx = x - 1;
477            let nid = idx(nx, y);
478            if !visited[nid] && mask_raw[nid] < threshold {
479                queue.push_back((nx, y));
480            }
481        }
482        if x + 1 < w {
483            let nx = x + 1;
484            let nid = idx(nx, y);
485            if !visited[nid] && mask_raw[nid] < threshold {
486                queue.push_back((nx, y));
487            }
488        }
489        if y > 0 {
490            let ny = y - 1;
491            let nid = idx(x, ny);
492            if !visited[nid] && mask_raw[nid] < threshold {
493                queue.push_back((x, ny));
494            }
495        }
496        if y + 1 < h {
497            let ny = y + 1;
498            let nid = idx(x, ny);
499            if !visited[nid] && mask_raw[nid] < threshold {
500                queue.push_back((x, ny));
501            }
502        }
503    }
504
505    let mut out = GrayImage::new(w, h);
506    for ((x, y, out_pixel), mask_pixel) in out.enumerate_pixels_mut().zip(mask.pixels()) {
507        let id = idx(x, y);
508        let value = mask_pixel[0];
509        // A pixel is part of a hole if it's dark but was not visited
510        let filled = if value >= threshold || !visited[id] {
511            255
512        } else {
513            0
514        };
515        *out_pixel = Luma([filled]);
516    }
517
518    out
519}
520
521/// How mask values are converted into output alpha.
522#[derive(Clone, Copy, Debug, Default, PartialEq)]
523pub enum MaskAlphaMode {
524    /// Use the mask value directly as alpha.
525    #[default]
526    UseMask,
527    /// Scale the mask alpha by a factor.
528    Scale(f32),
529    /// Treat any non-zero mask value as a solid alpha.
530    Solid(u8),
531}
532
533/// Options for colorizing a mask into an RGBA image.
534#[derive(Debug, Clone, Copy, PartialEq)]
535pub struct MaskColor {
536    /// Flat RGBA color applied to mask-covered pixels; A acts as a global opacity multiplier.
537    color: [u8; 4],
538    /// How mask values influence the output alpha.
539    alpha_mode: MaskAlphaMode,
540}
541
542impl MaskColor {
543    /// Create a mask color from an RGBA array.
544    pub const fn new(color: [u8; 4]) -> Self {
545        Self::from_rgba(color)
546    }
547
548    /// Create an opaque mask color from RGB channels.
549    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
550        Self::from_rgb([r, g, b])
551    }
552
553    /// Create a mask color from RGBA channels.
554    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
555        Self::from_rgba([r, g, b, a])
556    }
557
558    /// Create an opaque mask color from an RGB array.
559    pub const fn from_rgb([r, g, b]: [u8; 3]) -> Self {
560        Self::from_rgba([r, g, b, 255])
561    }
562
563    /// Create a mask color from an RGBA array.
564    pub const fn from_rgba(color: [u8; 4]) -> Self {
565        Self {
566            color,
567            alpha_mode: MaskAlphaMode::UseMask,
568        }
569    }
570
571    /// Return the flat RGBA color.
572    pub const fn to_rgba8(self) -> [u8; 4] {
573        self.color
574    }
575
576    /// Return how mask values influence the output alpha.
577    pub const fn alpha_mode(self) -> MaskAlphaMode {
578        self.alpha_mode
579    }
580
581    /// Override the alpha mode while keeping the color.
582    pub const fn with_alpha_mode(mut self, alpha_mode: MaskAlphaMode) -> Self {
583        self.alpha_mode = alpha_mode;
584        self
585    }
586}
587
588impl Default for MaskColor {
589    fn default() -> Self {
590        Self::new([255, 255, 255, 255])
591    }
592}
593
594impl From<[u8; 4]> for MaskColor {
595    fn from(color: [u8; 4]) -> Self {
596        Self::from_rgba(color)
597    }
598}
599
600impl From<[u8; 3]> for MaskColor {
601    fn from(color: [u8; 3]) -> Self {
602        Self::from_rgb(color)
603    }
604}
605
606impl From<Rgb<u8>> for MaskColor {
607    fn from(color: Rgb<u8>) -> Self {
608        Self::from_rgb(color.0)
609    }
610}
611
612impl From<Rgba<u8>> for MaskColor {
613    fn from(color: Rgba<u8>) -> Self {
614        Self::from_rgba(color.0)
615    }
616}
617
618fn resolve_mask_alpha(mask_value: u8, mode: MaskAlphaMode) -> u8 {
619    match mode {
620        MaskAlphaMode::UseMask => mask_value,
621        MaskAlphaMode::Scale(scale) => {
622            let scaled = (mask_value as f32) * scale.max(0.0);
623            scaled.round().clamp(0.0, 255.0) as u8
624        }
625        MaskAlphaMode::Solid(alpha) => {
626            if mask_value > 0 {
627                alpha
628            } else {
629                0
630            }
631        }
632    }
633}
634
635/// Convert a grayscale mask into a flat-color RGBA image.
636///
637/// The final alpha is the mask-derived alpha, as controlled by [`MaskAlphaMode`],
638/// multiplied by `color[3]`.
639pub fn colorize_mask(mask: &GrayImage, color: impl Into<MaskColor>) -> RgbaImage {
640    let color = color.into();
641    let (w, h) = mask.dimensions();
642    let [r, g, b, base_alpha] = color.to_rgba8();
643
644    let mut out = RgbaImage::new(w, h);
645    for (mask_px, out_px) in mask.pixels().zip(out.pixels_mut()) {
646        let mask_alpha = resolve_mask_alpha(mask_px[0], color.alpha_mode());
647        let alpha = ((mask_alpha as u16 * base_alpha as u16) / 255) as u8;
648        *out_px = Rgba([r, g, b, alpha]);
649    }
650
651    out
652}
653
654/// Processed mask image with optional further refinement and output generation.
655///
656/// Represents a concrete mask image (typically binary after thresholding) produced by executing
657/// operations from a [`crate::MatteHandle`].
658///
659/// # Example
660/// ```no_run
661/// use outline::Outline;
662///
663/// let outline = Outline::new("model.onnx");
664/// let session = outline.for_image("input.jpg")?;
665/// let mask = session.matte().blur().threshold().processed()?;
666///
667/// // Generate multiple outputs from the mask
668/// mask.save("mask.png")?;
669/// mask.foreground()?.save("subject.png")?;
670/// # Ok::<_, outline::OutlineError>(())
671/// ```
672///
673/// To trace the mask into SVG, pass a [`MaskVectorizer`] implementation to [`MaskHandle::trace`].
674/// The crate re-exports `VtracerSvgVectorizer` when the `vectorizer-vtracer` feature is enabled.
675#[derive(Debug, Clone)]
676pub struct MaskHandle {
677    rgb_image: Arc<RgbImage>,
678    mask: GrayImage,
679    mask_processing_defaults: MaskProcessingDefaults,
680    operations: Vec<MaskOperation>,
681}
682
683impl MaskHandle {
684    pub(crate) fn new(
685        rgb_image: Arc<RgbImage>,
686        mask: GrayImage,
687        mask_processing_defaults: MaskProcessingDefaults,
688    ) -> Self {
689        Self {
690            rgb_image,
691            mask,
692            mask_processing_defaults,
693            operations: Vec::new(),
694        }
695    }
696
697    fn resolve_pending_operations(mut self) -> Self {
698        if self.operations.is_empty() {
699            return self;
700        }
701
702        let operations = std::mem::take(&mut self.operations);
703        let mask = apply_operations(&self.mask, &operations);
704        Self::new(self.rgb_image, mask, self.mask_processing_defaults)
705    }
706
707    fn resolved_mask(&self) -> Cow<'_, GrayImage> {
708        if self.operations.is_empty() {
709            Cow::Borrowed(&self.mask)
710        } else {
711            Cow::Owned(apply_operations(&self.mask, &self.operations))
712        }
713    }
714
715    /// Clone and return the raw mask.
716    pub fn to_raw_mask(&self) -> GrayImage {
717        self.mask.clone()
718    }
719
720    /// Get a reference to the raw mask.
721    pub fn as_raw_mask(&self) -> &GrayImage {
722        &self.mask
723    }
724
725    /// Return the current mask canvas dimensions.
726    pub fn dimensions(&self) -> (u32, u32) {
727        self.mask.dimensions()
728    }
729
730    /// Get the raw mask.
731    #[deprecated(note = "use to_raw_mask()")]
732    pub fn raw(&self) -> GrayImage {
733        self.to_raw_mask()
734    }
735
736    /// Get a reference to the raw mask.
737    #[deprecated(note = "use as_raw_mask()")]
738    pub fn image(&self) -> &GrayImage {
739        self.as_raw_mask()
740    }
741
742    /// Consume the handle and return the current mask.
743    pub fn into_image(self) -> GrayImage {
744        self.resolve_pending_operations().mask
745    }
746
747    /// Save the current mask to the specified path.
748    pub fn save(&self, path: impl AsRef<Path>) -> OutlineResult<()> {
749        self.resolved_mask().save(path)?;
750        Ok(())
751    }
752
753    /// Compute the bounding box of the current mask using a non-zero threshold.
754    pub fn bounding_box(&self) -> Option<BoundingBox> {
755        self.bounding_box_with(1)
756    }
757
758    /// Compute the bounding box of the current mask at or above `threshold`.
759    pub fn bounding_box_with(&self, threshold: u8) -> Option<BoundingBox> {
760        let mask = self.resolved_mask();
761        mask_bounding_box(&mask, threshold)
762    }
763
764    /// Add a blur operation using the default sigma.
765    ///
766    /// # Panics
767    ///
768    /// Panics if the configured default blur sigma is not greater than zero.
769    pub fn blur(mut self) -> Self {
770        let sigma = self.mask_processing_defaults.blur_sigma;
771        assert!(sigma > 0.0, "sigma must be > 0.0");
772        self.operations.push(MaskOperation::Blur { sigma });
773        self
774    }
775
776    /// Add a blur operation with a custom sigma.
777    ///
778    /// # Panics
779    ///
780    /// Panics if `sigma` is not greater than zero.
781    pub fn blur_with(mut self, sigma: f32) -> Self {
782        assert!(sigma > 0.0, "sigma must be > 0.0");
783        self.operations.push(MaskOperation::Blur { sigma });
784        self
785    }
786
787    /// Add a threshold operation using the default mask threshold.
788    pub fn threshold(mut self) -> Self {
789        let value = self.mask_processing_defaults.mask_threshold;
790        self.operations.push(MaskOperation::Threshold { value });
791        self
792    }
793
794    /// Add a threshold operation with a custom value.
795    pub fn threshold_with(mut self, value: u8) -> Self {
796        self.operations.push(MaskOperation::Threshold { value });
797        self
798    }
799
800    /// Add a dilation operation using the default radius.
801    ///
802    /// **Note**: Dilation typically works best on binary masks. If this mask is still grayscale,
803    /// consider calling [`threshold`](MaskHandle::threshold) first.
804    ///
805    /// A radius of zero leaves the mask unchanged.
806    ///
807    /// # Panics
808    ///
809    /// Panics if the configured default dilation radius is negative or NaN.
810    pub fn dilate(mut self) -> Self {
811        let radius = self.mask_processing_defaults.dilation_radius;
812        assert_nonnegative_radius(radius);
813        self.operations.push(MaskOperation::Dilate { radius });
814        self
815    }
816
817    /// Add a dilation operation with a custom radius.
818    ///
819    /// **Note**: Dilation typically works best on binary masks. If this mask is still grayscale,
820    /// consider calling [`threshold`](MaskHandle::threshold) first.
821    ///
822    /// A radius of zero leaves the mask unchanged.
823    ///
824    /// # Panics
825    ///
826    /// Panics if `radius` is negative or NaN.
827    pub fn dilate_with(mut self, radius: f32) -> Self {
828        assert_nonnegative_radius(radius);
829        self.operations.push(MaskOperation::Dilate { radius });
830        self
831    }
832
833    /// Add an erosion operation using the default radius.
834    ///
835    /// **Note**: Erosion typically works best on binary masks. If this mask is still grayscale,
836    /// consider calling [`threshold`](MaskHandle::threshold) first.
837    ///
838    /// A radius of zero leaves the mask unchanged.
839    ///
840    /// # Panics
841    ///
842    /// Panics if the configured default erosion radius is negative or NaN.
843    pub fn erode(mut self) -> Self {
844        let radius = self.mask_processing_defaults.erosion_radius;
845        assert_nonnegative_radius(radius);
846        let border_mode = self.mask_processing_defaults.erosion_border_mode;
847        self.operations.push(MaskOperation::Erode {
848            radius,
849            border_mode,
850        });
851        self
852    }
853
854    /// Add an erosion operation with a custom radius.
855    ///
856    /// **Note**: Erosion typically works best on binary masks. If this mask is still grayscale,
857    /// consider calling [`threshold`](MaskHandle::threshold) first.
858    ///
859    /// A radius of zero leaves the mask unchanged.
860    ///
861    /// # Panics
862    ///
863    /// Panics if `radius` is negative or NaN.
864    pub fn erode_with(mut self, radius: f32) -> Self {
865        let border_mode = self.mask_processing_defaults.erosion_border_mode;
866        assert_nonnegative_radius(radius);
867        self.operations.push(MaskOperation::Erode {
868            radius,
869            border_mode,
870        });
871        self
872    }
873
874    /// Add an erosion operation with a custom radius and boundary behavior.
875    ///
876    /// **Note**: Erosion typically works best on binary masks. If this mask is still grayscale,
877    /// consider calling [`threshold`](MaskHandle::threshold) first.
878    ///
879    /// A radius of zero leaves the mask unchanged.
880    ///
881    /// # Panics
882    ///
883    /// Panics if `radius` is negative or NaN.
884    pub fn erode_with_border_mode(mut self, radius: f32, border_mode: ErosionBorderMode) -> Self {
885        assert_nonnegative_radius(radius);
886        self.operations.push(MaskOperation::Erode {
887            radius,
888            border_mode,
889        });
890        self
891    }
892
893    /// Add a hole-filling operation to the processing pipeline.
894    ///
895    /// **Note**: Hole-filling typically works best on binary masks. If this mask is still grayscale,
896    /// consider calling [`threshold`](MaskHandle::threshold) first.
897    pub fn fill_holes(self) -> Self {
898        let threshold = self.mask_processing_defaults.mask_threshold;
899        self.fill_holes_with(threshold)
900    }
901
902    /// Add a hole-filling operation with a custom threshold.
903    ///
904    /// **Note**: Hole-filling typically works best on binary masks. If this mask is still grayscale,
905    /// consider calling [`threshold`](MaskHandle::threshold) first.
906    pub fn fill_holes_with(mut self, threshold: u8) -> Self {
907        self.operations.push(MaskOperation::FillHoles { threshold });
908        self
909    }
910
911    /// Keep only the largest connected foreground component.
912    ///
913    /// **Note**: Component filtering typically works best on binary masks. If this mask is still grayscale,
914    /// consider calling [`threshold`](MaskHandle::threshold) first.
915    pub fn keep_largest_component(self) -> Self {
916        self.keep_n_largest_components(1)
917    }
918
919    /// Keep the largest `count` connected foreground components.
920    ///
921    /// **Note**: Component filtering typically works best on binary masks. If this mask is still grayscale,
922    /// consider calling [`threshold`](MaskHandle::threshold) first.
923    pub fn keep_n_largest_components(mut self, count: usize) -> Self {
924        self.operations.push(MaskOperation::KeepNLargestComponents {
925            count,
926            threshold: self.mask_processing_defaults.mask_threshold,
927        });
928        self
929    }
930
931    /// Drop connected foreground components below `min_area` pixels.
932    ///
933    /// **Note**: Component filtering typically works best on binary masks. If this mask is still grayscale,
934    /// consider calling [`threshold`](MaskHandle::threshold) first.
935    ///
936    /// # Panics
937    ///
938    /// Panics if `min_area` is negative or NaN.
939    pub fn drop_components_below_area(mut self, min_area: f32) -> Self {
940        assert_nonnegative_area(min_area);
941        self.operations
942            .push(MaskOperation::DropComponentsBelowArea {
943                min_area,
944                threshold: self.mask_processing_defaults.mask_threshold,
945            });
946        self
947    }
948
949    /// Process the mask with the accumulated operations.
950    pub fn processed(self) -> OutlineResult<MaskHandle> {
951        self.process_with_pipeline(None)
952    }
953
954    /// Process the mask with the accumulated operations and a custom pipeline.
955    pub fn processed_with(self, pipeline: &MaskPipeline) -> OutlineResult<MaskHandle> {
956        self.process_with_pipeline(Some(pipeline))
957    }
958
959    fn process_with_pipeline(
960        mut self,
961        pipeline: Option<&MaskPipeline>,
962    ) -> OutlineResult<MaskHandle> {
963        let mut ops = std::mem::take(&mut self.operations);
964        if let Some(custom) = pipeline {
965            ops.extend_from_slice(custom.operations());
966        }
967
968        let mask = apply_operations(&self.mask, &ops);
969        Ok(MaskHandle::new(
970            self.rgb_image,
971            mask,
972            self.mask_processing_defaults,
973        ))
974    }
975
976    /// Compose the RGBA foreground image from the RGB image and the current mask.
977    pub fn foreground(&self) -> OutlineResult<ForegroundHandle> {
978        let mask = self.resolved_mask();
979        let rgba = compose_foreground(self.rgb_image.as_ref(), &mask)?;
980        Ok(ForegroundHandle::new(rgba))
981    }
982
983    /// Colorize the current mask into a flat-color RGBA image.
984    pub fn colorize(&self, color: impl Into<MaskColor>) -> RgbaImage {
985        let mask = self.resolved_mask();
986        colorize_mask(&mask, color)
987    }
988
989    /// Trace the current mask using the specified vectorizer and options.
990    pub fn trace<V>(&self, vectorizer: &V, options: &V::Options) -> OutlineResult<V::Output>
991    where
992        V: MaskVectorizer,
993    {
994        let mask = self.resolved_mask();
995        vectorizer.vectorize(&mask, options)
996    }
997
998    /// Expand the mask canvas by the given padding while keeping content at the same offset.
999    ///
1000    /// Any pending mask operations are applied before padding so method call order is preserved.
1001    /// The underlying RGB canvas is padded with black to keep [`foreground`](MaskHandle::foreground)
1002    /// aligned with the mask.
1003    pub fn pad(self, padding: impl Into<Padding>) -> Self {
1004        let this = self.resolve_pending_operations();
1005        let padding = padding.into();
1006        let mask = pad_gray_image(&this.mask, padding, 0);
1007        let rgb = Arc::new(pad_rgb_image(this.rgb_image.as_ref(), padding, [0, 0, 0]));
1008        Self::new(rgb, mask, this.mask_processing_defaults)
1009    }
1010
1011    /// Crop the mask and source RGB image to `bounds`.
1012    ///
1013    /// Returns `None` when `bounds` is empty or outside the current mask canvas.
1014    pub fn crop(self, bounds: BoundingBox) -> Option<Self> {
1015        let this = self.resolve_pending_operations();
1016        if !crop_bounds_fit_image(bounds, this.mask.width(), this.mask.height()) {
1017            return None;
1018        }
1019
1020        let mask = crop_gray_image(&this.mask, bounds);
1021        let rgb = Arc::new(crop_rgb_image(this.rgb_image.as_ref(), bounds));
1022        Some(Self::new(rgb, mask, this.mask_processing_defaults))
1023    }
1024
1025    /// Crop the mask and source RGB image to the smallest non-zero content box.
1026    ///
1027    /// Returns `None` when the current mask has no non-zero pixels.
1028    pub fn crop_to_content(self) -> Option<Self> {
1029        self.crop_to_content_with(1)
1030    }
1031
1032    /// Crop the mask and source RGB image to the smallest content box at or above `threshold`.
1033    ///
1034    /// Returns `None` when the current mask has no pixels at or above `threshold`.
1035    pub fn crop_to_content_with(self, threshold: u8) -> Option<Self> {
1036        let this = self.resolve_pending_operations();
1037        let bounds = mask_bounding_box(&this.mask, threshold)?;
1038        this.crop(bounds)
1039    }
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use super::*;
1045    use ndarray::arr2;
1046
1047    fn gray_image(w: u32, h: u32, value: u8) -> GrayImage {
1048        GrayImage::from_pixel(w, h, Luma([value]))
1049    }
1050
1051    mod threshold_mask {
1052        use super::*;
1053
1054        mod unit {
1055            use super::*;
1056
1057            #[test]
1058            fn all_below_threshold_become_black() {
1059                let input = gray_image(2, 2, 100);
1060                let result = threshold_mask(&input, 128);
1061                for px in result.pixels() {
1062                    assert_eq!(px.0[0], 0);
1063                }
1064            }
1065
1066            #[test]
1067            fn all_above_threshold_become_white() {
1068                let input = gray_image(2, 2, 200);
1069                let result = threshold_mask(&input, 128);
1070                for px in result.pixels() {
1071                    assert_eq!(px.0[0], 255);
1072                }
1073            }
1074
1075            #[test]
1076            fn exact_threshold_becomes_black() {
1077                // imageproc threshold: > threshold -> white, <= threshold -> black
1078                let input = gray_image(2, 2, 128);
1079                let result = threshold_mask(&input, 128);
1080                for px in result.pixels() {
1081                    assert_eq!(px.0[0], 0);
1082                }
1083            }
1084
1085            #[test]
1086            fn one_below_threshold_becomes_black() {
1087                let input = gray_image(2, 2, 127);
1088                let result = threshold_mask(&input, 128);
1089                for px in result.pixels() {
1090                    assert_eq!(px.0[0], 0);
1091                }
1092            }
1093
1094            #[test]
1095            fn dimensions_preserved() {
1096                let input = gray_image(5, 3, 100);
1097                let result = threshold_mask(&input, 128);
1098                assert_eq!(result.dimensions(), (5, 3));
1099            }
1100
1101            #[test]
1102            fn mixed_values_per_pixel() {
1103                // 2x2 image with values: 127, 128, 129, 255
1104                let mut input = GrayImage::new(2, 2);
1105                input.put_pixel(0, 0, Luma([127])); // thr-1
1106                input.put_pixel(1, 0, Luma([128])); // thr
1107                input.put_pixel(0, 1, Luma([129])); // thr+1
1108                input.put_pixel(1, 1, Luma([255])); // max
1109
1110                let result = threshold_mask(&input, 128);
1111
1112                assert_eq!(result.get_pixel(0, 0).0[0], 0); // 127 <= 128
1113                assert_eq!(result.get_pixel(1, 0).0[0], 0); // 128 <= 128
1114                assert_eq!(result.get_pixel(0, 1).0[0], 255); // 129 > 128
1115                assert_eq!(result.get_pixel(1, 1).0[0], 255); // 255 > 128
1116            }
1117
1118            #[test]
1119            fn threshold_zero() {
1120                // thr=0: only value=0 becomes black, everything else white
1121                let mut input = GrayImage::new(2, 1);
1122                input.put_pixel(0, 0, Luma([0]));
1123                input.put_pixel(1, 0, Luma([1]));
1124
1125                let result = threshold_mask(&input, 0);
1126
1127                assert_eq!(result.get_pixel(0, 0).0[0], 0); // 0 <= 0
1128                assert_eq!(result.get_pixel(1, 0).0[0], 255); // 1 > 0
1129            }
1130
1131            #[test]
1132            fn threshold_255() {
1133                // thr=255: everything becomes black (nothing > 255)
1134                let mut input = GrayImage::new(2, 1);
1135                input.put_pixel(0, 0, Luma([254]));
1136                input.put_pixel(1, 0, Luma([255]));
1137
1138                let result = threshold_mask(&input, 255);
1139
1140                assert_eq!(result.get_pixel(0, 0).0[0], 0); // 254 <= 255
1141                assert_eq!(result.get_pixel(1, 0).0[0], 0); // 255 <= 255
1142            }
1143        }
1144
1145        mod prop {
1146            use super::*;
1147            use proptest::prelude::*;
1148
1149            proptest! {
1150                /// threshold_mask: output is always binary (0 or 255)
1151                #[test]
1152                fn output_is_binary(
1153                    w in 1u32..20,
1154                    h in 1u32..20,
1155                    fill_value in proptest::num::u8::ANY,
1156                    threshold in proptest::num::u8::ANY
1157                ) {
1158                    let input = GrayImage::from_pixel(w, h, Luma([fill_value]));
1159                    let result = threshold_mask(&input, threshold);
1160
1161                    prop_assert_eq!(result.dimensions(), (w, h));
1162                    for px in result.pixels() {
1163                        prop_assert!(px.0[0] == 0 || px.0[0] == 255);
1164                    }
1165                }
1166
1167                /// threshold_mask: values > threshold become 255, values <= threshold become 0
1168                #[test]
1169                fn respects_threshold(
1170                    value in proptest::num::u8::ANY,
1171                    threshold in proptest::num::u8::ANY
1172                ) {
1173                    let input = GrayImage::from_pixel(1, 1, Luma([value]));
1174                    let result = threshold_mask(&input, threshold);
1175                    let out = result.get_pixel(0, 0).0[0];
1176
1177                    if value > threshold {
1178                        prop_assert_eq!(out, 255);
1179                    } else {
1180                        prop_assert_eq!(out, 0);
1181                    }
1182                }
1183            }
1184        }
1185    }
1186
1187    mod array_to_gray_image {
1188        use super::*;
1189
1190        mod unit {
1191            use super::*;
1192
1193            #[test]
1194            fn all_zeros_black() {
1195                let arr = arr2(&[[0.0, 0.0], [0.0, 0.0]]);
1196                let result = array_to_gray_image(&arr);
1197                for px in result.pixels() {
1198                    assert_eq!(px.0[0], 0);
1199                }
1200            }
1201
1202            #[test]
1203            fn all_ones_white() {
1204                let arr = arr2(&[[1.0, 1.0], [1.0, 1.0]]);
1205                let result = array_to_gray_image(&arr);
1206                for px in result.pixels() {
1207                    assert_eq!(px.0[0], 255);
1208                }
1209            }
1210
1211            #[test]
1212            fn half_value_gray() {
1213                let arr = arr2(&[[0.5]]);
1214                let result = array_to_gray_image(&arr);
1215                // 0.5 * 255 + 0.5 = 128
1216                assert_eq!(result.get_pixel(0, 0).0[0], 128);
1217            }
1218
1219            #[test]
1220            fn clamps_above_one() {
1221                let arr = arr2(&[[2.0]]);
1222                let result = array_to_gray_image(&arr);
1223                assert_eq!(result.get_pixel(0, 0).0[0], 255);
1224            }
1225
1226            #[test]
1227            fn clamps_below_zero() {
1228                let arr = arr2(&[[-1.0]]);
1229                let result = array_to_gray_image(&arr);
1230                assert_eq!(result.get_pixel(0, 0).0[0], 0);
1231            }
1232
1233            #[test]
1234            fn dimensions_preserved() {
1235                let arr = arr2(&[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]);
1236                let result = array_to_gray_image(&arr);
1237                // ndarray is (rows, cols) = (h, w), image is (w, h)
1238                assert_eq!(result.dimensions(), (3, 2));
1239            }
1240
1241            #[test]
1242            fn rounding_behavior() {
1243                let arr = arr2(&[[0.4]]);
1244                let result = array_to_gray_image(&arr);
1245                assert_eq!(result.get_pixel(0, 0).0[0], 102); // 0.4 * 255 + 0.5 = 102.5, truncated
1246            }
1247        }
1248
1249        mod prop {
1250            use super::*;
1251            use proptest::prelude::*;
1252
1253            proptest! {
1254                /// array_to_gray_image: any f32 input clamps to [0, 255] range
1255                #[test]
1256                fn clamps_out_of_range_values(
1257                    values in proptest::collection::vec(-10.0f32..10.0f32, 1..100)
1258                ) {
1259                    // Create a 1D array that we'll reshape - use length as width, height=1
1260                    let len = values.len();
1261                    let arr = ndarray::Array2::from_shape_vec((1, len), values.clone()).unwrap();
1262                    let result = array_to_gray_image(&arr);
1263
1264                    // Verify clamping: negative values -> 0, values > 1 -> 255
1265                    for (i, px) in result.pixels().enumerate() {
1266                        let input = values[i];
1267                        if input <= 0.0 {
1268                            prop_assert_eq!(px.0[0], 0);
1269                        } else if input >= 1.0 {
1270                            prop_assert_eq!(px.0[0], 255);
1271                        }
1272                    }
1273                    // Dimensions preserved
1274                    prop_assert_eq!(result.dimensions(), (len as u32, 1));
1275                }
1276
1277                /// array_to_gray_image: values in [0, 1] map to proportional bytes
1278                #[test]
1279                fn valid_range_maps_proportionally(value in 0.0f32..=1.0f32) {
1280                    let arr = ndarray::arr2(&[[value]]);
1281                    let result = array_to_gray_image(&arr);
1282                    let byte = result.get_pixel(0, 0).0[0];
1283
1284                    // Expected: (value * 255.0 + 0.5) as u8
1285                    let expected = (value * 255.0 + 0.5) as u8;
1286                    prop_assert_eq!(byte, expected);
1287                }
1288            }
1289        }
1290    }
1291
1292    mod fill_mask_holes {
1293        use super::*;
1294
1295        mod unit {
1296            use super::*;
1297
1298            #[test]
1299            fn solid_white_unchanged() {
1300                let input = gray_image(4, 4, 255);
1301                let result = fill_mask_holes(&input, 128);
1302                for px in result.pixels() {
1303                    assert_eq!(px.0[0], 255);
1304                }
1305            }
1306
1307            #[test]
1308            fn solid_black_unchanged() {
1309                let input = gray_image(4, 4, 0); // all black, connected to border
1310                let result = fill_mask_holes(&input, 128);
1311                for px in result.pixels() {
1312                    assert_eq!(px.0[0], 0); // stays black
1313                }
1314            }
1315
1316            #[test]
1317            fn interior_hole_filled() {
1318                // Create a 5x5 image: white border, black interior
1319                // W W W W W
1320                // W B B B W
1321                // W B B B W
1322                // W B B B W
1323                // W W W W W
1324                let mut input = gray_image(5, 5, 255);
1325                for y in 1..4 {
1326                    for x in 1..4 {
1327                        input.put_pixel(x, y, Luma([0]));
1328                    }
1329                }
1330
1331                let result = fill_mask_holes(&input, 128);
1332
1333                // The interior black region is NOT connected to border, so it gets filled
1334                for px in result.pixels() {
1335                    assert_eq!(px.0[0], 255);
1336                }
1337            }
1338
1339            #[test]
1340            fn border_connected_black_not_filled() {
1341                // Create a 4x4 image with black on left edge
1342                // B W W W
1343                // B W W W
1344                // B W W W
1345                // B W W W
1346                let mut input = gray_image(4, 4, 255);
1347                for y in 0..4 {
1348                    input.put_pixel(0, y, Luma([0]));
1349                }
1350
1351                let result = fill_mask_holes(&input, 128);
1352
1353                // Left column is connected to border, stays black (=0 in output)
1354                for y in 0..4 {
1355                    assert_eq!(result.get_pixel(0, y).0[0], 0);
1356                }
1357                // Rest stays white
1358                for y in 0..4 {
1359                    for x in 1..4 {
1360                        assert_eq!(result.get_pixel(x, y).0[0], 255);
1361                    }
1362                }
1363            }
1364
1365            #[test]
1366            fn dimensions_preserved() {
1367                let input = gray_image(7, 5, 128);
1368                let result = fill_mask_holes(&input, 128);
1369                assert_eq!(result.dimensions(), (7, 5));
1370            }
1371
1372            #[test]
1373            fn empty_image_is_unchanged() {
1374                let input = GrayImage::new(0, 0);
1375                let result = fill_mask_holes(&input, 128);
1376                assert_eq!(result.dimensions(), (0, 0));
1377            }
1378
1379            #[test]
1380            fn diagonal_only_connection_not_traversed() {
1381                // 4-connectivity: diagonal doesn't count as connected
1382                // W W W
1383                // W B W
1384                // B W W  (bottom-left black only diagonally connected to center)
1385                let mut input = GrayImage::new(3, 3);
1386                for y in 0..3 {
1387                    for x in 0..3 {
1388                        input.put_pixel(x, y, Luma([255]));
1389                    }
1390                }
1391                input.put_pixel(1, 1, Luma([0])); // center black
1392                input.put_pixel(0, 2, Luma([0])); // corner black (touches border)
1393
1394                let result = fill_mask_holes(&input, 128);
1395
1396                // corner (0,2) is on border, stays black
1397                assert_eq!(result.get_pixel(0, 2).0[0], 0);
1398                // center (1,1) is NOT 4-connected to border, gets filled
1399                assert_eq!(result.get_pixel(1, 1).0[0], 255);
1400            }
1401
1402            #[test]
1403            fn threshold_changes_border_connectivity() {
1404                // pixel value 110 on border:
1405                // thr=128: 110 < 128, dark, BFS marks visited, output 0
1406                // thr=100: 110 >= 100, light, not visited by BFS, output 255
1407                let mut input = gray_image(3, 3, 255);
1408                input.put_pixel(0, 1, Luma([110])); // left border
1409
1410                let r128 = fill_mask_holes(&input, 128);
1411                assert_eq!(r128.get_pixel(0, 1).0[0], 0);
1412
1413                let r100 = fill_mask_holes(&input, 100);
1414                assert_eq!(r100.get_pixel(0, 1).0[0], 255);
1415            }
1416
1417            #[test]
1418            fn output_is_always_binary() {
1419                // fill_mask_holes should always produce binary output (0 or 255)
1420                let mut input = gray_image(5, 5, 200); // non-binary input
1421                input.put_pixel(2, 2, Luma([50])); // interior dark pixel
1422
1423                let result = fill_mask_holes(&input, 128);
1424
1425                let is_binary = result.pixels().all(|p| p.0[0] == 0 || p.0[0] == 255);
1426                assert!(is_binary);
1427            }
1428        }
1429
1430        mod prop {
1431            use super::*;
1432            use proptest::prelude::*;
1433
1434            proptest! {
1435                /// fill_mask_holes: output is always binary and dimensions preserved
1436                #[test]
1437                fn output_is_binary(
1438                    w in 1u32..15,
1439                    h in 1u32..15,
1440                    fill_value in proptest::num::u8::ANY,
1441                    threshold in proptest::num::u8::ANY
1442                ) {
1443                    let input = GrayImage::from_pixel(w, h, Luma([fill_value]));
1444                    let result = fill_mask_holes(&input, threshold);
1445
1446                    prop_assert_eq!(result.dimensions(), (w, h));
1447                    for px in result.pixels() {
1448                        prop_assert!(px.0[0] == 0 || px.0[0] == 255);
1449                    }
1450                }
1451            }
1452        }
1453    }
1454
1455    mod component_filtering {
1456        use super::*;
1457
1458        fn mask_with_three_components() -> GrayImage {
1459            let mut input = gray_image(8, 5, 0);
1460            for y in 0..2 {
1461                for x in 0..2 {
1462                    input.put_pixel(x, y, Luma([200]));
1463                }
1464            }
1465            input.put_pixel(5, 0, Luma([180]));
1466            input.put_pixel(5, 1, Luma([180]));
1467            input.put_pixel(7, 4, Luma([220]));
1468            input
1469        }
1470
1471        #[test]
1472        fn keep_n_largest_components_keeps_requested_count() {
1473            let input = mask_with_three_components();
1474            let result = keep_n_largest_components(&input, 2, 128);
1475
1476            assert_eq!(result.get_pixel(0, 0).0[0], 200);
1477            assert_eq!(result.get_pixel(5, 0).0[0], 180);
1478            assert_eq!(result.get_pixel(7, 4).0[0], 0);
1479        }
1480
1481        #[test]
1482        fn keep_largest_component_keeps_only_biggest_component() {
1483            let input = mask_with_three_components();
1484            let result = keep_n_largest_components(&input, 1, 128);
1485
1486            assert_eq!(result.get_pixel(0, 0).0[0], 200);
1487            assert_eq!(result.get_pixel(5, 0).0[0], 0);
1488            assert_eq!(result.get_pixel(7, 4).0[0], 0);
1489        }
1490
1491        #[test]
1492        fn drop_components_below_area_removes_small_components() {
1493            let input = mask_with_three_components();
1494            let result = drop_components_below_area(&input, 2.0, 128);
1495
1496            assert_eq!(result.get_pixel(0, 0).0[0], 200);
1497            assert_eq!(result.get_pixel(5, 0).0[0], 180);
1498            assert_eq!(result.get_pixel(7, 4).0[0], 0);
1499        }
1500
1501        #[test]
1502        fn component_labels_threshold_before_labelling() {
1503            let mut input = gray_image(3, 1, 0);
1504            input.put_pixel(0, 0, Luma([128]));
1505            input.put_pixel(1, 0, Luma([200]));
1506
1507            let result = keep_n_largest_components(&input, 1, 128);
1508
1509            assert_eq!(result.get_pixel(0, 0).0[0], 128);
1510            assert_eq!(result.get_pixel(1, 0).0[0], 200);
1511        }
1512
1513        #[test]
1514        fn component_operations_work_inside_pipeline() {
1515            let input = mask_with_three_components();
1516            let result = apply_operations(
1517                &input,
1518                &[MaskOperation::KeepNLargestComponents {
1519                    count: 1,
1520                    threshold: 128,
1521                }],
1522            );
1523
1524            assert_eq!(result.get_pixel(0, 0).0[0], 200);
1525            assert_eq!(result.get_pixel(5, 0).0[0], 0);
1526        }
1527    }
1528
1529    mod dilate_euclidean {
1530        use super::*;
1531
1532        mod unit {
1533            use super::*;
1534
1535            #[test]
1536            fn solid_white_stays_white() {
1537                let input = gray_image(4, 4, 255);
1538                let result = dilate_euclidean(&input, 2.0);
1539                for px in result.pixels() {
1540                    assert_eq!(px.0[0], 255);
1541                }
1542            }
1543
1544            #[test]
1545            fn solid_black_stays_black() {
1546                let input = gray_image(4, 4, 0);
1547                let result = dilate_euclidean(&input, 2.0);
1548                for px in result.pixels() {
1549                    assert_eq!(px.0[0], 0);
1550                }
1551            }
1552
1553            #[test]
1554            fn single_white_pixel_dilates() {
1555                // 5x5 black image with center pixel white
1556                let mut input = gray_image(5, 5, 0);
1557                input.put_pixel(2, 2, Luma([255]));
1558
1559                let result = dilate_euclidean(&input, 1.5);
1560
1561                // Center should be white
1562                assert_eq!(result.get_pixel(2, 2).0[0], 255);
1563                // Neighbors within radius should also be white
1564                assert_eq!(result.get_pixel(2, 1).0[0], 255);
1565                assert_eq!(result.get_pixel(2, 3).0[0], 255);
1566                assert_eq!(result.get_pixel(1, 2).0[0], 255);
1567                assert_eq!(result.get_pixel(3, 2).0[0], 255);
1568            }
1569
1570            #[test]
1571            fn dimensions_preserved() {
1572                let input = gray_image(6, 4, 128);
1573                let result = dilate_euclidean(&input, 1.0);
1574                assert_eq!(result.dimensions(), (6, 4));
1575            }
1576
1577            #[test]
1578            fn diagonal_within_radius() {
1579                // r=1.5, diagonal distance = sqrt(2) ~ 1.414 < 1.5
1580                let mut input = gray_image(5, 5, 0);
1581                input.put_pixel(2, 2, Luma([255]));
1582
1583                let result = dilate_euclidean(&input, 1.5);
1584
1585                // diagonals should be white (euclidean distance ~1.414)
1586                assert_eq!(result.get_pixel(1, 1).0[0], 255);
1587                assert_eq!(result.get_pixel(3, 1).0[0], 255);
1588                assert_eq!(result.get_pixel(1, 3).0[0], 255);
1589                assert_eq!(result.get_pixel(3, 3).0[0], 255);
1590            }
1591
1592            #[test]
1593            fn radius_zero_only_original_pixels() {
1594                // r=0: only original white pixels stay white
1595                let mut input = gray_image(3, 3, 0);
1596                input.put_pixel(1, 1, Luma([255]));
1597
1598                let result = dilate_euclidean(&input, 0.0);
1599
1600                assert_eq!(result.get_pixel(1, 1).0[0], 255);
1601                // neighbors should stay black
1602                assert_eq!(result.get_pixel(0, 1).0[0], 0);
1603                assert_eq!(result.get_pixel(2, 1).0[0], 0);
1604                assert_eq!(result.get_pixel(1, 0).0[0], 0);
1605                assert_eq!(result.get_pixel(1, 2).0[0], 0);
1606            }
1607
1608            #[test]
1609            #[should_panic(expected = "radius must be >= 0.0")]
1610            fn negative_radius_panics() {
1611                let input = gray_image(3, 3, 0);
1612                let _ = dilate_euclidean(&input, -1.0);
1613            }
1614        }
1615
1616        mod prop {
1617            use super::*;
1618            use proptest::prelude::*;
1619
1620            proptest! {
1621                /// dilate_euclidean: output is always binary and dimensions preserved
1622                #[test]
1623                fn output_is_binary(
1624                    w in 1u32..15,
1625                    h in 1u32..15,
1626                    fill_value in proptest::num::u8::ANY,
1627                    radius in 0.0f32..5.0f32
1628                ) {
1629                    let input = GrayImage::from_pixel(w, h, Luma([fill_value]));
1630                    let result = dilate_euclidean(&input, radius);
1631
1632                    prop_assert_eq!(result.dimensions(), (w, h));
1633                    for px in result.pixels() {
1634                        prop_assert!(px.0[0] == 0 || px.0[0] == 255);
1635                    }
1636                }
1637            }
1638        }
1639    }
1640
1641    mod erode_euclidean_tests {
1642        use super::*;
1643        use crate::config::ErosionBorderMode;
1644
1645        #[test]
1646        fn erosion_shrinks_binary_mask() {
1647            let mut input = gray_image(5, 5, 0);
1648            for y in 1..4 {
1649                for x in 1..4 {
1650                    input.put_pixel(x, y, Luma([255]));
1651                }
1652            }
1653
1654            let result =
1655                erode_euclidean_with_border_mode(&input, 1.0, ErosionBorderMode::default());
1656
1657            assert_eq!(result.get_pixel(2, 2).0[0], 255);
1658            assert_eq!(result.get_pixel(1, 1).0[0], 0);
1659            assert_eq!(result.get_pixel(1, 2).0[0], 0);
1660            assert_eq!(result.get_pixel(2, 1).0[0], 0);
1661            assert_eq!(result.get_pixel(3, 3).0[0], 0);
1662        }
1663
1664        #[test]
1665        fn erosion_treats_image_exterior_as_background() {
1666            let input = gray_image(5, 5, 255);
1667
1668            let result =
1669                erode_euclidean_with_border_mode(&input, 1.0, ErosionBorderMode::default());
1670
1671            for y in 0..5 {
1672                for x in 0..5 {
1673                    let expected = if x == 0 || y == 0 || x == 4 || y == 4 {
1674                        0
1675                    } else {
1676                        255
1677                    };
1678                    assert_eq!(result.get_pixel(x, y).0[0], expected, "pixel ({x}, {y})");
1679                }
1680            }
1681        }
1682
1683        #[test]
1684        fn erosion_can_treat_image_exterior_as_unknown() {
1685            let input = gray_image(5, 5, 255);
1686
1687            let result =
1688                erode_euclidean_with_border_mode(&input, 1.0, ErosionBorderMode::OutsideIsUnknown);
1689
1690            assert_eq!(result.as_raw(), input.as_raw());
1691        }
1692
1693        #[test]
1694        fn erosion_radius_zero_preserves_mask() {
1695            let mut input = gray_image(3, 3, 0);
1696            input.put_pixel(0, 0, Luma([255]));
1697            input.put_pixel(1, 1, Luma([255]));
1698            input.put_pixel(2, 2, Luma([255]));
1699
1700            let result =
1701                erode_euclidean_with_border_mode(&input, 0.0, ErosionBorderMode::default());
1702
1703            assert_eq!(result.as_raw(), input.as_raw());
1704        }
1705
1706        #[test]
1707        #[should_panic(expected = "radius must be >= 0.0")]
1708        fn erosion_negative_radius_panics() {
1709            let input = gray_image(3, 3, 0);
1710            let _ = erode_euclidean_with_border_mode(&input, -1.0, ErosionBorderMode::default());
1711        }
1712
1713        #[test]
1714        fn erosion_shrinks_foreground_touching_one_edge() {
1715            let mut input = gray_image(5, 5, 0);
1716            for y in 1..4 {
1717                for x in 0..3 {
1718                    input.put_pixel(x, y, Luma([255]));
1719                }
1720            }
1721
1722            let result =
1723                erode_euclidean_with_border_mode(&input, 1.0, ErosionBorderMode::default());
1724
1725            for y in 0..5 {
1726                for x in 0..5 {
1727                    let expected = if x == 1 && y == 2 { 255 } else { 0 };
1728                    assert_eq!(result.get_pixel(x, y).0[0], expected, "pixel ({x}, {y})");
1729                }
1730            }
1731        }
1732    }
1733
1734    mod apply_operations {
1735        use super::*;
1736
1737        mod unit {
1738            use super::*;
1739
1740            #[test]
1741            fn empty_operations_returns_clone() {
1742                let input = gray_image(3, 3, 100);
1743                let result = apply_operations(&input, &[]);
1744                assert_eq!(result.as_raw(), input.as_raw());
1745            }
1746
1747            #[test]
1748            fn single_threshold_operation() {
1749                let input = gray_image(2, 2, 200);
1750                let ops = vec![MaskOperation::Threshold { value: 128 }];
1751                let result = apply_operations(&input, &ops);
1752                for px in result.pixels() {
1753                    assert_eq!(px.0[0], 255); // 200 > 128
1754                }
1755            }
1756
1757            #[test]
1758            fn threshold_then_dilate() {
1759                let mut input = gray_image(5, 5, 0);
1760                input.put_pixel(2, 2, Luma([200]));
1761
1762                // threshold (200 > 128 = white), then dilate expands it
1763                let ops = vec![
1764                    MaskOperation::Threshold { value: 128 },
1765                    MaskOperation::Dilate { radius: 1.0 },
1766                ];
1767                let result = apply_operations(&input, &ops);
1768
1769                // center and neighbors should be white
1770                assert_eq!(result.get_pixel(2, 2).0[0], 255);
1771                assert_eq!(result.get_pixel(2, 1).0[0], 255);
1772            }
1773
1774            #[test]
1775            fn threshold_then_erode() {
1776                let mut input = gray_image(5, 5, 0);
1777                for y in 1..4 {
1778                    for x in 1..4 {
1779                        input.put_pixel(x, y, Luma([200]));
1780                    }
1781                }
1782
1783                let ops = vec![
1784                    MaskOperation::Threshold { value: 128 },
1785                    MaskOperation::Erode {
1786                        radius: 1.0,
1787                        border_mode: ErosionBorderMode::default(),
1788                    },
1789                ];
1790                let result = apply_operations(&input, &ops);
1791
1792                assert_eq!(result.get_pixel(2, 2).0[0], 255);
1793                assert_eq!(result.get_pixel(1, 1).0[0], 0);
1794            }
1795
1796            #[test]
1797            fn order_matters_blur_vs_threshold() {
1798                // blur then threshold vs threshold then blur produce different results
1799                let mut input = gray_image(5, 5, 0);
1800                input.put_pixel(2, 2, Luma([255])); // single white pixel
1801
1802                // blur first spreads the white, then threshold produces binary output
1803                let ops_blur_first = vec![
1804                    MaskOperation::Blur { sigma: 1.0 },
1805                    MaskOperation::Threshold { value: 50 },
1806                ];
1807                let result_blur_first = apply_operations(&input, &ops_blur_first);
1808
1809                // threshold first (255 > 50 = white), then blur produces soft edges
1810                let ops_threshold_first = vec![
1811                    MaskOperation::Threshold { value: 50 },
1812                    MaskOperation::Blur { sigma: 1.0 },
1813                ];
1814                let result_threshold_first = apply_operations(&input, &ops_threshold_first);
1815
1816                // blur->threshold is binary (all pixels are 0 or 255)
1817                let is_binary = result_blur_first
1818                    .pixels()
1819                    .all(|p| p.0[0] == 0 || p.0[0] == 255);
1820                assert!(is_binary);
1821
1822                // threshold->blur has intermediate grays (some 0 < value < 255)
1823                let has_intermediate = result_threshold_first
1824                    .pixels()
1825                    .any(|p| p.0[0] > 0 && p.0[0] < 255);
1826                assert!(has_intermediate);
1827
1828                // fallback: overall results must differ
1829                assert_ne!(result_blur_first.as_raw(), result_threshold_first.as_raw());
1830            }
1831
1832            #[test]
1833            fn order_matters_dilate_vs_blur() {
1834                let mut input = gray_image(5, 5, 0);
1835                input.put_pixel(2, 2, Luma([255]));
1836
1837                let ops_dilate_first = vec![
1838                    MaskOperation::Dilate { radius: 1.0 },
1839                    MaskOperation::Blur { sigma: 1.0 },
1840                ];
1841                let result_dilate_first = apply_operations(&input, &ops_dilate_first);
1842
1843                let ops_blur_first = vec![
1844                    MaskOperation::Blur { sigma: 1.0 },
1845                    MaskOperation::Dilate { radius: 1.0 },
1846                ];
1847                let result_blur_first = apply_operations(&input, &ops_blur_first);
1848
1849                assert_ne!(result_dilate_first.as_raw(), result_blur_first.as_raw());
1850            }
1851        }
1852
1853        mod prop {
1854            use super::*;
1855            use proptest::prelude::*;
1856
1857            proptest! {
1858                /// apply_operations: dimensions always preserved regardless of operations
1859                #[test]
1860                fn preserves_dimensions(
1861                    w in 1u32..10,
1862                    h in 1u32..10,
1863                    fill_value in proptest::num::u8::ANY
1864                ) {
1865                    let input = GrayImage::from_pixel(w, h, Luma([fill_value]));
1866
1867                    // Test with various operation combinations
1868                    let ops_threshold = vec![MaskOperation::Threshold { value: 128 }];
1869                    let result = apply_operations(&input, &ops_threshold);
1870                    prop_assert_eq!(result.dimensions(), (w, h));
1871
1872                    let ops_dilate = vec![MaskOperation::Dilate { radius: 1.0 }];
1873                    let result = apply_operations(&input, &ops_dilate);
1874                    prop_assert_eq!(result.dimensions(), (w, h));
1875
1876                    let ops_erode = vec![MaskOperation::Erode {
1877                        radius: 1.0,
1878                        border_mode: ErosionBorderMode::default(),
1879                    }];
1880                    let result = apply_operations(&input, &ops_erode);
1881                    prop_assert_eq!(result.dimensions(), (w, h));
1882
1883                    let ops_fill = vec![MaskOperation::FillHoles { threshold: 128 }];
1884                    let result = apply_operations(&input, &ops_fill);
1885                    prop_assert_eq!(result.dimensions(), (w, h));
1886
1887                    let ops_blur = vec![MaskOperation::Blur { sigma: 1.0 }];
1888                    let result = apply_operations(&input, &ops_blur);
1889                    prop_assert_eq!(result.dimensions(), (w, h));
1890                }
1891            }
1892        }
1893    }
1894
1895    mod mask_pipeline {
1896        use super::*;
1897
1898        mod unit {
1899            use super::*;
1900
1901            #[test]
1902            fn new_returns_empty() {
1903                let pipeline = MaskPipeline::new();
1904
1905                assert!(pipeline.is_empty());
1906                assert!(pipeline.operations().is_empty());
1907            }
1908
1909            #[test]
1910            fn blur_with_adds_blur_operation() {
1911                let pipeline = MaskPipeline::new().blur_with(3.0);
1912                let ops = pipeline.operations();
1913
1914                assert_eq!(ops.len(), 1);
1915                assert!(
1916                    matches!(ops[0], MaskOperation::Blur { sigma } if (sigma - 3.0).abs() < 0.001)
1917                );
1918            }
1919
1920            #[test]
1921            fn full_pipeline_order_and_values() {
1922                let pipeline = MaskPipeline::new()
1923                    .blur_with(2.0)
1924                    .threshold_with(128)
1925                    .dilate_with(5.0)
1926                    .erode_with_border_mode(3.0, ErosionBorderMode::OutsideIsUnknown)
1927                    .fill_holes_with(128);
1928                let ops = pipeline.operations();
1929
1930                assert_eq!(ops.len(), 5);
1931                assert!(
1932                    matches!(ops[0], MaskOperation::Blur { sigma } if (sigma - 2.0).abs() < 1e-6)
1933                );
1934                assert!(matches!(ops[1], MaskOperation::Threshold { value: 128 }));
1935                assert!(
1936                    matches!(ops[2], MaskOperation::Dilate { radius } if (radius - 5.0).abs() < 1e-6)
1937                );
1938                assert!(matches!(
1939                    ops[3],
1940                    MaskOperation::Erode { radius, border_mode }
1941                        if (radius - 3.0).abs() < 1e-6
1942                            && border_mode == ErosionBorderMode::OutsideIsUnknown
1943                ));
1944                assert!(matches!(
1945                    ops[4],
1946                    MaskOperation::FillHoles { threshold: 128 }
1947                ));
1948            }
1949
1950            #[test]
1951            fn partial_pipeline_keeps_inserted_order() {
1952                let pipeline = MaskPipeline::new().threshold_with(100).fill_holes_with(100);
1953                let ops = pipeline.operations();
1954
1955                assert_eq!(ops.len(), 2);
1956                assert!(matches!(ops[0], MaskOperation::Threshold { value: 100 }));
1957                assert!(matches!(
1958                    ops[1],
1959                    MaskOperation::FillHoles { threshold: 100 }
1960                ));
1961            }
1962
1963            #[test]
1964            fn component_filtering_operations_are_pipeline_steps() {
1965                let pipeline = MaskPipeline::new()
1966                    .threshold_with(100)
1967                    .keep_largest_component()
1968                    .keep_n_largest_components(3)
1969                    .drop_components_below_area(12.0);
1970                let ops = pipeline.operations();
1971
1972                assert_eq!(ops.len(), 4);
1973                assert!(matches!(ops[0], MaskOperation::Threshold { value: 100 }));
1974                assert!(matches!(
1975                    ops[1],
1976                    MaskOperation::KeepNLargestComponents {
1977                        count: 1,
1978                        threshold: 1
1979                    }
1980                ));
1981                assert!(matches!(
1982                    ops[2],
1983                    MaskOperation::KeepNLargestComponents {
1984                        count: 3,
1985                        threshold: 1
1986                    }
1987                ));
1988                assert!(matches!(
1989                    ops[3],
1990                    MaskOperation::DropComponentsBelowArea {
1991                        min_area,
1992                        threshold: 1
1993                    } if (min_area - 12.0).abs() < f32::EPSILON
1994                ));
1995            }
1996        }
1997    }
1998
1999    mod mask_handle_api {
2000        use super::*;
2001        use image::Rgb;
2002
2003        fn mask_handle() -> MaskHandle {
2004            MaskHandle {
2005                rgb_image: Arc::new(RgbImage::from_pixel(1, 1, Rgb([255, 255, 255]))),
2006                mask: GrayImage::from_pixel(1, 1, Luma([255])),
2007                mask_processing_defaults: MaskProcessingDefaults::default(),
2008                operations: Vec::new(),
2009            }
2010        }
2011
2012        fn mask_handle_with_images(rgb: RgbImage, mask: GrayImage) -> MaskHandle {
2013            MaskHandle {
2014                rgb_image: Arc::new(rgb),
2015                mask,
2016                mask_processing_defaults: MaskProcessingDefaults::default(),
2017                operations: Vec::new(),
2018            }
2019        }
2020
2021        fn single_pixel_mask_handle() -> MaskHandle {
2022            mask_handle_with_images(
2023                RgbImage::from_pixel(5, 5, Rgb([10, 20, 30])),
2024                GrayImage::from_fn(5, 5, |x, y| {
2025                    if x == 2 && y == 2 {
2026                        Luma([255])
2027                    } else {
2028                        Luma([0])
2029                    }
2030                }),
2031            )
2032        }
2033
2034        struct BoundingBoxVectorizer;
2035
2036        impl MaskVectorizer for BoundingBoxVectorizer {
2037            type Options = ();
2038            type Output = Option<BoundingBox>;
2039
2040            fn vectorize(
2041                &self,
2042                mask: &GrayImage,
2043                _options: &Self::Options,
2044            ) -> OutlineResult<Self::Output> {
2045                Ok(mask_bounding_box(mask, 1))
2046            }
2047        }
2048
2049        mod colorize {
2050            use super::*;
2051
2052            #[test]
2053            fn default_color_is_white_with_mask_alpha() {
2054                let color = MaskColor::default();
2055                assert_eq!(color.to_rgba8(), [255, 255, 255, 255]);
2056                assert!(matches!(color.alpha_mode(), MaskAlphaMode::UseMask));
2057            }
2058
2059            #[test]
2060            fn rgb_constructor_defaults_to_opaque_alpha() {
2061                let color = MaskColor::rgb(10, 20, 30);
2062
2063                assert_eq!(color.to_rgba8(), [10, 20, 30, 255]);
2064                assert!(matches!(color.alpha_mode(), MaskAlphaMode::UseMask));
2065            }
2066
2067            #[test]
2068            fn rgba_constructor_keeps_all_channels() {
2069                let color = MaskColor::rgba(10, 20, 30, 40);
2070
2071                assert_eq!(color.to_rgba8(), [10, 20, 30, 40]);
2072                assert!(matches!(color.alpha_mode(), MaskAlphaMode::UseMask));
2073            }
2074
2075            #[test]
2076            fn rgb_array_color_defaults_to_opaque_alpha() {
2077                let color = MaskColor::from([10, 20, 30]);
2078
2079                assert_eq!(color.to_rgba8(), [10, 20, 30, 255]);
2080                assert!(matches!(color.alpha_mode(), MaskAlphaMode::UseMask));
2081            }
2082
2083            #[test]
2084            fn rgb_pixel_color_defaults_to_opaque_alpha() {
2085                let color = MaskColor::from(Rgb([10, 20, 30]));
2086
2087                assert_eq!(color.to_rgba8(), [10, 20, 30, 255]);
2088                assert!(matches!(color.alpha_mode(), MaskAlphaMode::UseMask));
2089            }
2090
2091            #[test]
2092            fn rgba_pixel_color_keeps_all_channels() {
2093                let color = MaskColor::from(Rgba([10, 20, 30, 40]));
2094
2095                assert_eq!(color.to_rgba8(), [10, 20, 30, 40]);
2096                assert!(matches!(color.alpha_mode(), MaskAlphaMode::UseMask));
2097            }
2098
2099            #[test]
2100            fn use_mask_mode_sets_alpha_from_mask() {
2101                let mask = gray_image(2, 2, 128);
2102                let result = colorize_mask(&mask, MaskColor::new([0, 180, 255, 255]));
2103
2104                for px in result.pixels() {
2105                    assert_eq!(px.0, [0, 180, 255, 128]);
2106                }
2107            }
2108
2109            #[test]
2110            fn base_alpha_is_multiplied_with_mask_alpha() {
2111                let mask = gray_image(1, 1, 128);
2112                let result = colorize_mask(&mask, MaskColor::new([255, 255, 255, 128]));
2113
2114                assert_eq!(result.get_pixel(0, 0).0, [255, 255, 255, 64]);
2115            }
2116
2117            #[test]
2118            fn scale_mode_scales_mask_alpha() {
2119                let mask = gray_image(1, 1, 200);
2120                let color =
2121                    MaskColor::new([255, 0, 0, 255]).with_alpha_mode(MaskAlphaMode::Scale(0.5));
2122                let result = colorize_mask(&mask, color);
2123
2124                assert_eq!(result.get_pixel(0, 0).0, [255, 0, 0, 100]);
2125            }
2126
2127            #[test]
2128            fn solid_mode_uses_alpha_for_nonzero_mask() {
2129                let mask =
2130                    GrayImage::from_fn(2, 1, |x, _| if x == 0 { Luma([0]) } else { Luma([25]) });
2131                let color =
2132                    MaskColor::new([0, 255, 0, 255]).with_alpha_mode(MaskAlphaMode::Solid(200));
2133                let result = colorize_mask(&mask, color);
2134
2135                assert_eq!(result.get_pixel(0, 0).0, [0, 255, 0, 0]);
2136                assert_eq!(result.get_pixel(1, 0).0, [0, 255, 0, 200]);
2137            }
2138        }
2139
2140        mod erode_builder {
2141            use super::*;
2142
2143            #[test]
2144            fn mask_handle_erode_uses_default_radius() {
2145                let handle = mask_handle().erode();
2146                assert!(matches!(
2147                    handle.operations.as_slice(),
2148                    [MaskOperation::Erode { radius, border_mode }]
2149                        if (*radius - MaskProcessingDefaults::default().erosion_radius).abs() < f32::EPSILON
2150                            && *border_mode == MaskProcessingDefaults::default().erosion_border_mode
2151                ));
2152            }
2153
2154            #[test]
2155            fn mask_handle_erode_with_uses_custom_radius() {
2156                let handle = mask_handle().erode_with(3.0);
2157                assert!(matches!(
2158                    handle.operations.as_slice(),
2159                    [MaskOperation::Erode { radius, border_mode }]
2160                        if (*radius - 3.0).abs() < f32::EPSILON
2161                            && *border_mode == MaskProcessingDefaults::default().erosion_border_mode
2162                ));
2163            }
2164
2165            #[test]
2166            fn mask_handle_erode_with_border_mode_uses_custom_mode() {
2167                let handle =
2168                    mask_handle().erode_with_border_mode(3.0, ErosionBorderMode::OutsideIsUnknown);
2169                assert!(matches!(
2170                    handle.operations.as_slice(),
2171                    [MaskOperation::Erode { radius, border_mode }]
2172                        if (*radius - 3.0).abs() < f32::EPSILON
2173                            && *border_mode == ErosionBorderMode::OutsideIsUnknown
2174                ));
2175            }
2176        }
2177
2178        mod fill_holes_builder {
2179            use super::*;
2180
2181            #[test]
2182            fn mask_handle_fill_holes_with_uses_custom_threshold() {
2183                let handle = mask_handle().fill_holes_with(180);
2184                assert!(matches!(
2185                    handle.operations.as_slice(),
2186                    [MaskOperation::FillHoles { threshold: 180 }]
2187                ));
2188            }
2189        }
2190
2191        mod processing_pipeline {
2192            use super::*;
2193
2194            #[test]
2195            fn chain_and_pipeline_are_equivalent() {
2196                let pipeline = MaskPipeline::new()
2197                    .threshold_with(128)
2198                    .dilate_with(1.0)
2199                    .erode_with(1.0);
2200
2201                let chained = single_pixel_mask_handle()
2202                    .threshold_with(128)
2203                    .dilate_with(1.0)
2204                    .erode_with(1.0)
2205                    .processed()
2206                    .expect("chain should process")
2207                    .into_image();
2208                let piped = single_pixel_mask_handle()
2209                    .processed_with(&pipeline)
2210                    .expect("pipeline should process")
2211                    .into_image();
2212
2213                assert_eq!(chained.as_raw(), piped.as_raw());
2214            }
2215
2216            #[test]
2217            fn processed_with_appends_after_chained_operations() {
2218                let pipeline = MaskPipeline::new().dilate_with(1.0);
2219
2220                let appended = single_pixel_mask_handle()
2221                    .threshold_with(128)
2222                    .processed_with(&pipeline)
2223                    .expect("processing should succeed")
2224                    .into_image();
2225                let chained = single_pixel_mask_handle()
2226                    .threshold_with(128)
2227                    .dilate_with(1.0)
2228                    .processed()
2229                    .expect("processing should succeed")
2230                    .into_image();
2231
2232                assert_eq!(appended.as_raw(), chained.as_raw());
2233            }
2234
2235            #[test]
2236            fn processed_without_chained_operations_is_identity() {
2237                let source = single_pixel_mask_handle().into_image();
2238                let handle = MaskHandle {
2239                    rgb_image: Arc::new(RgbImage::from_pixel(5, 5, Rgb([10, 20, 30]))),
2240                    mask: source.clone(),
2241                    mask_processing_defaults: MaskProcessingDefaults::default(),
2242                    operations: Vec::new(),
2243                };
2244
2245                let mask = handle
2246                    .processed()
2247                    .expect("empty processing should succeed")
2248                    .into_image();
2249
2250                assert_eq!(mask.as_raw(), source.as_raw());
2251            }
2252
2253            #[test]
2254            fn no_arg_methods_use_custom_defaults() {
2255                let defaults = MaskProcessingDefaults {
2256                    blur_sigma: 2.0,
2257                    mask_threshold: 180,
2258                    dilation_radius: 3.0,
2259                    erosion_radius: 4.0,
2260                    erosion_border_mode: ErosionBorderMode::OutsideIsUnknown,
2261                };
2262                let handle = MaskHandle {
2263                    rgb_image: Arc::new(RgbImage::from_pixel(1, 1, Rgb([255, 255, 255]))),
2264                    mask: GrayImage::from_pixel(1, 1, Luma([255])),
2265                    mask_processing_defaults: defaults,
2266                    operations: Vec::new(),
2267                }
2268                .blur()
2269                .threshold()
2270                .dilate()
2271                .erode()
2272                .fill_holes();
2273
2274                assert!(matches!(
2275                    handle.operations.as_slice(),
2276                    [
2277                        MaskOperation::Blur { sigma },
2278                        MaskOperation::Threshold { value: 180 },
2279                        MaskOperation::Dilate { radius: dilation_radius },
2280                        MaskOperation::Erode {
2281                            radius: erosion_radius,
2282                            border_mode: ErosionBorderMode::OutsideIsUnknown
2283                        },
2284                        MaskOperation::FillHoles { threshold: 180 }
2285                    ] if (*sigma - 2.0).abs() < f32::EPSILON
2286                        && (*dilation_radius - 3.0).abs() < f32::EPSILON
2287                        && (*erosion_radius - 4.0).abs() < f32::EPSILON
2288                ));
2289            }
2290
2291            #[test]
2292            fn component_filtering_methods_add_operations() {
2293                let defaults = MaskProcessingDefaults {
2294                    mask_threshold: 180,
2295                    ..MaskProcessingDefaults::default()
2296                };
2297                let handle = MaskHandle {
2298                    rgb_image: Arc::new(RgbImage::from_pixel(1, 1, Rgb([255, 255, 255]))),
2299                    mask: GrayImage::from_pixel(1, 1, Luma([255])),
2300                    mask_processing_defaults: defaults,
2301                    operations: Vec::new(),
2302                }
2303                .keep_largest_component()
2304                .keep_n_largest_components(2)
2305                .drop_components_below_area(8.0);
2306
2307                assert!(matches!(
2308                    handle.operations.as_slice(),
2309                    [
2310                        MaskOperation::KeepNLargestComponents {
2311                            count: 1,
2312                            threshold: 180
2313                        },
2314                        MaskOperation::KeepNLargestComponents {
2315                            count: 2,
2316                            threshold: 180
2317                        },
2318                        MaskOperation::DropComponentsBelowArea {
2319                            min_area,
2320                            threshold: 180
2321                        }
2322                    ] if (*min_area - 8.0).abs() < f32::EPSILON
2323                ));
2324            }
2325        }
2326
2327        mod geometry {
2328            use super::*;
2329
2330            #[test]
2331            fn mask_handle_dimensions_reports_current_canvas() {
2332                let cropped = single_pixel_mask_handle()
2333                    .crop(BoundingBox::new(1, 1, 2, 3))
2334                    .expect("bounds are inside the image");
2335                assert_eq!(cropped.dimensions(), (2, 3));
2336
2337                let padded = cropped.pad(Padding::new(1, 2, 3, 4));
2338                assert_eq!(padded.dimensions(), (6, 9));
2339            }
2340
2341            #[test]
2342            fn mask_handle_pad_updates_mask_and_foreground_canvas() {
2343                let rgb = RgbImage::from_pixel(2, 2, Rgb([10, 20, 30]));
2344                let mut mask = GrayImage::from_pixel(2, 2, Luma([0]));
2345                mask.put_pixel(1, 1, Luma([255]));
2346
2347                let padded = mask_handle_with_images(rgb, mask).pad(Padding::new(1, 2, 3, 4));
2348
2349                assert_eq!(padded.dimensions(), (6, 8));
2350                let foreground = padded.foreground().expect("foreground should compose");
2351                assert_eq!(foreground.dimensions(), (6, 8));
2352                assert_eq!(foreground.image().get_pixel(2, 3)[3], 255);
2353            }
2354
2355            #[test]
2356            fn mask_handle_crop_to_content_crops_mask_and_rgb_together() {
2357                let rgb = RgbImage::from_fn(4, 4, |x, y| Rgb([x as u8, y as u8, 0]));
2358                let mut mask = GrayImage::from_pixel(4, 4, Luma([0]));
2359                mask.put_pixel(2, 1, Luma([255]));
2360                mask.put_pixel(2, 2, Luma([255]));
2361
2362                let cropped = mask_handle_with_images(rgb, mask)
2363                    .crop_to_content()
2364                    .expect("mask has content");
2365
2366                assert_eq!(cropped.dimensions(), (1, 2));
2367                let foreground = cropped.foreground().expect("foreground should compose");
2368                assert_eq!(foreground.dimensions(), (1, 2));
2369                assert_eq!(foreground.image().get_pixel(0, 0)[0], 2);
2370                assert_eq!(foreground.image().get_pixel(0, 0)[1], 1);
2371                assert_eq!(foreground.image().get_pixel(0, 0)[3], 255);
2372            }
2373
2374            #[test]
2375            fn mask_handle_crop_uses_requested_bounds() {
2376                let rgb = RgbImage::from_fn(4, 4, |x, y| Rgb([x as u8, y as u8, 0]));
2377                let mut mask = GrayImage::from_pixel(4, 4, Luma([0]));
2378                mask.put_pixel(2, 1, Luma([255]));
2379
2380                let cropped = mask_handle_with_images(rgb, mask)
2381                    .crop(BoundingBox::new(1, 1, 2, 2))
2382                    .expect("bounds are inside the image");
2383
2384                assert_eq!(cropped.dimensions(), (2, 2));
2385                assert_eq!(cropped.as_raw_mask().get_pixel(1, 0)[0], 255);
2386                let foreground = cropped.foreground().expect("foreground should compose");
2387                assert_eq!(foreground.image().get_pixel(1, 0)[0], 2);
2388                assert_eq!(foreground.image().get_pixel(1, 0)[1], 1);
2389                assert_eq!(foreground.image().get_pixel(1, 0)[3], 255);
2390            }
2391
2392            #[test]
2393            fn mask_handle_crop_rejects_invalid_bounds() {
2394                assert!(mask_handle().crop(BoundingBox::new(4, 4, 2, 2)).is_none());
2395                assert!(mask_handle().crop(BoundingBox::new(0, 0, 0, 1)).is_none());
2396            }
2397
2398            #[test]
2399            fn mask_handle_crop_to_content_with_ignores_low_values() {
2400                let rgb = RgbImage::from_fn(5, 3, |x, y| Rgb([x as u8, y as u8, 0]));
2401                let mut mask = GrayImage::from_pixel(5, 3, Luma([0]));
2402                mask.put_pixel(1, 1, Luma([32]));
2403                mask.put_pixel(3, 1, Luma([200]));
2404
2405                let cropped = mask_handle_with_images(rgb, mask)
2406                    .crop_to_content_with(128)
2407                    .expect("mask has content above threshold");
2408
2409                assert_eq!(cropped.dimensions(), (1, 1));
2410                assert_eq!(cropped.as_raw_mask().get_pixel(0, 0)[0], 200);
2411                let foreground = cropped.foreground().expect("foreground should compose");
2412                assert_eq!(foreground.image().get_pixel(0, 0)[0], 3);
2413                assert_eq!(foreground.image().get_pixel(0, 0)[1], 1);
2414                assert_eq!(foreground.image().get_pixel(0, 0)[3], 200);
2415            }
2416        }
2417
2418        mod pending_operations {
2419            use super::*;
2420
2421            #[test]
2422            fn mask_handle_pad_applies_pending_operations_first() {
2423                let padded = single_pixel_mask_handle()
2424                    .dilate_with(1.0)
2425                    .pad(Padding::new(1, 2, 0, 0));
2426
2427                assert_eq!(
2428                    mask_bounding_box(padded.as_raw_mask(), 1),
2429                    Some(BoundingBox::new(2, 3, 3, 3))
2430                );
2431            }
2432
2433            #[test]
2434            fn mask_handle_into_image_applies_pending_operations() {
2435                let mask = single_pixel_mask_handle().dilate_with(1.0).into_image();
2436
2437                assert_eq!(
2438                    mask_bounding_box(&mask, 1),
2439                    Some(BoundingBox::new(1, 1, 3, 3))
2440                );
2441            }
2442
2443            #[test]
2444            fn mask_handle_save_applies_pending_operations() {
2445                let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2446                let path = temp_dir.path().join("mask.png");
2447
2448                single_pixel_mask_handle()
2449                    .dilate_with(1.0)
2450                    .save(&path)
2451                    .expect("mask should save");
2452
2453                let saved = image::open(&path)
2454                    .expect("saved mask should load")
2455                    .to_luma8();
2456
2457                assert_eq!(
2458                    mask_bounding_box(&saved, 1),
2459                    Some(BoundingBox::new(1, 1, 3, 3))
2460                );
2461            }
2462
2463            #[test]
2464            fn mask_handle_foreground_applies_pending_operations() {
2465                let foreground = single_pixel_mask_handle()
2466                    .dilate_with(1.0)
2467                    .foreground()
2468                    .expect("foreground should compose");
2469
2470                assert_eq!(foreground.image().get_pixel(1, 2)[3], 255);
2471                assert_eq!(foreground.image().get_pixel(0, 0)[3], 0);
2472            }
2473
2474            #[test]
2475            fn mask_handle_colorize_applies_pending_operations() {
2476                let colorized = single_pixel_mask_handle()
2477                    .dilate_with(1.0)
2478                    .colorize([0, 180, 255, 255]);
2479
2480                assert_eq!(colorized.get_pixel(1, 2).0, [0, 180, 255, 255]);
2481                assert_eq!(colorized.get_pixel(0, 0).0, [0, 180, 255, 0]);
2482            }
2483
2484            #[test]
2485            fn mask_handle_trace_applies_pending_operations() {
2486                let bounds = single_pixel_mask_handle()
2487                    .dilate_with(1.0)
2488                    .trace(&BoundingBoxVectorizer, &())
2489                    .expect("trace should run");
2490
2491                assert_eq!(bounds, Some(BoundingBox::new(1, 1, 3, 3)));
2492            }
2493        }
2494    }
2495
2496    #[cfg(feature = "vectorizer-vtracer")]
2497    mod gray_to_color_image_rgba {
2498        use super::*;
2499
2500        mod unit {
2501            use super::*;
2502
2503            #[test]
2504            fn passthrough_no_threshold_no_invert() {
2505                let mut input = GrayImage::new(2, 1);
2506                input.put_pixel(0, 0, Luma([0]));
2507                input.put_pixel(1, 0, Luma([128]));
2508
2509                let result = gray_to_color_image_rgba(&input, None, false);
2510
2511                assert_eq!(result.width, 2);
2512                assert_eq!(result.height, 1);
2513                // pixel 0: gray=0
2514                assert_eq!(result.pixels[0..4], [0, 0, 0, 255]);
2515                // pixel 1: gray=128
2516                assert_eq!(result.pixels[4..8], [128, 128, 128, 255]);
2517            }
2518
2519            #[test]
2520            fn threshold_boundary() {
2521                // threshold=128: >= 128 becomes 255, < 128 becomes 0
2522                let mut input = GrayImage::new(3, 1);
2523                input.put_pixel(0, 0, Luma([127])); // below
2524                input.put_pixel(1, 0, Luma([128])); // exact
2525                input.put_pixel(2, 0, Luma([129])); // above
2526
2527                let result = gray_to_color_image_rgba(&input, Some(128), false);
2528
2529                assert_eq!(result.pixels[0..4], [0, 0, 0, 255]); // 127 < 128
2530                assert_eq!(result.pixels[4..8], [255, 255, 255, 255]); // 128 >= 128
2531                assert_eq!(result.pixels[8..12], [255, 255, 255, 255]); // 129 >= 128
2532            }
2533
2534            #[test]
2535            fn invert_without_threshold() {
2536                let mut input = GrayImage::new(2, 1);
2537                input.put_pixel(0, 0, Luma([0]));
2538                input.put_pixel(1, 0, Luma([255]));
2539
2540                let result = gray_to_color_image_rgba(&input, None, true);
2541
2542                assert_eq!(result.pixels[0..4], [255, 255, 255, 255]); // 255 - 0
2543                assert_eq!(result.pixels[4..8], [0, 0, 0, 255]); // 255 - 255
2544            }
2545
2546            #[test]
2547            fn invert_with_threshold() {
2548                let mut input = GrayImage::new(2, 1);
2549                input.put_pixel(0, 0, Luma([100])); // < 128, becomes 0, then inverted to 255
2550                input.put_pixel(1, 0, Luma([200])); // >= 128, becomes 255, then inverted to 0
2551
2552                let result = gray_to_color_image_rgba(&input, Some(128), true);
2553
2554                assert_eq!(result.pixels[0..4], [255, 255, 255, 255]);
2555                assert_eq!(result.pixels[4..8], [0, 0, 0, 255]);
2556            }
2557
2558            #[test]
2559            fn alpha_always_255() {
2560                let input = gray_image(3, 3, 100);
2561                let result = gray_to_color_image_rgba(&input, None, false);
2562
2563                // check every 4th byte (alpha channel)
2564                for i in 0..9 {
2565                    assert_eq!(result.pixels[i * 4 + 3], 255);
2566                }
2567            }
2568
2569            #[test]
2570            fn dimensions_correct() {
2571                let input = gray_image(7, 5, 128);
2572                let result = gray_to_color_image_rgba(&input, None, false);
2573
2574                assert_eq!(result.width, 7);
2575                assert_eq!(result.height, 5);
2576                assert_eq!(result.pixels.len(), 7 * 5 * 4);
2577            }
2578        }
2579    }
2580}