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#[derive(Debug, Clone, PartialEq)]
27#[non_exhaustive]
28pub enum MaskOperation {
29 Blur {
31 sigma: f32,
33 },
34 Threshold {
36 value: u8,
38 },
39 Dilate {
41 radius: f32,
43 },
44 Erode {
46 radius: f32,
48 border_mode: ErosionBorderMode,
50 },
51 FillHoles {
53 threshold: u8,
55 },
56 KeepNLargestComponents {
58 count: usize,
60 threshold: u8,
62 },
63 DropComponentsBelowArea {
65 min_area: f32,
67 threshold: u8,
69 },
70}
71
72impl MaskOperation {
73 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
100pub fn apply_operations(source: &GrayImage, operations: &[MaskOperation]) -> GrayImage {
107 let mut current = source.clone();
108 for op in operations {
109 current = op.apply(¤t);
110 }
111 current
112}
113
114#[derive(Debug, Clone, Default, PartialEq)]
116pub struct MaskPipeline {
117 operations: Vec<MaskOperation>,
118}
119
120impl MaskPipeline {
121 pub fn new() -> Self {
123 Self::default()
124 }
125
126 pub fn is_empty(&self) -> bool {
128 self.operations.is_empty()
129 }
130
131 pub fn operations(&self) -> &[MaskOperation] {
133 &self.operations
134 }
135
136 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 pub fn threshold_with(mut self, value: u8) -> Self {
149 self.operations.push(MaskOperation::Threshold { value });
150 self
151 }
152
153 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 pub fn erode_with(self, radius: f32) -> Self {
174 self.erode_with_border_mode(radius, ErosionBorderMode::default())
175 }
176
177 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 pub fn fill_holes_with(mut self, threshold: u8) -> Self {
195 self.operations.push(MaskOperation::FillHoles { threshold });
196 self
197 }
198
199 pub fn keep_largest_component(self) -> Self {
201 self.keep_n_largest_components(1)
202 }
203
204 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 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
229pub 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#[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
276pub 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
358pub 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
384pub 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
423pub 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
433pub 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 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 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 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 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#[derive(Clone, Copy, Debug, Default, PartialEq)]
523pub enum MaskAlphaMode {
524 #[default]
526 UseMask,
527 Scale(f32),
529 Solid(u8),
531}
532
533#[derive(Debug, Clone, Copy, PartialEq)]
535pub struct MaskColor {
536 color: [u8; 4],
538 alpha_mode: MaskAlphaMode,
540}
541
542impl MaskColor {
543 pub const fn new(color: [u8; 4]) -> Self {
545 Self::from_rgba(color)
546 }
547
548 pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
550 Self::from_rgb([r, g, b])
551 }
552
553 pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
555 Self::from_rgba([r, g, b, a])
556 }
557
558 pub const fn from_rgb([r, g, b]: [u8; 3]) -> Self {
560 Self::from_rgba([r, g, b, 255])
561 }
562
563 pub const fn from_rgba(color: [u8; 4]) -> Self {
565 Self {
566 color,
567 alpha_mode: MaskAlphaMode::UseMask,
568 }
569 }
570
571 pub const fn to_rgba8(self) -> [u8; 4] {
573 self.color
574 }
575
576 pub const fn alpha_mode(self) -> MaskAlphaMode {
578 self.alpha_mode
579 }
580
581 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
635pub 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#[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 pub fn to_raw_mask(&self) -> GrayImage {
717 self.mask.clone()
718 }
719
720 pub fn as_raw_mask(&self) -> &GrayImage {
722 &self.mask
723 }
724
725 pub fn dimensions(&self) -> (u32, u32) {
727 self.mask.dimensions()
728 }
729
730 #[deprecated(note = "use to_raw_mask()")]
732 pub fn raw(&self) -> GrayImage {
733 self.to_raw_mask()
734 }
735
736 #[deprecated(note = "use as_raw_mask()")]
738 pub fn image(&self) -> &GrayImage {
739 self.as_raw_mask()
740 }
741
742 pub fn into_image(self) -> GrayImage {
744 self.resolve_pending_operations().mask
745 }
746
747 pub fn save(&self, path: impl AsRef<Path>) -> OutlineResult<()> {
749 self.resolved_mask().save(path)?;
750 Ok(())
751 }
752
753 pub fn bounding_box(&self) -> Option<BoundingBox> {
755 self.bounding_box_with(1)
756 }
757
758 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 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 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 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 pub fn threshold_with(mut self, value: u8) -> Self {
796 self.operations.push(MaskOperation::Threshold { value });
797 self
798 }
799
800 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 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 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 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 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 pub fn fill_holes(self) -> Self {
898 let threshold = self.mask_processing_defaults.mask_threshold;
899 self.fill_holes_with(threshold)
900 }
901
902 pub fn fill_holes_with(mut self, threshold: u8) -> Self {
907 self.operations.push(MaskOperation::FillHoles { threshold });
908 self
909 }
910
911 pub fn keep_largest_component(self) -> Self {
916 self.keep_n_largest_components(1)
917 }
918
919 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 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 pub fn processed(self) -> OutlineResult<MaskHandle> {
951 self.process_with_pipeline(None)
952 }
953
954 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 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 pub fn colorize(&self, color: impl Into<MaskColor>) -> RgbaImage {
985 let mask = self.resolved_mask();
986 colorize_mask(&mask, color)
987 }
988
989 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 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 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 pub fn crop_to_content(self) -> Option<Self> {
1029 self.crop_to_content_with(1)
1030 }
1031
1032 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 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 let mut input = GrayImage::new(2, 2);
1105 input.put_pixel(0, 0, Luma([127])); input.put_pixel(1, 0, Luma([128])); input.put_pixel(0, 1, Luma([129])); input.put_pixel(1, 1, Luma([255])); let result = threshold_mask(&input, 128);
1111
1112 assert_eq!(result.get_pixel(0, 0).0[0], 0); assert_eq!(result.get_pixel(1, 0).0[0], 0); assert_eq!(result.get_pixel(0, 1).0[0], 255); assert_eq!(result.get_pixel(1, 1).0[0], 255); }
1117
1118 #[test]
1119 fn threshold_zero() {
1120 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); assert_eq!(result.get_pixel(1, 0).0[0], 255); }
1130
1131 #[test]
1132 fn threshold_255() {
1133 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); assert_eq!(result.get_pixel(1, 0).0[0], 0); }
1143 }
1144
1145 mod prop {
1146 use super::*;
1147 use proptest::prelude::*;
1148
1149 proptest! {
1150 #[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 #[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 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 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); }
1247 }
1248
1249 mod prop {
1250 use super::*;
1251 use proptest::prelude::*;
1252
1253 proptest! {
1254 #[test]
1256 fn clamps_out_of_range_values(
1257 values in proptest::collection::vec(-10.0f32..10.0f32, 1..100)
1258 ) {
1259 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 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 prop_assert_eq!(result.dimensions(), (len as u32, 1));
1275 }
1276
1277 #[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 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); let result = fill_mask_holes(&input, 128);
1311 for px in result.pixels() {
1312 assert_eq!(px.0[0], 0); }
1314 }
1315
1316 #[test]
1317 fn interior_hole_filled() {
1318 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 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 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 for y in 0..4 {
1355 assert_eq!(result.get_pixel(0, y).0[0], 0);
1356 }
1357 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 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])); input.put_pixel(0, 2, Luma([0])); let result = fill_mask_holes(&input, 128);
1395
1396 assert_eq!(result.get_pixel(0, 2).0[0], 0);
1398 assert_eq!(result.get_pixel(1, 1).0[0], 255);
1400 }
1401
1402 #[test]
1403 fn threshold_changes_border_connectivity() {
1404 let mut input = gray_image(3, 3, 255);
1408 input.put_pixel(0, 1, Luma([110])); 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 let mut input = gray_image(5, 5, 200); input.put_pixel(2, 2, Luma([50])); 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 #[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 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 assert_eq!(result.get_pixel(2, 2).0[0], 255);
1563 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 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 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 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 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 #[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); }
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 let ops = vec![
1764 MaskOperation::Threshold { value: 128 },
1765 MaskOperation::Dilate { radius: 1.0 },
1766 ];
1767 let result = apply_operations(&input, &ops);
1768
1769 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 let mut input = gray_image(5, 5, 0);
1800 input.put_pixel(2, 2, Luma([255])); 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 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 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 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 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 #[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 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 assert_eq!(result.pixels[0..4], [0, 0, 0, 255]);
2515 assert_eq!(result.pixels[4..8], [128, 128, 128, 255]);
2517 }
2518
2519 #[test]
2520 fn threshold_boundary() {
2521 let mut input = GrayImage::new(3, 1);
2523 input.put_pixel(0, 0, Luma([127])); input.put_pixel(1, 0, Luma([128])); input.put_pixel(2, 0, Luma([129])); let result = gray_to_color_image_rgba(&input, Some(128), false);
2528
2529 assert_eq!(result.pixels[0..4], [0, 0, 0, 255]); assert_eq!(result.pixels[4..8], [255, 255, 255, 255]); assert_eq!(result.pixels[8..12], [255, 255, 255, 255]); }
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]); assert_eq!(result.pixels[4..8], [0, 0, 0, 255]); }
2545
2546 #[test]
2547 fn invert_with_threshold() {
2548 let mut input = GrayImage::new(2, 1);
2549 input.put_pixel(0, 0, Luma([100])); input.put_pixel(1, 0, Luma([200])); 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 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}