use std::borrow::Cow;
use std::collections::VecDeque;
use std::path::Path;
use std::sync::Arc;
use image::{GrayImage, Luma, Rgb, RgbImage, Rgba, RgbaImage};
use imageproc::contrast::{ThresholdType, threshold as ip_threshold};
use imageproc::distance_transform::euclidean_squared_distance_transform;
use imageproc::filter::gaussian_blur_f32;
use ndarray::Array2;
use crate::MaskVectorizer;
use crate::OutlineResult;
use crate::config::{ErosionBorderMode, MaskProcessingDefaults};
use crate::foreground::{ForegroundHandle, compose_foreground};
use crate::geometry::{
BoundingBox, Padding, crop_bounds_fit_image, crop_gray_image, crop_rgb_image,
mask_bounding_box, pad_gray_image, pad_rgb_image,
};
#[cfg(feature = "vectorizer-vtracer")]
use vtracer::ColorImage;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum MaskOperation {
Blur {
sigma: f32,
},
Threshold {
value: u8,
},
Dilate {
radius: f32,
},
Erode {
radius: f32,
border_mode: ErosionBorderMode,
},
FillHoles {
threshold: u8,
},
}
impl MaskOperation {
pub fn apply(&self, input: &GrayImage) -> GrayImage {
match self {
MaskOperation::Blur { sigma } => gaussian_blur_f32(input, *sigma),
MaskOperation::Threshold { value } => threshold_mask(input, *value),
MaskOperation::Dilate { radius } => dilate_euclidean(input, *radius),
MaskOperation::Erode {
radius,
border_mode,
} => erode_euclidean_with_border_mode(input, *radius, *border_mode),
MaskOperation::FillHoles { threshold } => fill_mask_holes(input, *threshold),
}
}
}
pub fn apply_operations(source: &GrayImage, operations: &[MaskOperation]) -> GrayImage {
let mut current = source.clone();
for op in operations {
current = op.apply(¤t);
}
current
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MaskPipeline {
operations: Vec<MaskOperation>,
}
impl MaskPipeline {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.operations.is_empty()
}
pub fn operations(&self) -> &[MaskOperation] {
&self.operations
}
pub fn blur_with(mut self, sigma: f32) -> Self {
assert!(sigma > 0.0, "sigma must be > 0.0");
self.operations.push(MaskOperation::Blur { sigma });
self
}
pub fn threshold_with(mut self, value: u8) -> Self {
self.operations.push(MaskOperation::Threshold { value });
self
}
pub fn dilate_with(mut self, radius: f32) -> Self {
assert_nonnegative_radius(radius);
self.operations.push(MaskOperation::Dilate { radius });
self
}
pub fn erode_with(self, radius: f32) -> Self {
self.erode_with_border_mode(radius, ErosionBorderMode::default())
}
pub fn erode_with_border_mode(mut self, radius: f32, border_mode: ErosionBorderMode) -> Self {
assert_nonnegative_radius(radius);
self.operations.push(MaskOperation::Erode {
radius,
border_mode,
});
self
}
pub fn fill_holes_with(mut self, threshold: u8) -> Self {
self.operations.push(MaskOperation::FillHoles { threshold });
self
}
}
pub fn array_to_gray_image(array: &Array2<f32>) -> GrayImage {
let (h, w) = array.dim();
GrayImage::from_fn(w as u32, h as u32, |x, y| {
let value = array[[y as usize, x as usize]].clamp(0.0, 1.0);
let byte = (value * 255.0 + 0.5) as u8;
Luma([byte])
})
}
#[cfg(feature = "vectorizer-vtracer")]
pub fn gray_to_color_image_rgba(
gray: &GrayImage,
threshold: Option<u8>,
invert: bool,
) -> ColorImage {
let (w, h) = gray.dimensions();
let (w_usize, h_usize) = (w as usize, h as usize);
let mut rgba = vec![0u8; 4 * w_usize * h_usize];
for (i, gray_pixel) in gray.pixels().enumerate() {
let Luma([g]) = gray_pixel;
let base = if let Some(t) = threshold {
if *g >= t { 255 } else { 0 }
} else {
*g
};
let v = if invert {
255u8.saturating_sub(base)
} else {
base
};
let idx = i * 4;
rgba[idx] = v;
rgba[idx + 1] = v;
rgba[idx + 2] = v;
rgba[idx + 3] = 255;
}
ColorImage {
pixels: rgba,
width: w_usize,
height: h_usize,
}
}
pub fn threshold_mask(gray: &GrayImage, thr: u8) -> GrayImage {
ip_threshold(gray, thr, ThresholdType::Binary)
}
fn assert_nonnegative_radius(radius: f32) {
assert!(radius >= 0.0, "radius must be >= 0.0");
}
pub fn dilate_euclidean(mask_bin: &GrayImage, r: f32) -> GrayImage {
assert_nonnegative_radius(r);
if r == 0.0 {
return mask_bin.clone();
}
let d2 = euclidean_squared_distance_transform(mask_bin);
let r2: f64 = (r as f64) * (r as f64);
let (w, h) = mask_bin.dimensions();
let mut out = GrayImage::new(w, h);
for (o_pixel, d2pixel) in out.pixels_mut().zip(d2.pixels()) {
let d2xy: f64 = d2pixel[0];
let v: u8 = if d2xy <= r2 { 255 } else { 0 };
*o_pixel = Luma([v]);
}
out
}
pub fn erode_euclidean_with_border_mode(
mask_bin: &GrayImage,
r: f32,
border_mode: ErosionBorderMode,
) -> GrayImage {
assert_nonnegative_radius(r);
if r == 0.0 {
return mask_bin.clone();
}
let inverted = invert_mask(mask_bin);
match border_mode {
ErosionBorderMode::OutsideIsUnknown => invert_mask(&dilate_euclidean(&inverted, r)),
ErosionBorderMode::OutsideIsBackground => {
let padding = Padding::uniform(1);
let padded = pad_gray_image(&inverted, padding, 255);
let dilated = dilate_euclidean(&padded, r);
let cropped = crop_gray_image(
&dilated,
BoundingBox::new(
padding.left,
padding.top,
mask_bin.width(),
mask_bin.height(),
),
);
invert_mask(&cropped)
}
}
}
pub fn invert_mask(mask: &GrayImage) -> GrayImage {
let (w, h) = mask.dimensions();
let mut out = GrayImage::new(w, h);
for (src, dst) in mask.pixels().zip(out.pixels_mut()) {
*dst = Luma([255u8.saturating_sub(src[0])]);
}
out
}
pub fn fill_mask_holes(mask: &GrayImage, threshold: u8) -> GrayImage {
let (w, h) = mask.dimensions();
if w == 0 || h == 0 {
return mask.clone();
}
let (w_usize, h_usize) = (w as usize, h as usize);
let mut visited = vec![false; w_usize * h_usize];
let mut queue = VecDeque::new();
let idx = |x: u32, y: u32| -> usize { (y as usize) * w_usize + x as usize };
let mask_raw = mask.as_raw();
for x in 0..w {
if mask_raw[idx(x, 0)] < threshold {
queue.push_back((x, 0));
}
if mask_raw[idx(x, h - 1)] < threshold {
queue.push_back((x, h - 1));
}
}
for y in 0..h {
if mask_raw[idx(0, y)] < threshold {
queue.push_back((0, y));
}
if mask_raw[idx(w - 1, y)] < threshold {
queue.push_back((w - 1, y));
}
}
while let Some((x, y)) = queue.pop_front() {
let id = idx(x, y);
if visited[id] {
continue;
}
visited[id] = true;
if x > 0 {
let nx = x - 1;
let nid = idx(nx, y);
if !visited[nid] && mask_raw[nid] < threshold {
queue.push_back((nx, y));
}
}
if x + 1 < w {
let nx = x + 1;
let nid = idx(nx, y);
if !visited[nid] && mask_raw[nid] < threshold {
queue.push_back((nx, y));
}
}
if y > 0 {
let ny = y - 1;
let nid = idx(x, ny);
if !visited[nid] && mask_raw[nid] < threshold {
queue.push_back((x, ny));
}
}
if y + 1 < h {
let ny = y + 1;
let nid = idx(x, ny);
if !visited[nid] && mask_raw[nid] < threshold {
queue.push_back((x, ny));
}
}
}
let mut out = GrayImage::new(w, h);
for ((x, y, out_pixel), mask_pixel) in out.enumerate_pixels_mut().zip(mask.pixels()) {
let id = idx(x, y);
let value = mask_pixel[0];
let filled = if value >= threshold || !visited[id] {
255
} else {
0
};
*out_pixel = Luma([filled]);
}
out
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum MaskAlphaMode {
#[default]
UseMask,
Scale(f32),
Solid(u8),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MaskColor {
color: [u8; 4],
alpha_mode: MaskAlphaMode,
}
impl MaskColor {
pub const fn new(color: [u8; 4]) -> Self {
Self::from_rgba(color)
}
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self::from_rgb([r, g, b])
}
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self::from_rgba([r, g, b, a])
}
pub const fn from_rgb([r, g, b]: [u8; 3]) -> Self {
Self::from_rgba([r, g, b, 255])
}
pub const fn from_rgba(color: [u8; 4]) -> Self {
Self {
color,
alpha_mode: MaskAlphaMode::UseMask,
}
}
pub const fn to_rgba8(self) -> [u8; 4] {
self.color
}
pub const fn alpha_mode(self) -> MaskAlphaMode {
self.alpha_mode
}
pub const fn with_alpha_mode(mut self, alpha_mode: MaskAlphaMode) -> Self {
self.alpha_mode = alpha_mode;
self
}
}
impl Default for MaskColor {
fn default() -> Self {
Self::new([255, 255, 255, 255])
}
}
impl From<[u8; 4]> for MaskColor {
fn from(color: [u8; 4]) -> Self {
Self::from_rgba(color)
}
}
impl From<[u8; 3]> for MaskColor {
fn from(color: [u8; 3]) -> Self {
Self::from_rgb(color)
}
}
impl From<Rgb<u8>> for MaskColor {
fn from(color: Rgb<u8>) -> Self {
Self::from_rgb(color.0)
}
}
impl From<Rgba<u8>> for MaskColor {
fn from(color: Rgba<u8>) -> Self {
Self::from_rgba(color.0)
}
}
fn resolve_mask_alpha(mask_value: u8, mode: MaskAlphaMode) -> u8 {
match mode {
MaskAlphaMode::UseMask => mask_value,
MaskAlphaMode::Scale(scale) => {
let scaled = (mask_value as f32) * scale.max(0.0);
scaled.round().clamp(0.0, 255.0) as u8
}
MaskAlphaMode::Solid(alpha) => {
if mask_value > 0 {
alpha
} else {
0
}
}
}
}
pub fn colorize_mask(mask: &GrayImage, color: impl Into<MaskColor>) -> RgbaImage {
let color = color.into();
let (w, h) = mask.dimensions();
let [r, g, b, base_alpha] = color.to_rgba8();
let mut out = RgbaImage::new(w, h);
for (mask_px, out_px) in mask.pixels().zip(out.pixels_mut()) {
let mask_alpha = resolve_mask_alpha(mask_px[0], color.alpha_mode());
let alpha = ((mask_alpha as u16 * base_alpha as u16) / 255) as u8;
*out_px = Rgba([r, g, b, alpha]);
}
out
}
#[derive(Debug, Clone)]
pub struct MaskHandle {
rgb_image: Arc<RgbImage>,
mask: GrayImage,
mask_processing_defaults: MaskProcessingDefaults,
operations: Vec<MaskOperation>,
}
impl MaskHandle {
pub(crate) fn new(
rgb_image: Arc<RgbImage>,
mask: GrayImage,
mask_processing_defaults: MaskProcessingDefaults,
) -> Self {
Self {
rgb_image,
mask,
mask_processing_defaults,
operations: Vec::new(),
}
}
fn resolve_pending_operations(mut self) -> Self {
if self.operations.is_empty() {
return self;
}
let operations = std::mem::take(&mut self.operations);
let mask = apply_operations(&self.mask, &operations);
Self::new(self.rgb_image, mask, self.mask_processing_defaults)
}
fn resolved_mask(&self) -> Cow<'_, GrayImage> {
if self.operations.is_empty() {
Cow::Borrowed(&self.mask)
} else {
Cow::Owned(apply_operations(&self.mask, &self.operations))
}
}
pub fn to_raw_mask(&self) -> GrayImage {
self.mask.clone()
}
pub fn as_raw_mask(&self) -> &GrayImage {
&self.mask
}
pub fn dimensions(&self) -> (u32, u32) {
self.mask.dimensions()
}
#[deprecated(note = "use to_raw_mask()")]
pub fn raw(&self) -> GrayImage {
self.to_raw_mask()
}
#[deprecated(note = "use as_raw_mask()")]
pub fn image(&self) -> &GrayImage {
self.as_raw_mask()
}
pub fn into_image(self) -> GrayImage {
self.resolve_pending_operations().mask
}
pub fn save(&self, path: impl AsRef<Path>) -> OutlineResult<()> {
self.resolved_mask().save(path)?;
Ok(())
}
pub fn bounding_box(&self) -> Option<BoundingBox> {
self.bounding_box_with(1)
}
pub fn bounding_box_with(&self, threshold: u8) -> Option<BoundingBox> {
let mask = self.resolved_mask();
mask_bounding_box(&mask, threshold)
}
pub fn blur(mut self) -> Self {
let sigma = self.mask_processing_defaults.blur_sigma;
assert!(sigma > 0.0, "sigma must be > 0.0");
self.operations.push(MaskOperation::Blur { sigma });
self
}
pub fn blur_with(mut self, sigma: f32) -> Self {
assert!(sigma > 0.0, "sigma must be > 0.0");
self.operations.push(MaskOperation::Blur { sigma });
self
}
pub fn threshold(mut self) -> Self {
let value = self.mask_processing_defaults.mask_threshold;
self.operations.push(MaskOperation::Threshold { value });
self
}
pub fn threshold_with(mut self, value: u8) -> Self {
self.operations.push(MaskOperation::Threshold { value });
self
}
pub fn dilate(mut self) -> Self {
let radius = self.mask_processing_defaults.dilation_radius;
assert_nonnegative_radius(radius);
self.operations.push(MaskOperation::Dilate { radius });
self
}
pub fn dilate_with(mut self, radius: f32) -> Self {
assert_nonnegative_radius(radius);
self.operations.push(MaskOperation::Dilate { radius });
self
}
pub fn erode(mut self) -> Self {
let radius = self.mask_processing_defaults.erosion_radius;
assert_nonnegative_radius(radius);
let border_mode = self.mask_processing_defaults.erosion_border_mode;
self.operations.push(MaskOperation::Erode {
radius,
border_mode,
});
self
}
pub fn erode_with(mut self, radius: f32) -> Self {
let border_mode = self.mask_processing_defaults.erosion_border_mode;
assert_nonnegative_radius(radius);
self.operations.push(MaskOperation::Erode {
radius,
border_mode,
});
self
}
pub fn erode_with_border_mode(mut self, radius: f32, border_mode: ErosionBorderMode) -> Self {
assert_nonnegative_radius(radius);
self.operations.push(MaskOperation::Erode {
radius,
border_mode,
});
self
}
pub fn fill_holes(self) -> Self {
let threshold = self.mask_processing_defaults.mask_threshold;
self.fill_holes_with(threshold)
}
pub fn fill_holes_with(mut self, threshold: u8) -> Self {
self.operations.push(MaskOperation::FillHoles { threshold });
self
}
pub fn processed(self) -> OutlineResult<MaskHandle> {
self.process_with_pipeline(None)
}
pub fn processed_with(self, pipeline: &MaskPipeline) -> OutlineResult<MaskHandle> {
self.process_with_pipeline(Some(pipeline))
}
fn process_with_pipeline(
mut self,
pipeline: Option<&MaskPipeline>,
) -> OutlineResult<MaskHandle> {
let mut ops = std::mem::take(&mut self.operations);
if let Some(custom) = pipeline {
ops.extend_from_slice(custom.operations());
}
let mask = apply_operations(&self.mask, &ops);
Ok(MaskHandle::new(
self.rgb_image,
mask,
self.mask_processing_defaults,
))
}
pub fn foreground(&self) -> OutlineResult<ForegroundHandle> {
let mask = self.resolved_mask();
let rgba = compose_foreground(self.rgb_image.as_ref(), &mask)?;
Ok(ForegroundHandle::new(rgba))
}
pub fn colorize(&self, color: impl Into<MaskColor>) -> RgbaImage {
let mask = self.resolved_mask();
colorize_mask(&mask, color)
}
pub fn trace<V>(&self, vectorizer: &V, options: &V::Options) -> OutlineResult<V::Output>
where
V: MaskVectorizer,
{
let mask = self.resolved_mask();
vectorizer.vectorize(&mask, options)
}
pub fn pad(self, padding: impl Into<Padding>) -> Self {
let this = self.resolve_pending_operations();
let padding = padding.into();
let mask = pad_gray_image(&this.mask, padding, 0);
let rgb = Arc::new(pad_rgb_image(this.rgb_image.as_ref(), padding, [0, 0, 0]));
Self::new(rgb, mask, this.mask_processing_defaults)
}
pub fn crop(self, bounds: BoundingBox) -> Option<Self> {
let this = self.resolve_pending_operations();
if !crop_bounds_fit_image(bounds, this.mask.width(), this.mask.height()) {
return None;
}
let mask = crop_gray_image(&this.mask, bounds);
let rgb = Arc::new(crop_rgb_image(this.rgb_image.as_ref(), bounds));
Some(Self::new(rgb, mask, this.mask_processing_defaults))
}
pub fn crop_to_content(self) -> Option<Self> {
self.crop_to_content_with(1)
}
pub fn crop_to_content_with(self, threshold: u8) -> Option<Self> {
let this = self.resolve_pending_operations();
let bounds = mask_bounding_box(&this.mask, threshold)?;
this.crop(bounds)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::arr2;
fn gray_image(w: u32, h: u32, value: u8) -> GrayImage {
GrayImage::from_pixel(w, h, Luma([value]))
}
mod threshold_mask {
use super::*;
mod unit {
use super::*;
#[test]
fn all_below_threshold_become_black() {
let input = gray_image(2, 2, 100);
let result = threshold_mask(&input, 128);
for px in result.pixels() {
assert_eq!(px.0[0], 0);
}
}
#[test]
fn all_above_threshold_become_white() {
let input = gray_image(2, 2, 200);
let result = threshold_mask(&input, 128);
for px in result.pixels() {
assert_eq!(px.0[0], 255);
}
}
#[test]
fn exact_threshold_becomes_black() {
let input = gray_image(2, 2, 128);
let result = threshold_mask(&input, 128);
for px in result.pixels() {
assert_eq!(px.0[0], 0);
}
}
#[test]
fn one_below_threshold_becomes_black() {
let input = gray_image(2, 2, 127);
let result = threshold_mask(&input, 128);
for px in result.pixels() {
assert_eq!(px.0[0], 0);
}
}
#[test]
fn dimensions_preserved() {
let input = gray_image(5, 3, 100);
let result = threshold_mask(&input, 128);
assert_eq!(result.dimensions(), (5, 3));
}
#[test]
fn mixed_values_per_pixel() {
let mut input = GrayImage::new(2, 2);
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);
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); }
#[test]
fn threshold_zero() {
let mut input = GrayImage::new(2, 1);
input.put_pixel(0, 0, Luma([0]));
input.put_pixel(1, 0, Luma([1]));
let result = threshold_mask(&input, 0);
assert_eq!(result.get_pixel(0, 0).0[0], 0); assert_eq!(result.get_pixel(1, 0).0[0], 255); }
#[test]
fn threshold_255() {
let mut input = GrayImage::new(2, 1);
input.put_pixel(0, 0, Luma([254]));
input.put_pixel(1, 0, Luma([255]));
let result = threshold_mask(&input, 255);
assert_eq!(result.get_pixel(0, 0).0[0], 0); assert_eq!(result.get_pixel(1, 0).0[0], 0); }
}
mod prop {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn output_is_binary(
w in 1u32..20,
h in 1u32..20,
fill_value in proptest::num::u8::ANY,
threshold in proptest::num::u8::ANY
) {
let input = GrayImage::from_pixel(w, h, Luma([fill_value]));
let result = threshold_mask(&input, threshold);
prop_assert_eq!(result.dimensions(), (w, h));
for px in result.pixels() {
prop_assert!(px.0[0] == 0 || px.0[0] == 255);
}
}
#[test]
fn respects_threshold(
value in proptest::num::u8::ANY,
threshold in proptest::num::u8::ANY
) {
let input = GrayImage::from_pixel(1, 1, Luma([value]));
let result = threshold_mask(&input, threshold);
let out = result.get_pixel(0, 0).0[0];
if value > threshold {
prop_assert_eq!(out, 255);
} else {
prop_assert_eq!(out, 0);
}
}
}
}
}
mod array_to_gray_image {
use super::*;
mod unit {
use super::*;
#[test]
fn all_zeros_black() {
let arr = arr2(&[[0.0, 0.0], [0.0, 0.0]]);
let result = array_to_gray_image(&arr);
for px in result.pixels() {
assert_eq!(px.0[0], 0);
}
}
#[test]
fn all_ones_white() {
let arr = arr2(&[[1.0, 1.0], [1.0, 1.0]]);
let result = array_to_gray_image(&arr);
for px in result.pixels() {
assert_eq!(px.0[0], 255);
}
}
#[test]
fn half_value_gray() {
let arr = arr2(&[[0.5]]);
let result = array_to_gray_image(&arr);
assert_eq!(result.get_pixel(0, 0).0[0], 128);
}
#[test]
fn clamps_above_one() {
let arr = arr2(&[[2.0]]);
let result = array_to_gray_image(&arr);
assert_eq!(result.get_pixel(0, 0).0[0], 255);
}
#[test]
fn clamps_below_zero() {
let arr = arr2(&[[-1.0]]);
let result = array_to_gray_image(&arr);
assert_eq!(result.get_pixel(0, 0).0[0], 0);
}
#[test]
fn dimensions_preserved() {
let arr = arr2(&[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]);
let result = array_to_gray_image(&arr);
assert_eq!(result.dimensions(), (3, 2));
}
#[test]
fn rounding_behavior() {
let arr = arr2(&[[0.4]]);
let result = array_to_gray_image(&arr);
assert_eq!(result.get_pixel(0, 0).0[0], 102); }
}
mod prop {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn clamps_out_of_range_values(
values in proptest::collection::vec(-10.0f32..10.0f32, 1..100)
) {
let len = values.len();
let arr = ndarray::Array2::from_shape_vec((1, len), values.clone()).unwrap();
let result = array_to_gray_image(&arr);
for (i, px) in result.pixels().enumerate() {
let input = values[i];
if input <= 0.0 {
prop_assert_eq!(px.0[0], 0);
} else if input >= 1.0 {
prop_assert_eq!(px.0[0], 255);
}
}
prop_assert_eq!(result.dimensions(), (len as u32, 1));
}
#[test]
fn valid_range_maps_proportionally(value in 0.0f32..=1.0f32) {
let arr = ndarray::arr2(&[[value]]);
let result = array_to_gray_image(&arr);
let byte = result.get_pixel(0, 0).0[0];
let expected = (value * 255.0 + 0.5) as u8;
prop_assert_eq!(byte, expected);
}
}
}
}
mod fill_mask_holes {
use super::*;
mod unit {
use super::*;
#[test]
fn solid_white_unchanged() {
let input = gray_image(4, 4, 255);
let result = fill_mask_holes(&input, 128);
for px in result.pixels() {
assert_eq!(px.0[0], 255);
}
}
#[test]
fn solid_black_unchanged() {
let input = gray_image(4, 4, 0); let result = fill_mask_holes(&input, 128);
for px in result.pixels() {
assert_eq!(px.0[0], 0); }
}
#[test]
fn interior_hole_filled() {
let mut input = gray_image(5, 5, 255);
for y in 1..4 {
for x in 1..4 {
input.put_pixel(x, y, Luma([0]));
}
}
let result = fill_mask_holes(&input, 128);
for px in result.pixels() {
assert_eq!(px.0[0], 255);
}
}
#[test]
fn border_connected_black_not_filled() {
let mut input = gray_image(4, 4, 255);
for y in 0..4 {
input.put_pixel(0, y, Luma([0]));
}
let result = fill_mask_holes(&input, 128);
for y in 0..4 {
assert_eq!(result.get_pixel(0, y).0[0], 0);
}
for y in 0..4 {
for x in 1..4 {
assert_eq!(result.get_pixel(x, y).0[0], 255);
}
}
}
#[test]
fn dimensions_preserved() {
let input = gray_image(7, 5, 128);
let result = fill_mask_holes(&input, 128);
assert_eq!(result.dimensions(), (7, 5));
}
#[test]
fn empty_image_is_unchanged() {
let input = GrayImage::new(0, 0);
let result = fill_mask_holes(&input, 128);
assert_eq!(result.dimensions(), (0, 0));
}
#[test]
fn diagonal_only_connection_not_traversed() {
let mut input = GrayImage::new(3, 3);
for y in 0..3 {
for x in 0..3 {
input.put_pixel(x, y, Luma([255]));
}
}
input.put_pixel(1, 1, Luma([0])); input.put_pixel(0, 2, Luma([0]));
let result = fill_mask_holes(&input, 128);
assert_eq!(result.get_pixel(0, 2).0[0], 0);
assert_eq!(result.get_pixel(1, 1).0[0], 255);
}
#[test]
fn threshold_changes_border_connectivity() {
let mut input = gray_image(3, 3, 255);
input.put_pixel(0, 1, Luma([110]));
let r128 = fill_mask_holes(&input, 128);
assert_eq!(r128.get_pixel(0, 1).0[0], 0);
let r100 = fill_mask_holes(&input, 100);
assert_eq!(r100.get_pixel(0, 1).0[0], 255);
}
#[test]
fn output_is_always_binary() {
let mut input = gray_image(5, 5, 200); input.put_pixel(2, 2, Luma([50]));
let result = fill_mask_holes(&input, 128);
let is_binary = result.pixels().all(|p| p.0[0] == 0 || p.0[0] == 255);
assert!(is_binary);
}
}
mod prop {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn output_is_binary(
w in 1u32..15,
h in 1u32..15,
fill_value in proptest::num::u8::ANY,
threshold in proptest::num::u8::ANY
) {
let input = GrayImage::from_pixel(w, h, Luma([fill_value]));
let result = fill_mask_holes(&input, threshold);
prop_assert_eq!(result.dimensions(), (w, h));
for px in result.pixels() {
prop_assert!(px.0[0] == 0 || px.0[0] == 255);
}
}
}
}
}
mod dilate_euclidean {
use super::*;
mod unit {
use super::*;
#[test]
fn solid_white_stays_white() {
let input = gray_image(4, 4, 255);
let result = dilate_euclidean(&input, 2.0);
for px in result.pixels() {
assert_eq!(px.0[0], 255);
}
}
#[test]
fn solid_black_stays_black() {
let input = gray_image(4, 4, 0);
let result = dilate_euclidean(&input, 2.0);
for px in result.pixels() {
assert_eq!(px.0[0], 0);
}
}
#[test]
fn single_white_pixel_dilates() {
let mut input = gray_image(5, 5, 0);
input.put_pixel(2, 2, Luma([255]));
let result = dilate_euclidean(&input, 1.5);
assert_eq!(result.get_pixel(2, 2).0[0], 255);
assert_eq!(result.get_pixel(2, 1).0[0], 255);
assert_eq!(result.get_pixel(2, 3).0[0], 255);
assert_eq!(result.get_pixel(1, 2).0[0], 255);
assert_eq!(result.get_pixel(3, 2).0[0], 255);
}
#[test]
fn dimensions_preserved() {
let input = gray_image(6, 4, 128);
let result = dilate_euclidean(&input, 1.0);
assert_eq!(result.dimensions(), (6, 4));
}
#[test]
fn diagonal_within_radius() {
let mut input = gray_image(5, 5, 0);
input.put_pixel(2, 2, Luma([255]));
let result = dilate_euclidean(&input, 1.5);
assert_eq!(result.get_pixel(1, 1).0[0], 255);
assert_eq!(result.get_pixel(3, 1).0[0], 255);
assert_eq!(result.get_pixel(1, 3).0[0], 255);
assert_eq!(result.get_pixel(3, 3).0[0], 255);
}
#[test]
fn radius_zero_only_original_pixels() {
let mut input = gray_image(3, 3, 0);
input.put_pixel(1, 1, Luma([255]));
let result = dilate_euclidean(&input, 0.0);
assert_eq!(result.get_pixel(1, 1).0[0], 255);
assert_eq!(result.get_pixel(0, 1).0[0], 0);
assert_eq!(result.get_pixel(2, 1).0[0], 0);
assert_eq!(result.get_pixel(1, 0).0[0], 0);
assert_eq!(result.get_pixel(1, 2).0[0], 0);
}
#[test]
#[should_panic(expected = "radius must be >= 0.0")]
fn negative_radius_panics() {
let input = gray_image(3, 3, 0);
let _ = dilate_euclidean(&input, -1.0);
}
}
mod prop {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn output_is_binary(
w in 1u32..15,
h in 1u32..15,
fill_value in proptest::num::u8::ANY,
radius in 0.0f32..5.0f32
) {
let input = GrayImage::from_pixel(w, h, Luma([fill_value]));
let result = dilate_euclidean(&input, radius);
prop_assert_eq!(result.dimensions(), (w, h));
for px in result.pixels() {
prop_assert!(px.0[0] == 0 || px.0[0] == 255);
}
}
}
}
}
mod erode_euclidean_tests {
use super::*;
use crate::config::ErosionBorderMode;
#[test]
fn erosion_shrinks_binary_mask() {
let mut input = gray_image(5, 5, 0);
for y in 1..4 {
for x in 1..4 {
input.put_pixel(x, y, Luma([255]));
}
}
let result =
erode_euclidean_with_border_mode(&input, 1.0, ErosionBorderMode::default());
assert_eq!(result.get_pixel(2, 2).0[0], 255);
assert_eq!(result.get_pixel(1, 1).0[0], 0);
assert_eq!(result.get_pixel(1, 2).0[0], 0);
assert_eq!(result.get_pixel(2, 1).0[0], 0);
assert_eq!(result.get_pixel(3, 3).0[0], 0);
}
#[test]
fn erosion_treats_image_exterior_as_background() {
let input = gray_image(5, 5, 255);
let result =
erode_euclidean_with_border_mode(&input, 1.0, ErosionBorderMode::default());
for y in 0..5 {
for x in 0..5 {
let expected = if x == 0 || y == 0 || x == 4 || y == 4 {
0
} else {
255
};
assert_eq!(result.get_pixel(x, y).0[0], expected, "pixel ({x}, {y})");
}
}
}
#[test]
fn erosion_can_treat_image_exterior_as_unknown() {
let input = gray_image(5, 5, 255);
let result =
erode_euclidean_with_border_mode(&input, 1.0, ErosionBorderMode::OutsideIsUnknown);
assert_eq!(result.as_raw(), input.as_raw());
}
#[test]
fn erosion_radius_zero_preserves_mask() {
let mut input = gray_image(3, 3, 0);
input.put_pixel(0, 0, Luma([255]));
input.put_pixel(1, 1, Luma([255]));
input.put_pixel(2, 2, Luma([255]));
let result =
erode_euclidean_with_border_mode(&input, 0.0, ErosionBorderMode::default());
assert_eq!(result.as_raw(), input.as_raw());
}
#[test]
#[should_panic(expected = "radius must be >= 0.0")]
fn erosion_negative_radius_panics() {
let input = gray_image(3, 3, 0);
let _ = erode_euclidean_with_border_mode(&input, -1.0, ErosionBorderMode::default());
}
#[test]
fn erosion_shrinks_foreground_touching_one_edge() {
let mut input = gray_image(5, 5, 0);
for y in 1..4 {
for x in 0..3 {
input.put_pixel(x, y, Luma([255]));
}
}
let result =
erode_euclidean_with_border_mode(&input, 1.0, ErosionBorderMode::default());
for y in 0..5 {
for x in 0..5 {
let expected = if x == 1 && y == 2 { 255 } else { 0 };
assert_eq!(result.get_pixel(x, y).0[0], expected, "pixel ({x}, {y})");
}
}
}
}
mod apply_operations {
use super::*;
mod unit {
use super::*;
#[test]
fn empty_operations_returns_clone() {
let input = gray_image(3, 3, 100);
let result = apply_operations(&input, &[]);
assert_eq!(result.as_raw(), input.as_raw());
}
#[test]
fn single_threshold_operation() {
let input = gray_image(2, 2, 200);
let ops = vec![MaskOperation::Threshold { value: 128 }];
let result = apply_operations(&input, &ops);
for px in result.pixels() {
assert_eq!(px.0[0], 255); }
}
#[test]
fn threshold_then_dilate() {
let mut input = gray_image(5, 5, 0);
input.put_pixel(2, 2, Luma([200]));
let ops = vec![
MaskOperation::Threshold { value: 128 },
MaskOperation::Dilate { radius: 1.0 },
];
let result = apply_operations(&input, &ops);
assert_eq!(result.get_pixel(2, 2).0[0], 255);
assert_eq!(result.get_pixel(2, 1).0[0], 255);
}
#[test]
fn threshold_then_erode() {
let mut input = gray_image(5, 5, 0);
for y in 1..4 {
for x in 1..4 {
input.put_pixel(x, y, Luma([200]));
}
}
let ops = vec![
MaskOperation::Threshold { value: 128 },
MaskOperation::Erode {
radius: 1.0,
border_mode: ErosionBorderMode::default(),
},
];
let result = apply_operations(&input, &ops);
assert_eq!(result.get_pixel(2, 2).0[0], 255);
assert_eq!(result.get_pixel(1, 1).0[0], 0);
}
#[test]
fn order_matters_blur_vs_threshold() {
let mut input = gray_image(5, 5, 0);
input.put_pixel(2, 2, Luma([255]));
let ops_blur_first = vec![
MaskOperation::Blur { sigma: 1.0 },
MaskOperation::Threshold { value: 50 },
];
let result_blur_first = apply_operations(&input, &ops_blur_first);
let ops_threshold_first = vec![
MaskOperation::Threshold { value: 50 },
MaskOperation::Blur { sigma: 1.0 },
];
let result_threshold_first = apply_operations(&input, &ops_threshold_first);
let is_binary = result_blur_first
.pixels()
.all(|p| p.0[0] == 0 || p.0[0] == 255);
assert!(is_binary);
let has_intermediate = result_threshold_first
.pixels()
.any(|p| p.0[0] > 0 && p.0[0] < 255);
assert!(has_intermediate);
assert_ne!(result_blur_first.as_raw(), result_threshold_first.as_raw());
}
#[test]
fn order_matters_dilate_vs_blur() {
let mut input = gray_image(5, 5, 0);
input.put_pixel(2, 2, Luma([255]));
let ops_dilate_first = vec![
MaskOperation::Dilate { radius: 1.0 },
MaskOperation::Blur { sigma: 1.0 },
];
let result_dilate_first = apply_operations(&input, &ops_dilate_first);
let ops_blur_first = vec![
MaskOperation::Blur { sigma: 1.0 },
MaskOperation::Dilate { radius: 1.0 },
];
let result_blur_first = apply_operations(&input, &ops_blur_first);
assert_ne!(result_dilate_first.as_raw(), result_blur_first.as_raw());
}
}
mod prop {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn preserves_dimensions(
w in 1u32..10,
h in 1u32..10,
fill_value in proptest::num::u8::ANY
) {
let input = GrayImage::from_pixel(w, h, Luma([fill_value]));
let ops_threshold = vec![MaskOperation::Threshold { value: 128 }];
let result = apply_operations(&input, &ops_threshold);
prop_assert_eq!(result.dimensions(), (w, h));
let ops_dilate = vec![MaskOperation::Dilate { radius: 1.0 }];
let result = apply_operations(&input, &ops_dilate);
prop_assert_eq!(result.dimensions(), (w, h));
let ops_erode = vec![MaskOperation::Erode {
radius: 1.0,
border_mode: ErosionBorderMode::default(),
}];
let result = apply_operations(&input, &ops_erode);
prop_assert_eq!(result.dimensions(), (w, h));
let ops_fill = vec![MaskOperation::FillHoles { threshold: 128 }];
let result = apply_operations(&input, &ops_fill);
prop_assert_eq!(result.dimensions(), (w, h));
let ops_blur = vec![MaskOperation::Blur { sigma: 1.0 }];
let result = apply_operations(&input, &ops_blur);
prop_assert_eq!(result.dimensions(), (w, h));
}
}
}
}
mod mask_pipeline {
use super::*;
mod unit {
use super::*;
#[test]
fn new_returns_empty() {
let pipeline = MaskPipeline::new();
assert!(pipeline.is_empty());
assert!(pipeline.operations().is_empty());
}
#[test]
fn blur_with_adds_blur_operation() {
let pipeline = MaskPipeline::new().blur_with(3.0);
let ops = pipeline.operations();
assert_eq!(ops.len(), 1);
assert!(
matches!(ops[0], MaskOperation::Blur { sigma } if (sigma - 3.0).abs() < 0.001)
);
}
#[test]
fn full_pipeline_order_and_values() {
let pipeline = MaskPipeline::new()
.blur_with(2.0)
.threshold_with(128)
.dilate_with(5.0)
.erode_with_border_mode(3.0, ErosionBorderMode::OutsideIsUnknown)
.fill_holes_with(128);
let ops = pipeline.operations();
assert_eq!(ops.len(), 5);
assert!(
matches!(ops[0], MaskOperation::Blur { sigma } if (sigma - 2.0).abs() < 1e-6)
);
assert!(matches!(ops[1], MaskOperation::Threshold { value: 128 }));
assert!(
matches!(ops[2], MaskOperation::Dilate { radius } if (radius - 5.0).abs() < 1e-6)
);
assert!(matches!(
ops[3],
MaskOperation::Erode { radius, border_mode }
if (radius - 3.0).abs() < 1e-6
&& border_mode == ErosionBorderMode::OutsideIsUnknown
));
assert!(matches!(
ops[4],
MaskOperation::FillHoles { threshold: 128 }
));
}
#[test]
fn partial_pipeline_keeps_inserted_order() {
let pipeline = MaskPipeline::new().threshold_with(100).fill_holes_with(100);
let ops = pipeline.operations();
assert_eq!(ops.len(), 2);
assert!(matches!(ops[0], MaskOperation::Threshold { value: 100 }));
assert!(matches!(
ops[1],
MaskOperation::FillHoles { threshold: 100 }
));
}
}
}
mod mask_handle_api {
use super::*;
use image::Rgb;
fn mask_handle() -> MaskHandle {
MaskHandle {
rgb_image: Arc::new(RgbImage::from_pixel(1, 1, Rgb([255, 255, 255]))),
mask: GrayImage::from_pixel(1, 1, Luma([255])),
mask_processing_defaults: MaskProcessingDefaults::default(),
operations: Vec::new(),
}
}
fn mask_handle_with_images(rgb: RgbImage, mask: GrayImage) -> MaskHandle {
MaskHandle {
rgb_image: Arc::new(rgb),
mask,
mask_processing_defaults: MaskProcessingDefaults::default(),
operations: Vec::new(),
}
}
fn single_pixel_mask_handle() -> MaskHandle {
mask_handle_with_images(
RgbImage::from_pixel(5, 5, Rgb([10, 20, 30])),
GrayImage::from_fn(5, 5, |x, y| {
if x == 2 && y == 2 {
Luma([255])
} else {
Luma([0])
}
}),
)
}
struct BoundingBoxVectorizer;
impl MaskVectorizer for BoundingBoxVectorizer {
type Options = ();
type Output = Option<BoundingBox>;
fn vectorize(
&self,
mask: &GrayImage,
_options: &Self::Options,
) -> OutlineResult<Self::Output> {
Ok(mask_bounding_box(mask, 1))
}
}
mod colorize {
use super::*;
#[test]
fn default_color_is_white_with_mask_alpha() {
let color = MaskColor::default();
assert_eq!(color.to_rgba8(), [255, 255, 255, 255]);
assert!(matches!(color.alpha_mode(), MaskAlphaMode::UseMask));
}
#[test]
fn rgb_constructor_defaults_to_opaque_alpha() {
let color = MaskColor::rgb(10, 20, 30);
assert_eq!(color.to_rgba8(), [10, 20, 30, 255]);
assert!(matches!(color.alpha_mode(), MaskAlphaMode::UseMask));
}
#[test]
fn rgba_constructor_keeps_all_channels() {
let color = MaskColor::rgba(10, 20, 30, 40);
assert_eq!(color.to_rgba8(), [10, 20, 30, 40]);
assert!(matches!(color.alpha_mode(), MaskAlphaMode::UseMask));
}
#[test]
fn rgb_array_color_defaults_to_opaque_alpha() {
let color = MaskColor::from([10, 20, 30]);
assert_eq!(color.to_rgba8(), [10, 20, 30, 255]);
assert!(matches!(color.alpha_mode(), MaskAlphaMode::UseMask));
}
#[test]
fn rgb_pixel_color_defaults_to_opaque_alpha() {
let color = MaskColor::from(Rgb([10, 20, 30]));
assert_eq!(color.to_rgba8(), [10, 20, 30, 255]);
assert!(matches!(color.alpha_mode(), MaskAlphaMode::UseMask));
}
#[test]
fn rgba_pixel_color_keeps_all_channels() {
let color = MaskColor::from(Rgba([10, 20, 30, 40]));
assert_eq!(color.to_rgba8(), [10, 20, 30, 40]);
assert!(matches!(color.alpha_mode(), MaskAlphaMode::UseMask));
}
#[test]
fn use_mask_mode_sets_alpha_from_mask() {
let mask = gray_image(2, 2, 128);
let result = colorize_mask(&mask, MaskColor::new([0, 180, 255, 255]));
for px in result.pixels() {
assert_eq!(px.0, [0, 180, 255, 128]);
}
}
#[test]
fn base_alpha_is_multiplied_with_mask_alpha() {
let mask = gray_image(1, 1, 128);
let result = colorize_mask(&mask, MaskColor::new([255, 255, 255, 128]));
assert_eq!(result.get_pixel(0, 0).0, [255, 255, 255, 64]);
}
#[test]
fn scale_mode_scales_mask_alpha() {
let mask = gray_image(1, 1, 200);
let color =
MaskColor::new([255, 0, 0, 255]).with_alpha_mode(MaskAlphaMode::Scale(0.5));
let result = colorize_mask(&mask, color);
assert_eq!(result.get_pixel(0, 0).0, [255, 0, 0, 100]);
}
#[test]
fn solid_mode_uses_alpha_for_nonzero_mask() {
let mask =
GrayImage::from_fn(2, 1, |x, _| if x == 0 { Luma([0]) } else { Luma([25]) });
let color =
MaskColor::new([0, 255, 0, 255]).with_alpha_mode(MaskAlphaMode::Solid(200));
let result = colorize_mask(&mask, color);
assert_eq!(result.get_pixel(0, 0).0, [0, 255, 0, 0]);
assert_eq!(result.get_pixel(1, 0).0, [0, 255, 0, 200]);
}
}
mod erode_builder {
use super::*;
#[test]
fn mask_handle_erode_uses_default_radius() {
let handle = mask_handle().erode();
assert!(matches!(
handle.operations.as_slice(),
[MaskOperation::Erode { radius, border_mode }]
if (*radius - MaskProcessingDefaults::default().erosion_radius).abs() < f32::EPSILON
&& *border_mode == MaskProcessingDefaults::default().erosion_border_mode
));
}
#[test]
fn mask_handle_erode_with_uses_custom_radius() {
let handle = mask_handle().erode_with(3.0);
assert!(matches!(
handle.operations.as_slice(),
[MaskOperation::Erode { radius, border_mode }]
if (*radius - 3.0).abs() < f32::EPSILON
&& *border_mode == MaskProcessingDefaults::default().erosion_border_mode
));
}
#[test]
fn mask_handle_erode_with_border_mode_uses_custom_mode() {
let handle =
mask_handle().erode_with_border_mode(3.0, ErosionBorderMode::OutsideIsUnknown);
assert!(matches!(
handle.operations.as_slice(),
[MaskOperation::Erode { radius, border_mode }]
if (*radius - 3.0).abs() < f32::EPSILON
&& *border_mode == ErosionBorderMode::OutsideIsUnknown
));
}
}
mod fill_holes_builder {
use super::*;
#[test]
fn mask_handle_fill_holes_with_uses_custom_threshold() {
let handle = mask_handle().fill_holes_with(180);
assert!(matches!(
handle.operations.as_slice(),
[MaskOperation::FillHoles { threshold: 180 }]
));
}
}
mod processing_pipeline {
use super::*;
#[test]
fn chain_and_pipeline_are_equivalent() {
let pipeline = MaskPipeline::new()
.threshold_with(128)
.dilate_with(1.0)
.erode_with(1.0);
let chained = single_pixel_mask_handle()
.threshold_with(128)
.dilate_with(1.0)
.erode_with(1.0)
.processed()
.expect("chain should process")
.into_image();
let piped = single_pixel_mask_handle()
.processed_with(&pipeline)
.expect("pipeline should process")
.into_image();
assert_eq!(chained.as_raw(), piped.as_raw());
}
#[test]
fn processed_with_appends_after_chained_operations() {
let pipeline = MaskPipeline::new().dilate_with(1.0);
let appended = single_pixel_mask_handle()
.threshold_with(128)
.processed_with(&pipeline)
.expect("processing should succeed")
.into_image();
let chained = single_pixel_mask_handle()
.threshold_with(128)
.dilate_with(1.0)
.processed()
.expect("processing should succeed")
.into_image();
assert_eq!(appended.as_raw(), chained.as_raw());
}
#[test]
fn processed_without_chained_operations_is_identity() {
let source = single_pixel_mask_handle().into_image();
let handle = MaskHandle {
rgb_image: Arc::new(RgbImage::from_pixel(5, 5, Rgb([10, 20, 30]))),
mask: source.clone(),
mask_processing_defaults: MaskProcessingDefaults::default(),
operations: Vec::new(),
};
let mask = handle
.processed()
.expect("empty processing should succeed")
.into_image();
assert_eq!(mask.as_raw(), source.as_raw());
}
#[test]
fn no_arg_methods_use_custom_defaults() {
let defaults = MaskProcessingDefaults {
blur_sigma: 2.0,
mask_threshold: 180,
dilation_radius: 3.0,
erosion_radius: 4.0,
erosion_border_mode: ErosionBorderMode::OutsideIsUnknown,
};
let handle = MaskHandle {
rgb_image: Arc::new(RgbImage::from_pixel(1, 1, Rgb([255, 255, 255]))),
mask: GrayImage::from_pixel(1, 1, Luma([255])),
mask_processing_defaults: defaults,
operations: Vec::new(),
}
.blur()
.threshold()
.dilate()
.erode()
.fill_holes();
assert!(matches!(
handle.operations.as_slice(),
[
MaskOperation::Blur { sigma },
MaskOperation::Threshold { value: 180 },
MaskOperation::Dilate { radius: dilation_radius },
MaskOperation::Erode {
radius: erosion_radius,
border_mode: ErosionBorderMode::OutsideIsUnknown
},
MaskOperation::FillHoles { threshold: 180 }
] if (*sigma - 2.0).abs() < f32::EPSILON
&& (*dilation_radius - 3.0).abs() < f32::EPSILON
&& (*erosion_radius - 4.0).abs() < f32::EPSILON
));
}
}
mod geometry {
use super::*;
#[test]
fn mask_handle_dimensions_reports_current_canvas() {
let cropped = single_pixel_mask_handle()
.crop(BoundingBox::new(1, 1, 2, 3))
.expect("bounds are inside the image");
assert_eq!(cropped.dimensions(), (2, 3));
let padded = cropped.pad(Padding::new(1, 2, 3, 4));
assert_eq!(padded.dimensions(), (6, 9));
}
#[test]
fn mask_handle_pad_updates_mask_and_foreground_canvas() {
let rgb = RgbImage::from_pixel(2, 2, Rgb([10, 20, 30]));
let mut mask = GrayImage::from_pixel(2, 2, Luma([0]));
mask.put_pixel(1, 1, Luma([255]));
let padded = mask_handle_with_images(rgb, mask).pad(Padding::new(1, 2, 3, 4));
assert_eq!(padded.dimensions(), (6, 8));
let foreground = padded.foreground().expect("foreground should compose");
assert_eq!(foreground.dimensions(), (6, 8));
assert_eq!(foreground.image().get_pixel(2, 3)[3], 255);
}
#[test]
fn mask_handle_crop_to_content_crops_mask_and_rgb_together() {
let rgb = RgbImage::from_fn(4, 4, |x, y| Rgb([x as u8, y as u8, 0]));
let mut mask = GrayImage::from_pixel(4, 4, Luma([0]));
mask.put_pixel(2, 1, Luma([255]));
mask.put_pixel(2, 2, Luma([255]));
let cropped = mask_handle_with_images(rgb, mask)
.crop_to_content()
.expect("mask has content");
assert_eq!(cropped.dimensions(), (1, 2));
let foreground = cropped.foreground().expect("foreground should compose");
assert_eq!(foreground.dimensions(), (1, 2));
assert_eq!(foreground.image().get_pixel(0, 0)[0], 2);
assert_eq!(foreground.image().get_pixel(0, 0)[1], 1);
assert_eq!(foreground.image().get_pixel(0, 0)[3], 255);
}
#[test]
fn mask_handle_crop_uses_requested_bounds() {
let rgb = RgbImage::from_fn(4, 4, |x, y| Rgb([x as u8, y as u8, 0]));
let mut mask = GrayImage::from_pixel(4, 4, Luma([0]));
mask.put_pixel(2, 1, Luma([255]));
let cropped = mask_handle_with_images(rgb, mask)
.crop(BoundingBox::new(1, 1, 2, 2))
.expect("bounds are inside the image");
assert_eq!(cropped.dimensions(), (2, 2));
assert_eq!(cropped.as_raw_mask().get_pixel(1, 0)[0], 255);
let foreground = cropped.foreground().expect("foreground should compose");
assert_eq!(foreground.image().get_pixel(1, 0)[0], 2);
assert_eq!(foreground.image().get_pixel(1, 0)[1], 1);
assert_eq!(foreground.image().get_pixel(1, 0)[3], 255);
}
#[test]
fn mask_handle_crop_rejects_invalid_bounds() {
assert!(mask_handle().crop(BoundingBox::new(4, 4, 2, 2)).is_none());
assert!(mask_handle().crop(BoundingBox::new(0, 0, 0, 1)).is_none());
}
#[test]
fn mask_handle_crop_to_content_with_ignores_low_values() {
let rgb = RgbImage::from_fn(5, 3, |x, y| Rgb([x as u8, y as u8, 0]));
let mut mask = GrayImage::from_pixel(5, 3, Luma([0]));
mask.put_pixel(1, 1, Luma([32]));
mask.put_pixel(3, 1, Luma([200]));
let cropped = mask_handle_with_images(rgb, mask)
.crop_to_content_with(128)
.expect("mask has content above threshold");
assert_eq!(cropped.dimensions(), (1, 1));
assert_eq!(cropped.as_raw_mask().get_pixel(0, 0)[0], 200);
let foreground = cropped.foreground().expect("foreground should compose");
assert_eq!(foreground.image().get_pixel(0, 0)[0], 3);
assert_eq!(foreground.image().get_pixel(0, 0)[1], 1);
assert_eq!(foreground.image().get_pixel(0, 0)[3], 200);
}
}
mod pending_operations {
use super::*;
#[test]
fn mask_handle_pad_applies_pending_operations_first() {
let padded = single_pixel_mask_handle()
.dilate_with(1.0)
.pad(Padding::new(1, 2, 0, 0));
assert_eq!(
mask_bounding_box(padded.as_raw_mask(), 1),
Some(BoundingBox::new(2, 3, 3, 3))
);
}
#[test]
fn mask_handle_into_image_applies_pending_operations() {
let mask = single_pixel_mask_handle().dilate_with(1.0).into_image();
assert_eq!(
mask_bounding_box(&mask, 1),
Some(BoundingBox::new(1, 1, 3, 3))
);
}
#[test]
fn mask_handle_save_applies_pending_operations() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let path = temp_dir.path().join("mask.png");
single_pixel_mask_handle()
.dilate_with(1.0)
.save(&path)
.expect("mask should save");
let saved = image::open(&path)
.expect("saved mask should load")
.to_luma8();
assert_eq!(
mask_bounding_box(&saved, 1),
Some(BoundingBox::new(1, 1, 3, 3))
);
}
#[test]
fn mask_handle_foreground_applies_pending_operations() {
let foreground = single_pixel_mask_handle()
.dilate_with(1.0)
.foreground()
.expect("foreground should compose");
assert_eq!(foreground.image().get_pixel(1, 2)[3], 255);
assert_eq!(foreground.image().get_pixel(0, 0)[3], 0);
}
#[test]
fn mask_handle_colorize_applies_pending_operations() {
let colorized = single_pixel_mask_handle()
.dilate_with(1.0)
.colorize([0, 180, 255, 255]);
assert_eq!(colorized.get_pixel(1, 2).0, [0, 180, 255, 255]);
assert_eq!(colorized.get_pixel(0, 0).0, [0, 180, 255, 0]);
}
#[test]
fn mask_handle_trace_applies_pending_operations() {
let bounds = single_pixel_mask_handle()
.dilate_with(1.0)
.trace(&BoundingBoxVectorizer, &())
.expect("trace should run");
assert_eq!(bounds, Some(BoundingBox::new(1, 1, 3, 3)));
}
}
}
#[cfg(feature = "vectorizer-vtracer")]
mod gray_to_color_image_rgba {
use super::*;
mod unit {
use super::*;
#[test]
fn passthrough_no_threshold_no_invert() {
let mut input = GrayImage::new(2, 1);
input.put_pixel(0, 0, Luma([0]));
input.put_pixel(1, 0, Luma([128]));
let result = gray_to_color_image_rgba(&input, None, false);
assert_eq!(result.width, 2);
assert_eq!(result.height, 1);
assert_eq!(result.pixels[0..4], [0, 0, 0, 255]);
assert_eq!(result.pixels[4..8], [128, 128, 128, 255]);
}
#[test]
fn threshold_boundary() {
let mut input = GrayImage::new(3, 1);
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);
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]); }
#[test]
fn invert_without_threshold() {
let mut input = GrayImage::new(2, 1);
input.put_pixel(0, 0, Luma([0]));
input.put_pixel(1, 0, Luma([255]));
let result = gray_to_color_image_rgba(&input, None, true);
assert_eq!(result.pixels[0..4], [255, 255, 255, 255]); assert_eq!(result.pixels[4..8], [0, 0, 0, 255]); }
#[test]
fn invert_with_threshold() {
let mut input = GrayImage::new(2, 1);
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);
assert_eq!(result.pixels[0..4], [255, 255, 255, 255]);
assert_eq!(result.pixels[4..8], [0, 0, 0, 255]);
}
#[test]
fn alpha_always_255() {
let input = gray_image(3, 3, 100);
let result = gray_to_color_image_rgba(&input, None, false);
for i in 0..9 {
assert_eq!(result.pixels[i * 4 + 3], 255);
}
}
#[test]
fn dimensions_correct() {
let input = gray_image(7, 5, 128);
let result = gray_to_color_image_rgba(&input, None, false);
assert_eq!(result.width, 7);
assert_eq!(result.height, 5);
assert_eq!(result.pixels.len(), 7 * 5 * 4);
}
}
}
}