Skip to main content

spatialrust_vision/
dense.rs

1//! Dense mask, depth, flow, confidence, and point-map primitives.
2
3use std::collections::BTreeMap;
4
5use rayon::prelude::*;
6use spatialrust_image::{ColorSpace, Image, ImageMetadata, ImageView};
7
8use crate::{BoundingBox2, VisionError, VisionResult};
9
10/// Pixel connectivity used by binary-mask algorithms.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
12pub enum Connectivity {
13    /// Horizontal and vertical neighbors.
14    Four,
15    /// Horizontal, vertical, and diagonal neighbors.
16    #[default]
17    Eight,
18}
19
20/// Validated binary mask (`0` background, `1` foreground).
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct BinaryMask {
23    image: Image<u8, 1>,
24}
25
26impl BinaryMask {
27    /// Creates a mask and rejects values other than zero and one.
28    pub fn try_new(width: usize, height: usize, data: Vec<u8>) -> VisionResult<Self> {
29        if data.iter().any(|&value| value > 1) {
30            return Err(VisionError::InvalidParameter(
31                "binary mask values must be zero or one".to_owned(),
32            ));
33        }
34        let metadata = ImageMetadata { color_space: ColorSpace::Label, ..Default::default() };
35        Ok(Self { image: Image::try_new_with_metadata(width, height, data, metadata)? })
36    }
37
38    /// Thresholds a float score image into a binary mask.
39    pub fn from_threshold(input: ImageView<'_, f32, 1>, threshold: f32) -> VisionResult<Self> {
40        if !threshold.is_finite() {
41            return Err(VisionError::InvalidParameter("mask threshold must be finite".to_owned()));
42        }
43        let mut data = Vec::with_capacity(input.width() * input.height());
44        for y in 0..input.height() {
45            for x in 0..input.width() {
46                let value = input.get(x, y).expect("coordinate in bounds")[0];
47                data.push(u8::from(value.is_finite() && value >= threshold));
48            }
49        }
50        Self::try_new(input.width(), input.height(), data)
51    }
52
53    /// Returns mask width.
54    #[must_use]
55    pub const fn width(&self) -> usize {
56        self.image.width()
57    }
58
59    /// Returns mask height.
60    #[must_use]
61    pub const fn height(&self) -> usize {
62        self.image.height()
63    }
64
65    /// Borrows the underlying image.
66    #[must_use]
67    pub fn image(&self) -> &Image<u8, 1> {
68        &self.image
69    }
70
71    /// Borrows a mask view.
72    #[must_use]
73    pub fn view(&self) -> ImageView<'_, u8, 1> {
74        self.image.view()
75    }
76
77    /// Returns whether one pixel is foreground.
78    #[must_use]
79    pub fn contains(&self, x: usize, y: usize) -> bool {
80        self.image.get(x, y).is_some_and(|pixel| pixel[0] != 0)
81    }
82
83    /// Counts foreground pixels.
84    #[must_use]
85    pub fn area(&self) -> usize {
86        self.image.as_slice().iter().filter(|&&value| value != 0).count()
87    }
88
89    /// Consumes the wrapper and returns its image.
90    #[must_use]
91    pub fn into_image(self) -> Image<u8, 1> {
92        self.image
93    }
94}
95
96/// Computes the exact Euclidean distance from every foreground pixel to the
97/// nearest background pixel using unit pixel spacing.
98///
99/// Background pixels have distance zero. A non-empty mask must contain at
100/// least one background pixel; otherwise the finite distance is undefined and
101/// an error is returned. The implementation applies the separable linear-time
102/// squared-distance transform by Felzenszwalb and Huttenlocher.
103///
104/// See <https://doi.org/10.4086/toc.2012.v008a019>.
105pub fn distance_transform_edt(mask: &BinaryMask) -> VisionResult<Image<f32, 1>> {
106    distance_transform_edt_with_spacing(mask, 1.0, 1.0)
107}
108
109/// Reusable host scratch storage for the unit-spacing exact distance transform.
110///
111/// The workspace makes allocation reuse explicit and never shares mutable state
112/// between calls. Grow it once for the largest expected image, then pass it to
113/// [`distance_transform_edt_into`] on the same worker thread.
114#[derive(Debug, Default)]
115pub struct DistanceTransformWorkspace {
116    horizontal: Vec<u16>,
117    transposed: Vec<u16>,
118    column_major: Vec<f32>,
119}
120
121impl DistanceTransformWorkspace {
122    /// Creates an empty workspace that grows on first use.
123    #[must_use]
124    pub const fn new() -> Self {
125        Self { horizontal: Vec::new(), transposed: Vec::new(), column_major: Vec::new() }
126    }
127
128    /// Returns the largest pixel count currently reserved by every scratch plane.
129    #[must_use]
130    pub fn capacity(&self) -> usize {
131        self.horizontal.capacity().min(self.transposed.capacity()).min(self.column_major.capacity())
132    }
133
134    fn prepare(&mut self, len: usize) {
135        self.horizontal.resize(len, u16::MAX);
136        self.transposed.resize(len, 0);
137        self.column_major.resize(len, 0.0);
138    }
139}
140
141/// Computes the exact unit-spacing Euclidean distance transform into a reusable slice.
142///
143/// `output` must contain exactly `mask.width() * mask.height()` elements. Scratch
144/// allocations are owned by `workspace`; the caller retains ownership of the
145/// output allocation.
146pub fn distance_transform_edt_into(
147    mask: &BinaryMask,
148    output: &mut [f32],
149    workspace: &mut DistanceTransformWorkspace,
150) -> VisionResult<()> {
151    distance_transform_edt_u8_into(
152        mask.image().as_slice(),
153        mask.width(),
154        mask.height(),
155        output,
156        workspace,
157    )
158}
159
160/// Computes a unit-spacing exact distance transform from a packed `u8` mask.
161///
162/// Zero is background and every non-zero value is foreground, so common `0/255`
163/// image masks need no normalization copy. `input` and `output` must both have
164/// exactly `width * height` elements.
165pub fn distance_transform_edt_u8_into(
166    input: &[u8],
167    width: usize,
168    height: usize,
169    output: &mut [f32],
170    workspace: &mut DistanceTransformWorkspace,
171) -> VisionResult<()> {
172    let len = width.checked_mul(height).ok_or_else(|| {
173        VisionError::InvalidDimensions("distance-transform size overflows".into())
174    })?;
175    if input.len() != len {
176        return Err(VisionError::ShapeMismatch(format!(
177            "distance-transform input needs {len} elements, found {}",
178            input.len()
179        )));
180    }
181    if output.len() != len {
182        return Err(VisionError::ShapeMismatch(format!(
183            "distance-transform output needs {len} elements, found {}",
184            output.len()
185        )));
186    }
187    if len == 0 {
188        return Ok(());
189    }
190    if !input.contains(&0) {
191        return Err(VisionError::InvalidParameter(
192            "distance transform requires at least one background pixel".to_owned(),
193        ));
194    }
195    if width > u16::MAX as usize || height > u16::MAX as usize {
196        return Err(VisionError::InvalidDimensions(
197            "reusable unit distance transform supports dimensions up to 65535".to_owned(),
198        ));
199    }
200    workspace.prepare(len);
201    distance_transform_unit_into(input, width, height, workspace, output);
202    Ok(())
203}
204
205/// Computes the exact Euclidean distance transform with physical pixel spacing.
206///
207/// `spacing_x` and `spacing_y` are the positive finite distances between
208/// adjacent pixel centers along each axis. The output is expressed in those
209/// physical units.
210pub fn distance_transform_edt_with_spacing(
211    mask: &BinaryMask,
212    spacing_x: f32,
213    spacing_y: f32,
214) -> VisionResult<Image<f32, 1>> {
215    if !spacing_x.is_finite() || spacing_x <= 0.0 {
216        return Err(VisionError::InvalidParameter(
217            "distance-transform x spacing must be finite and positive".to_owned(),
218        ));
219    }
220    if !spacing_y.is_finite() || spacing_y <= 0.0 {
221        return Err(VisionError::InvalidParameter(
222            "distance-transform y spacing must be finite and positive".to_owned(),
223        ));
224    }
225
226    let width = mask.width();
227    let height = mask.height();
228    let len = width.checked_mul(height).ok_or_else(|| {
229        VisionError::InvalidDimensions("distance-transform size overflows".into())
230    })?;
231    if len == 0 {
232        return Ok(Image::try_new_with_metadata(
233            width,
234            height,
235            Vec::new(),
236            ImageMetadata { color_space: ColorSpace::Gray, ..Default::default() },
237        )?);
238    }
239    if mask.area() == len {
240        return Err(VisionError::InvalidParameter(
241            "distance transform requires at least one background pixel".to_owned(),
242        ));
243    }
244
245    if spacing_x == 1.0
246        && spacing_y == 1.0
247        && width <= u16::MAX as usize
248        && height <= u16::MAX as usize
249    {
250        let distances = distance_transform_unit(mask.image().as_slice(), width, height);
251        return Ok(Image::try_new_with_metadata(
252            width,
253            height,
254            distances,
255            ImageMetadata { color_space: ColorSpace::Gray, ..Default::default() },
256        )?);
257    }
258
259    let mut horizontal = vec![f64::INFINITY; len];
260    horizontal_binary_distance_rows(
261        mask.image().as_slice(),
262        width,
263        f64::from(spacing_x).powi(2),
264        &mut horizontal,
265    );
266
267    // Transpose in cache-sized tiles so the expensive second pass reads and
268    // writes each column linearly instead of gathering it with a full-row
269    // stride. The envelope arithmetic remains f64; only final distances are f32.
270    let mut transposed = vec![0.0_f64; len];
271    transpose_f64(&horizontal, width, height, &mut transposed);
272    drop(horizontal);
273    let mut column_major = vec![0.0_f32; len];
274    vertical_distance_columns(&transposed, height, f64::from(spacing_y).powi(2), &mut column_major);
275    let mut distances = vec![0.0_f32; len];
276    transpose_f32(&column_major, height, width, &mut distances);
277    Ok(Image::try_new_with_metadata(
278        width,
279        height,
280        distances,
281        ImageMetadata { color_space: ColorSpace::Gray, ..Default::default() },
282    )?)
283}
284
285fn distance_transform_unit(mask: &[u8], width: usize, height: usize) -> Vec<f32> {
286    let mut workspace = DistanceTransformWorkspace::new();
287    workspace.prepare(mask.len());
288    let mut distances = vec![0.0_f32; mask.len()];
289    distance_transform_unit_into(mask, width, height, &mut workspace, &mut distances);
290    distances
291}
292
293fn distance_transform_unit_into(
294    mask: &[u8],
295    width: usize,
296    height: usize,
297    workspace: &mut DistanceTransformWorkspace,
298    distances: &mut [f32],
299) {
300    let all_rows_have_background =
301        horizontal_binary_distance_rows_u16(mask, width, &mut workspace.horizontal);
302    transpose_u16(&workspace.horizontal, width, height, &mut workspace.transposed);
303    vertical_distance_columns_u16(
304        &workspace.transposed,
305        height,
306        all_rows_have_background,
307        &mut workspace.column_major,
308    );
309    transpose_f32(&workspace.column_major, height, width, distances);
310}
311
312fn horizontal_binary_distance_rows_u16(mask: &[u8], width: usize, output: &mut [u16]) -> bool {
313    const PARALLEL_THRESHOLD: usize = 128 * 1024;
314    let height = mask.len() / width;
315    let threads = worker_count(height);
316    if threads == 1 || mask.len() < PARALLEL_THRESHOLD {
317        return horizontal_binary_distance_rows_u16_serial(mask, width, output);
318    }
319    let rows_per_worker = height.div_ceil(threads);
320    mask.par_chunks(rows_per_worker * width)
321        .zip(output.par_chunks_mut(rows_per_worker * width))
322        .map(|(worker_mask, worker_output)| {
323            horizontal_binary_distance_rows_u16_serial(worker_mask, width, worker_output)
324        })
325        .all(std::convert::identity)
326}
327
328fn horizontal_binary_distance_rows_u16_serial(
329    mask: &[u8],
330    width: usize,
331    output: &mut [u16],
332) -> bool {
333    let mut all_rows_have_background = true;
334    for (mask_row, output_row) in mask.chunks_exact(width).zip(output.chunks_exact_mut(width)) {
335        let Some(first_background) = mask_row.iter().position(|&value| value == 0) else {
336            output_row.fill(u16::MAX);
337            all_rows_have_background = false;
338            continue;
339        };
340        for (x, value) in output_row[..first_background].iter_mut().enumerate() {
341            *value = (first_background - x) as u16;
342        }
343        output_row[first_background] = 0;
344        let mut distance = 0_u16;
345        for x in first_background + 1..width {
346            if mask_row[x] == 0 {
347                distance = 0;
348            } else {
349                distance += 1;
350            }
351            output_row[x] = distance;
352        }
353
354        let last_background =
355            mask_row.iter().rposition(|&value| value == 0).expect("row has a background pixel");
356        distance = 0;
357        for x in (0..last_background).rev() {
358            if mask_row[x] == 0 {
359                distance = 0;
360            } else {
361                distance += 1;
362            }
363            output_row[x] = output_row[x].min(distance);
364        }
365    }
366    all_rows_have_background
367}
368
369fn transpose_u16(input: &[u16], width: usize, height: usize, output: &mut [u16]) {
370    const PARALLEL_THRESHOLD: usize = 128 * 1024;
371    let threads = worker_count(width);
372    if threads == 1 || input.len() < PARALLEL_THRESHOLD {
373        transpose_u16_columns(input, width, height, 0, output);
374        return;
375    }
376    // Smaller chunks let Rayon overlap cache-heavy transpose work.
377    let task_count = threads.saturating_mul(2).min(width);
378    let columns_per_worker = width.div_ceil(task_count);
379    output.par_chunks_mut(columns_per_worker * height).enumerate().for_each(
380        |(worker_index, worker_output)| {
381            let x_start = worker_index * columns_per_worker;
382            transpose_u16_columns(input, width, height, x_start, worker_output);
383        },
384    );
385}
386
387fn transpose_u16_columns(
388    input: &[u16],
389    width: usize,
390    height: usize,
391    x_start: usize,
392    output: &mut [u16],
393) {
394    const TILE: usize = 32;
395    let column_count = output.len() / height;
396    for y_start in (0..height).step_by(TILE) {
397        for local_x_start in (0..column_count).step_by(TILE) {
398            let y_end = (y_start + TILE).min(height);
399            let local_x_end = (local_x_start + TILE).min(column_count);
400            for y in y_start..y_end {
401                for local_x in local_x_start..local_x_end {
402                    output[local_x * height + y] = input[y * width + x_start + local_x];
403                }
404            }
405        }
406    }
407}
408
409fn vertical_distance_columns_u16(
410    columns: &[u16],
411    height: usize,
412    all_finite: bool,
413    output: &mut [f32],
414) {
415    const PARALLEL_THRESHOLD: usize = 128 * 1024;
416    let width = columns.len() / height;
417    let threads = worker_count(width);
418    if threads == 1 || columns.len() < PARALLEL_THRESHOLD {
419        if all_finite {
420            vertical_distance_column_range_u16::<true>(columns, height, output);
421        } else {
422            vertical_distance_column_range_u16::<false>(columns, height, output);
423        }
424        return;
425    }
426    // More chunks than workers reduce the long tail caused by columns whose
427    // lower envelopes contain different numbers of parabolas.
428    let task_count = threads.saturating_mul(2).min(width);
429    let columns_per_worker = width.div_ceil(task_count);
430    if all_finite {
431        vertical_distance_columns_u16_parallel::<true>(columns, height, columns_per_worker, output);
432    } else {
433        vertical_distance_columns_u16_parallel::<false>(
434            columns,
435            height,
436            columns_per_worker,
437            output,
438        );
439    }
440}
441
442fn vertical_distance_columns_u16_parallel<const ALL_FINITE: bool>(
443    columns: &[u16],
444    height: usize,
445    columns_per_worker: usize,
446    output: &mut [f32],
447) {
448    columns
449        .par_chunks(columns_per_worker * height)
450        .zip(output.par_chunks_mut(columns_per_worker * height))
451        .for_each(|(worker_input, worker_output)| {
452            vertical_distance_column_range_u16::<ALL_FINITE>(worker_input, height, worker_output);
453        });
454}
455
456fn vertical_distance_column_range_u16<const ALL_FINITE: bool>(
457    columns: &[u16],
458    height: usize,
459    output: &mut [f32],
460) {
461    let mut sites = vec![0_usize; height];
462    let mut boundaries = vec![0.0_f64; height.saturating_add(1)];
463    let mut heights = vec![0.0_f64; height];
464    for (input_column, output_column) in
465        columns.chunks_exact(height).zip(output.chunks_exact_mut(height))
466    {
467        distance_transform_1d_u16::<ALL_FINITE>(
468            input_column,
469            output_column,
470            &mut sites,
471            &mut boundaries,
472            &mut heights,
473        );
474    }
475}
476
477fn distance_transform_1d_u16<const ALL_FINITE: bool>(
478    input: &[u16],
479    output: &mut [f32],
480    sites: &mut [usize],
481    boundaries: &mut [f64],
482    heights: &mut [f64],
483) {
484    for (coordinate, (&horizontal, height)) in input.iter().zip(heights.iter_mut()).enumerate() {
485        let coordinate = coordinate as f64;
486        let horizontal = f64::from(horizontal);
487        *height = coordinate * coordinate + horizontal * horizontal;
488    }
489    let first = if ALL_FINITE {
490        0
491    } else {
492        input.iter().position(|&value| value != u16::MAX).expect("background exists")
493    };
494    let mut envelope_end = 0;
495    sites[0] = first;
496    boundaries[0] = f64::NEG_INFINITY;
497    boundaries[1] = f64::INFINITY;
498    for (q, &horizontal) in input.iter().enumerate().skip(first + 1) {
499        if !ALL_FINITE && horizontal == u16::MAX {
500            continue;
501        }
502        let mut intersection = parabola_intersection_u16(heights, q, sites[envelope_end]);
503        while envelope_end > 0 && intersection <= boundaries[envelope_end] {
504            envelope_end -= 1;
505            intersection = parabola_intersection_u16(heights, q, sites[envelope_end]);
506        }
507        envelope_end += 1;
508        sites[envelope_end] = q;
509        boundaries[envelope_end] = intersection;
510        boundaries[envelope_end + 1] = f64::INFINITY;
511    }
512    let mut envelope = 0;
513    for (q, distance) in output.iter_mut().enumerate() {
514        while boundaries[envelope + 1] < q as f64 {
515            envelope += 1;
516        }
517        let site = sites[envelope];
518        let delta = q.abs_diff(site) as u64;
519        let horizontal = u64::from(input[site]);
520        *distance = ((delta * delta + horizontal * horizontal) as f32).sqrt();
521    }
522}
523
524fn parabola_intersection_u16(heights: &[f64], right: usize, left: usize) -> f64 {
525    (heights[right] - heights[left]) / (2.0 * (right - left) as f64)
526}
527
528fn transpose_f64(input: &[f64], width: usize, height: usize, output: &mut [f64]) {
529    const PARALLEL_THRESHOLD: usize = 128 * 1024;
530    let threads = worker_count(width);
531    if threads == 1 || input.len() < PARALLEL_THRESHOLD {
532        transpose_f64_columns(input, width, height, 0, output);
533        return;
534    }
535    let columns_per_worker = width.div_ceil(threads);
536    output.par_chunks_mut(columns_per_worker * height).enumerate().for_each(
537        |(worker_index, worker_output)| {
538            let x_start = worker_index * columns_per_worker;
539            transpose_f64_columns(input, width, height, x_start, worker_output);
540        },
541    );
542}
543
544fn transpose_f64_columns(
545    input: &[f64],
546    width: usize,
547    height: usize,
548    x_start: usize,
549    output: &mut [f64],
550) {
551    const TILE: usize = 32;
552    let column_count = output.len() / height;
553    for y_start in (0..height).step_by(TILE) {
554        for local_x_start in (0..column_count).step_by(TILE) {
555            let y_end = (y_start + TILE).min(height);
556            let local_x_end = (local_x_start + TILE).min(column_count);
557            for y in y_start..y_end {
558                for local_x in local_x_start..local_x_end {
559                    output[local_x * height + y] = input[y * width + x_start + local_x];
560                }
561            }
562        }
563    }
564}
565
566fn transpose_f32(input: &[f32], width: usize, height: usize, output: &mut [f32]) {
567    const PARALLEL_THRESHOLD: usize = 128 * 1024;
568    let threads = worker_count(width);
569    if threads == 1 || input.len() < PARALLEL_THRESHOLD {
570        transpose_f32_columns(input, width, height, 0, output);
571        return;
572    }
573    let columns_per_worker = width.div_ceil(threads);
574    output.par_chunks_mut(columns_per_worker * height).enumerate().for_each(
575        |(worker_index, worker_output)| {
576            let x_start = worker_index * columns_per_worker;
577            transpose_f32_columns(input, width, height, x_start, worker_output);
578        },
579    );
580}
581
582fn transpose_f32_columns(
583    input: &[f32],
584    width: usize,
585    height: usize,
586    x_start: usize,
587    output: &mut [f32],
588) {
589    const TILE: usize = 32;
590    let column_count = output.len() / height;
591    for y_start in (0..height).step_by(TILE) {
592        for local_x_start in (0..column_count).step_by(TILE) {
593            let y_end = (y_start + TILE).min(height);
594            let local_x_end = (local_x_start + TILE).min(column_count);
595            for y in y_start..y_end {
596                for local_x in local_x_start..local_x_end {
597                    output[local_x * height + y] = input[y * width + x_start + local_x];
598                }
599            }
600        }
601    }
602}
603
604fn horizontal_binary_distance_rows(
605    mask: &[u8],
606    width: usize,
607    spacing_squared: f64,
608    output: &mut [f64],
609) {
610    const PARALLEL_THRESHOLD: usize = 128 * 1024;
611    let height = mask.len() / width;
612    let threads = worker_count(height);
613    if threads == 1 || mask.len() < PARALLEL_THRESHOLD {
614        horizontal_binary_distance_rows_serial(mask, width, spacing_squared, output);
615        return;
616    }
617    let rows_per_worker = height.div_ceil(threads);
618    mask.par_chunks(rows_per_worker * width)
619        .zip(output.par_chunks_mut(rows_per_worker * width))
620        .for_each(|(worker_mask, worker_output)| {
621            horizontal_binary_distance_rows_serial(
622                worker_mask,
623                width,
624                spacing_squared,
625                worker_output,
626            );
627        });
628}
629
630fn horizontal_binary_distance_rows_serial(
631    mask: &[u8],
632    width: usize,
633    spacing_squared: f64,
634    output: &mut [f64],
635) {
636    for (mask_row, output_row) in mask.chunks_exact(width).zip(output.chunks_exact_mut(width)) {
637        let mut last_background = None;
638        for x in 0..width {
639            if mask_row[x] == 0 {
640                last_background = Some(x);
641                output_row[x] = 0.0;
642            } else if let Some(background) = last_background {
643                let delta = (x - background) as f64;
644                output_row[x] = spacing_squared * delta * delta;
645            }
646        }
647
648        let mut next_background = None;
649        for x in (0..width).rev() {
650            if mask_row[x] == 0 {
651                next_background = Some(x);
652            } else if let Some(background) = next_background {
653                let delta = (background - x) as f64;
654                output_row[x] = output_row[x].min(spacing_squared * delta * delta);
655            }
656        }
657    }
658}
659
660fn vertical_distance_columns(
661    columns: &[f64],
662    height: usize,
663    spacing_squared: f64,
664    output: &mut [f32],
665) {
666    const PARALLEL_THRESHOLD: usize = 128 * 1024;
667    let width = columns.len() / height;
668    let threads = worker_count(width);
669    if threads == 1 || columns.len() < PARALLEL_THRESHOLD {
670        vertical_distance_column_range(columns, height, spacing_squared, output);
671        return;
672    }
673
674    let columns_per_worker = width.div_ceil(threads);
675    columns
676        .par_chunks(columns_per_worker * height)
677        .zip(output.par_chunks_mut(columns_per_worker * height))
678        .for_each(|(worker_input, worker_output)| {
679            vertical_distance_column_range(worker_input, height, spacing_squared, worker_output);
680        });
681}
682
683fn worker_count(independent_items: usize) -> usize {
684    rayon::current_num_threads().min(independent_items)
685}
686
687fn vertical_distance_column_range(
688    columns: &[f64],
689    height: usize,
690    spacing_squared: f64,
691    output: &mut [f32],
692) {
693    let mut sites = vec![0_usize; height];
694    let mut boundaries = vec![0.0_f64; height.saturating_add(1)];
695    for (input_column, output_column) in
696        columns.chunks_exact(height).zip(output.chunks_exact_mut(height))
697    {
698        distance_transform_1d_to_f32(
699            input_column,
700            spacing_squared,
701            output_column,
702            &mut sites,
703            &mut boundaries,
704        );
705    }
706}
707
708fn distance_transform_1d_to_f32(
709    input: &[f64],
710    coordinate_scale_squared: f64,
711    output: &mut [f32],
712    sites: &mut [usize],
713    boundaries: &mut [f64],
714) {
715    debug_assert_eq!(input.len(), output.len());
716    let Some(first) = input.iter().position(|value| value.is_finite()) else {
717        output.fill(f32::INFINITY);
718        return;
719    };
720    let mut envelope_end = 0;
721    sites[0] = first;
722    boundaries[0] = f64::NEG_INFINITY;
723    boundaries[1] = f64::INFINITY;
724    for q in first + 1..input.len() {
725        if !input[q].is_finite() {
726            continue;
727        }
728        let mut intersection =
729            parabola_intersection(input, coordinate_scale_squared, q, sites[envelope_end]);
730        while envelope_end > 0 && intersection <= boundaries[envelope_end] {
731            envelope_end -= 1;
732            intersection =
733                parabola_intersection(input, coordinate_scale_squared, q, sites[envelope_end]);
734        }
735        envelope_end += 1;
736        sites[envelope_end] = q;
737        boundaries[envelope_end] = intersection;
738        boundaries[envelope_end + 1] = f64::INFINITY;
739    }
740
741    let mut envelope = 0;
742    for (q, distance) in output.iter_mut().enumerate() {
743        while boundaries[envelope + 1] < q as f64 {
744            envelope += 1;
745        }
746        let site = sites[envelope];
747        let delta = q as f64 - site as f64;
748        *distance = coordinate_scale_squared.mul_add(delta * delta, input[site]).sqrt() as f32;
749    }
750}
751
752fn parabola_intersection(
753    input: &[f64],
754    coordinate_scale_squared: f64,
755    right: usize,
756    left: usize,
757) -> f64 {
758    let right_coordinate = right as f64;
759    let left_coordinate = left as f64;
760    let right_height =
761        coordinate_scale_squared.mul_add(right_coordinate * right_coordinate, input[right]);
762    let left_height =
763        coordinate_scale_squared.mul_add(left_coordinate * left_coordinate, input[left]);
764    (right_height - left_height)
765        / (2.0 * coordinate_scale_squared * (right_coordinate - left_coordinate))
766}
767
768/// Connected-component label image (`0` is background).
769#[derive(Clone, Debug, PartialEq, Eq)]
770pub struct LabelImage {
771    image: Image<u32, 1>,
772}
773
774impl LabelImage {
775    /// Returns label image width.
776    #[must_use]
777    pub const fn width(&self) -> usize {
778        self.image.width()
779    }
780
781    /// Returns label image height.
782    #[must_use]
783    pub const fn height(&self) -> usize {
784        self.image.height()
785    }
786
787    /// Returns packed labels.
788    #[must_use]
789    pub fn as_slice(&self) -> &[u32] {
790        self.image.as_slice()
791    }
792
793    /// Returns one label.
794    #[must_use]
795    pub fn get(&self, x: usize, y: usize) -> Option<u32> {
796        self.image.get(x, y).map(|pixel| pixel[0])
797    }
798
799    /// Borrows the underlying image.
800    #[must_use]
801    pub fn image(&self) -> &Image<u32, 1> {
802        &self.image
803    }
804
805    /// Consumes the wrapper and returns its image.
806    #[must_use]
807    pub fn into_image(self) -> Image<u32, 1> {
808        self.image
809    }
810}
811
812/// Per-component statistics.
813#[derive(Clone, Copy, Debug, PartialEq)]
814pub struct ComponentStats {
815    /// Positive component label.
816    pub label: u32,
817    /// Foreground pixel count.
818    pub area: usize,
819    /// Half-open pixel bounding box.
820    pub bbox: BoundingBox2,
821    /// Mean pixel-center coordinate.
822    pub centroid: [f64; 2],
823}
824
825/// Connected-component labeling output.
826#[derive(Clone, Debug, PartialEq)]
827pub struct ConnectedComponents {
828    /// Per-pixel labels.
829    pub labels: LabelImage,
830    /// Statistics ordered by positive label.
831    pub components: Vec<ComponentStats>,
832}
833
834/// Labels foreground components and computes bounding boxes and centroids.
835pub fn connected_components(
836    mask: &BinaryMask,
837    connectivity: Connectivity,
838) -> VisionResult<ConnectedComponents> {
839    connected_components_u8(mask.width(), mask.height(), mask.image.as_slice(), connectivity)
840}
841
842/// Labels connected non-zero pixels from packed `u8` mask storage.
843///
844/// This borrowed-input variant avoids constructing an owned [`BinaryMask`]
845/// when the caller already has packed mask storage. Like OpenCV, every
846/// non-zero byte is foreground. Positive labels follow the first foreground
847/// run of each component in row-major order.
848pub fn connected_components_u8(
849    width: usize,
850    height: usize,
851    pixels: &[u8],
852    connectivity: Connectivity,
853) -> VisionResult<ConnectedComponents> {
854    let expected = width
855        .checked_mul(height)
856        .ok_or_else(|| VisionError::InvalidDimensions("mask dimensions overflow".to_owned()))?;
857    if pixels.len() != expected {
858        return Err(VisionError::InvalidDimensions(format!(
859            "mask storage has {} pixels, expected {expected}",
860            pixels.len()
861        )));
862    }
863    let mut labels = vec![0_u32; width * height];
864    let mut runs = Vec::new();
865    let mut previous_runs = 0..0;
866
867    for y in 0..height {
868        let current_start = runs.len();
869        let row = &pixels[y * width..(y + 1) * width];
870        let mut x = 0;
871        let mut previous_cursor = previous_runs.start;
872        while x < width {
873            while x < width && row[x] == 0 {
874                x += 1;
875            }
876            if x == width {
877                break;
878            }
879            let start = x;
880            while x < width && row[x] != 0 {
881                x += 1;
882            }
883            let end = x;
884            let run_index = runs.len();
885            runs.push(ComponentRun { y, start, end, parent: run_index });
886
887            while previous_cursor < previous_runs.end
888                && run_precedes(runs[previous_cursor], start, connectivity)
889            {
890                previous_cursor += 1;
891            }
892            let mut overlap = previous_cursor;
893            while overlap < previous_runs.end && !run_follows(runs[overlap], end, connectivity) {
894                union_component_runs(&mut runs, run_index, overlap);
895                overlap += 1;
896            }
897        }
898        previous_runs = current_start..runs.len();
899    }
900
901    let mut root_labels = vec![0_u32; runs.len()];
902    let mut accumulators = Vec::<ComponentAccumulator>::new();
903    let mut next_label = 1_u32;
904    for run_index in 0..runs.len() {
905        let run = runs[run_index];
906        let root = component_run_root(&mut runs, run_index);
907        let label = if root_labels[root] == 0 {
908            let label = next_label;
909            root_labels[root] = label;
910            accumulators.push(ComponentAccumulator::new(run));
911            next_label = next_label
912                .checked_add(1)
913                .ok_or_else(|| VisionError::InvalidDimensions("too many components".to_owned()))?;
914            label
915        } else {
916            root_labels[root]
917        };
918        labels[run.y * width + run.start..run.y * width + run.end].fill(label);
919        accumulators[label as usize - 1].include(run);
920    }
921
922    let components = accumulators
923        .into_iter()
924        .enumerate()
925        .map(|(index, accumulator)| accumulator.finish(index as u32 + 1))
926        .collect();
927    let metadata = ImageMetadata { color_space: ColorSpace::Label, ..Default::default() };
928    let image = Image::try_new_with_metadata(width, height, labels, metadata)?;
929    Ok(ConnectedComponents { labels: LabelImage { image }, components })
930}
931
932#[derive(Clone, Copy)]
933struct ComponentRun {
934    y: usize,
935    start: usize,
936    end: usize,
937    parent: usize,
938}
939
940fn run_precedes(run: ComponentRun, start: usize, connectivity: Connectivity) -> bool {
941    match connectivity {
942        Connectivity::Four => run.end <= start,
943        Connectivity::Eight => run.end < start,
944    }
945}
946
947fn run_follows(run: ComponentRun, end: usize, connectivity: Connectivity) -> bool {
948    match connectivity {
949        Connectivity::Four => run.start >= end,
950        Connectivity::Eight => run.start > end,
951    }
952}
953
954fn component_run_root(runs: &mut [ComponentRun], mut index: usize) -> usize {
955    let mut root = index;
956    while runs[root].parent != root {
957        root = runs[root].parent;
958    }
959    while runs[index].parent != index {
960        let parent = runs[index].parent;
961        runs[index].parent = root;
962        index = parent;
963    }
964    root
965}
966
967fn union_component_runs(runs: &mut [ComponentRun], left: usize, right: usize) {
968    let left_root = component_run_root(runs, left);
969    let right_root = component_run_root(runs, right);
970    if left_root != right_root {
971        let (root, child) =
972            if left_root < right_root { (left_root, right_root) } else { (right_root, left_root) };
973        runs[child].parent = root;
974    }
975}
976
977struct ComponentAccumulator {
978    area: usize,
979    min_x: usize,
980    min_y: usize,
981    max_x: usize,
982    max_y: usize,
983    sum_x: f64,
984    sum_y: f64,
985}
986
987impl ComponentAccumulator {
988    fn new(run: ComponentRun) -> Self {
989        Self {
990            area: 0,
991            min_x: run.start,
992            min_y: run.y,
993            max_x: run.end,
994            max_y: run.y + 1,
995            sum_x: 0.0,
996            sum_y: 0.0,
997        }
998    }
999
1000    fn include(&mut self, run: ComponentRun) {
1001        let length = run.end - run.start;
1002        self.area += length;
1003        self.min_x = self.min_x.min(run.start);
1004        self.min_y = self.min_y.min(run.y);
1005        self.max_x = self.max_x.max(run.end);
1006        self.max_y = self.max_y.max(run.y + 1);
1007        self.sum_x += length as f64 * (run.start as f64 + run.end as f64) * 0.5;
1008        self.sum_y += length as f64 * (run.y as f64 + 0.5);
1009    }
1010
1011    fn finish(self, label: u32) -> ComponentStats {
1012        ComponentStats {
1013            label,
1014            area: self.area,
1015            bbox: BoundingBox2 {
1016                x_min: self.min_x as f32,
1017                y_min: self.min_y as f32,
1018                x_max: self.max_x as f32,
1019                y_max: self.max_y as f32,
1020            },
1021            centroid: [self.sum_x / self.area as f64, self.sum_y / self.area as f64],
1022        }
1023    }
1024}
1025
1026/// One closed polygonal contour on pixel-grid corner coordinates.
1027#[derive(Clone, Debug, PartialEq, Eq)]
1028pub struct Contour {
1029    /// Ordered vertices. The first vertex is not repeated at the end.
1030    pub points: Vec<[i32; 2]>,
1031}
1032
1033/// Extracts oriented boundary loops, including hole contours.
1034pub fn find_contours(mask: &BinaryMask) -> Vec<Contour> {
1035    type Point = (i32, i32);
1036    let mut edges: BTreeMap<Point, Vec<Point>> = BTreeMap::new();
1037    let foreground = |x: isize, y: isize| {
1038        x >= 0
1039            && y >= 0
1040            && (x as usize) < mask.width()
1041            && (y as usize) < mask.height()
1042            && mask.contains(x as usize, y as usize)
1043    };
1044    for y in 0..mask.height() as isize {
1045        for x in 0..mask.width() as isize {
1046            if !foreground(x, y) {
1047                continue;
1048            }
1049            let x = x as i32;
1050            let y = y as i32;
1051            if !foreground(x as isize, y as isize - 1) {
1052                add_edge(&mut edges, (x, y), (x + 1, y));
1053            }
1054            if !foreground(x as isize + 1, y as isize) {
1055                add_edge(&mut edges, (x + 1, y), (x + 1, y + 1));
1056            }
1057            if !foreground(x as isize, y as isize + 1) {
1058                add_edge(&mut edges, (x + 1, y + 1), (x, y + 1));
1059            }
1060            if !foreground(x as isize - 1, y as isize) {
1061                add_edge(&mut edges, (x, y + 1), (x, y));
1062            }
1063        }
1064    }
1065
1066    let mut contours = Vec::new();
1067    while let Some((&start, _)) = edges.iter().next() {
1068        let mut current = start;
1069        let mut points = Vec::new();
1070        loop {
1071            points.push([current.0, current.1]);
1072            let Some(next) = take_edge(&mut edges, current) else {
1073                break;
1074            };
1075            current = next;
1076            if current == start {
1077                break;
1078            }
1079        }
1080        if points.len() >= 3 {
1081            contours.push(Contour { points });
1082        }
1083    }
1084    contours
1085}
1086
1087fn add_edge(edges: &mut BTreeMap<(i32, i32), Vec<(i32, i32)>>, start: (i32, i32), end: (i32, i32)) {
1088    edges.entry(start).or_default().push(end);
1089}
1090
1091fn take_edge(
1092    edges: &mut BTreeMap<(i32, i32), Vec<(i32, i32)>>,
1093    start: (i32, i32),
1094) -> Option<(i32, i32)> {
1095    let values = edges.get_mut(&start)?;
1096    values.sort_unstable();
1097    let end = values.remove(0);
1098    if values.is_empty() {
1099        edges.remove(&start);
1100    }
1101    Some(end)
1102}
1103
1104/// Simplifies a closed contour with Ramer–Douglas–Peucker approximation.
1105pub fn approximate_polygon(contour: &Contour, epsilon: f64) -> VisionResult<Contour> {
1106    if !epsilon.is_finite() || epsilon < 0.0 {
1107        return Err(VisionError::InvalidParameter(
1108            "polygon epsilon must be finite and non-negative".to_owned(),
1109        ));
1110    }
1111    if contour.points.len() <= 3 || epsilon == 0.0 {
1112        return Ok(contour.clone());
1113    }
1114    // Rotate at the point farthest from vertex 0, then simplify the two open
1115    // arcs independently so a closed loop has stable endpoints.
1116    let first = contour.points[0];
1117    let split = contour
1118        .points
1119        .iter()
1120        .enumerate()
1121        .max_by_key(|(_, point)| squared_distance(**point, first))
1122        .map_or(0, |(index, _)| index);
1123    let mut left = rdp(&contour.points[..=split], epsilon);
1124    let mut wrapped = contour.points[split..].to_vec();
1125    wrapped.push(first);
1126    let right = rdp(&wrapped, epsilon);
1127    left.extend(right.into_iter().skip(1).take_while(|point| *point != first));
1128    Ok(Contour { points: left })
1129}
1130
1131fn rdp(points: &[[i32; 2]], epsilon: f64) -> Vec<[i32; 2]> {
1132    if points.len() <= 2 {
1133        return points.to_vec();
1134    }
1135    let first = points[0];
1136    let last = points[points.len() - 1];
1137    let mut max_distance = 0.0;
1138    let mut split = 0;
1139    for (index, &point) in points.iter().enumerate().take(points.len() - 1).skip(1) {
1140        let distance = point_segment_distance(point, first, last);
1141        if distance > max_distance {
1142            max_distance = distance;
1143            split = index;
1144        }
1145    }
1146    if max_distance <= epsilon {
1147        return vec![first, last];
1148    }
1149    let mut left = rdp(&points[..=split], epsilon);
1150    let right = rdp(&points[split..], epsilon);
1151    left.extend(right.into_iter().skip(1));
1152    left
1153}
1154
1155fn squared_distance(a: [i32; 2], b: [i32; 2]) -> i64 {
1156    let dx = i64::from(a[0]) - i64::from(b[0]);
1157    let dy = i64::from(a[1]) - i64::from(b[1]);
1158    dx * dx + dy * dy
1159}
1160
1161fn point_segment_distance(point: [i32; 2], start: [i32; 2], end: [i32; 2]) -> f64 {
1162    let px = f64::from(point[0]);
1163    let py = f64::from(point[1]);
1164    let sx = f64::from(start[0]);
1165    let sy = f64::from(start[1]);
1166    let dx = f64::from(end[0] - start[0]);
1167    let dy = f64::from(end[1] - start[1]);
1168    let length_squared = dx.mul_add(dx, dy * dy);
1169    if length_squared == 0.0 {
1170        return (px - sx).hypot(py - sy);
1171    }
1172    let t = ((px - sx).mul_add(dx, (py - sy) * dy) / length_squared).clamp(0.0, 1.0);
1173    (px - sx - t * dx).hypot(py - sy - t * dy)
1174}
1175
1176/// Linearization order used by run-length encoded masks.
1177#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1178pub enum RleOrder {
1179    /// Conventional row-major traversal.
1180    #[default]
1181    RowMajor,
1182    /// COCO-compatible column-major traversal.
1183    CocoColumnMajor,
1184}
1185
1186/// Alternating zero/one run lengths, always beginning with a zero run.
1187#[derive(Clone, Debug, PartialEq, Eq)]
1188pub struct MaskRle {
1189    /// Mask width.
1190    pub width: usize,
1191    /// Mask height.
1192    pub height: usize,
1193    /// Linearization order.
1194    pub order: RleOrder,
1195    /// Alternating zero and one run lengths.
1196    pub counts: Vec<usize>,
1197}
1198
1199/// Encodes a binary mask into alternating runs.
1200#[must_use]
1201pub fn encode_rle(mask: &BinaryMask, order: RleOrder) -> MaskRle {
1202    let mut counts = vec![0_usize];
1203    let mut current = 0_u8;
1204    for index in 0..mask.width() * mask.height() {
1205        let (x, y) = linear_coordinate(index, mask.width(), mask.height(), order);
1206        let value = u8::from(mask.contains(x, y));
1207        if value == current {
1208            *counts.last_mut().expect("initial run exists") += 1;
1209        } else {
1210            counts.push(1);
1211            current = value;
1212        }
1213    }
1214    MaskRle { width: mask.width(), height: mask.height(), order, counts }
1215}
1216
1217/// Decodes a run-length mask and validates its total length.
1218pub fn decode_rle(rle: &MaskRle) -> VisionResult<BinaryMask> {
1219    let total = rle
1220        .width
1221        .checked_mul(rle.height)
1222        .ok_or_else(|| VisionError::InvalidDimensions("RLE dimensions overflow".to_owned()))?;
1223    let encoded = rle.counts.iter().try_fold(0_usize, |sum, &count| sum.checked_add(count));
1224    if encoded != Some(total) {
1225        return Err(VisionError::InvalidParameter(
1226            "RLE run lengths do not match mask dimensions".to_owned(),
1227        ));
1228    }
1229    let mut data = vec![0_u8; total];
1230    let mut linear = 0;
1231    let mut value = 0_u8;
1232    for &count in &rle.counts {
1233        for _ in 0..count {
1234            let (x, y) = linear_coordinate(linear, rle.width, rle.height, rle.order);
1235            data[y * rle.width + x] = value;
1236            linear += 1;
1237        }
1238        value ^= 1;
1239    }
1240    BinaryMask::try_new(rle.width, rle.height, data)
1241}
1242
1243fn linear_coordinate(index: usize, width: usize, height: usize, order: RleOrder) -> (usize, usize) {
1244    match order {
1245        RleOrder::RowMajor => (index % width, index / width),
1246        RleOrder::CocoColumnMajor => (index / height, index % height),
1247    }
1248}
1249
1250/// Metric or relative dense depth map. Non-positive and non-finite values are invalid.
1251#[derive(Clone, Debug, PartialEq)]
1252pub struct DepthMap {
1253    image: Image<f32, 1>,
1254}
1255
1256impl DepthMap {
1257    /// Wraps a depth image and marks its semantic metadata.
1258    pub fn try_new(width: usize, height: usize, data: Vec<f32>) -> VisionResult<Self> {
1259        if data.iter().any(|value| value.is_infinite()) {
1260            return Err(VisionError::InvalidParameter(
1261                "depth values may be finite or NaN, but not infinite".to_owned(),
1262            ));
1263        }
1264        let metadata = ImageMetadata { color_space: ColorSpace::Depth, ..Default::default() };
1265        Ok(Self { image: Image::try_new_with_metadata(width, height, data, metadata)? })
1266    }
1267
1268    /// Borrows the depth image.
1269    #[must_use]
1270    pub fn image(&self) -> &Image<f32, 1> {
1271        &self.image
1272    }
1273
1274    /// Builds a validity mask for an inclusive depth range.
1275    pub fn valid_mask(&self, min_depth: f32, max_depth: f32) -> VisionResult<BinaryMask> {
1276        if !min_depth.is_finite() || min_depth < 0.0 || max_depth.is_nan() || max_depth < min_depth
1277        {
1278            return Err(VisionError::InvalidParameter("invalid depth range".to_owned()));
1279        }
1280        BinaryMask::try_new(
1281            self.image.width(),
1282            self.image.height(),
1283            self.image
1284                .as_slice()
1285                .iter()
1286                .map(|&value| {
1287                    u8::from(value.is_finite() && value >= min_depth && value <= max_depth)
1288                })
1289                .collect(),
1290        )
1291    }
1292}
1293
1294/// Dense confidence values constrained to `[0, 1]`.
1295#[derive(Clone, Debug, PartialEq)]
1296pub struct ConfidenceMap {
1297    image: Image<f32, 1>,
1298}
1299
1300impl ConfidenceMap {
1301    /// Creates a validated confidence map.
1302    pub fn try_new(width: usize, height: usize, data: Vec<f32>) -> VisionResult<Self> {
1303        if data.iter().any(|value| !value.is_finite() || !(0.0..=1.0).contains(value)) {
1304            return Err(VisionError::InvalidParameter(
1305                "confidence values must be finite and in [0, 1]".to_owned(),
1306            ));
1307        }
1308        Ok(Self { image: Image::try_new(width, height, data)? })
1309    }
1310
1311    /// Borrows the confidence image.
1312    #[must_use]
1313    pub fn image(&self) -> &Image<f32, 1> {
1314        &self.image
1315    }
1316
1317    /// Returns confidence map width.
1318    #[must_use]
1319    pub const fn width(&self) -> usize {
1320        self.image.width()
1321    }
1322
1323    /// Returns confidence map height.
1324    #[must_use]
1325    pub const fn height(&self) -> usize {
1326        self.image.height()
1327    }
1328}
1329
1330/// Dense `(dx, dy)` optical-flow field in pixel units.
1331#[derive(Clone, Debug, PartialEq)]
1332pub struct FlowField {
1333    image: Image<f32, 2>,
1334}
1335
1336impl FlowField {
1337    /// Creates a flow field. NaN marks invalid flow; infinity is rejected.
1338    pub fn try_new(width: usize, height: usize, data: Vec<f32>) -> VisionResult<Self> {
1339        if data.iter().any(|value| value.is_infinite()) {
1340            return Err(VisionError::InvalidParameter(
1341                "flow values may be finite or NaN, but not infinite".to_owned(),
1342            ));
1343        }
1344        Ok(Self { image: Image::try_new(width, height, data)? })
1345    }
1346
1347    /// Borrows the flow image.
1348    #[must_use]
1349    pub fn image(&self) -> &Image<f32, 2> {
1350        &self.image
1351    }
1352
1353    /// Converts displacement vectors to absolute remap coordinates.
1354    pub fn to_remap(&self) -> VisionResult<(Image<f32, 1>, Image<f32, 1>)> {
1355        let mut map_x = Vec::with_capacity(self.image.width() * self.image.height());
1356        let mut map_y = Vec::with_capacity(self.image.width() * self.image.height());
1357        for y in 0..self.image.height() {
1358            for x in 0..self.image.width() {
1359                let flow = self.image.get(x, y).expect("coordinate in bounds");
1360                map_x.push(x as f32 + flow[0]);
1361                map_y.push(y as f32 + flow[1]);
1362            }
1363        }
1364        Ok((
1365            Image::try_new(self.image.width(), self.image.height(), map_x)?,
1366            Image::try_new(self.image.width(), self.image.height(), map_y)?,
1367        ))
1368    }
1369}
1370
1371/// Dense per-pixel XYZ point map. Non-finite triples represent invalid points.
1372#[derive(Clone, Debug, PartialEq)]
1373pub struct PointMap {
1374    image: Image<f32, 3>,
1375}
1376
1377impl PointMap {
1378    /// Creates a point map and rejects infinite components.
1379    pub fn try_new(width: usize, height: usize, data: Vec<f32>) -> VisionResult<Self> {
1380        if data.iter().any(|value| value.is_infinite()) {
1381            return Err(VisionError::InvalidParameter(
1382                "point-map values may be finite or NaN, but not infinite".to_owned(),
1383            ));
1384        }
1385        Ok(Self { image: Image::try_new(width, height, data)? })
1386    }
1387
1388    /// Borrows the XYZ image.
1389    #[must_use]
1390    pub fn image(&self) -> &Image<f32, 3> {
1391        &self.image
1392    }
1393
1394    /// Returns point-map width.
1395    #[must_use]
1396    pub const fn width(&self) -> usize {
1397        self.image.width()
1398    }
1399
1400    /// Returns point-map height.
1401    #[must_use]
1402    pub const fn height(&self) -> usize {
1403        self.image.height()
1404    }
1405
1406    /// Returns a mask where all XYZ components are finite.
1407    pub fn valid_mask(&self) -> VisionResult<BinaryMask> {
1408        let mut data = Vec::with_capacity(self.image.width() * self.image.height());
1409        for y in 0..self.image.height() {
1410            for x in 0..self.image.width() {
1411                let point = self.image.get(x, y).expect("coordinate in bounds");
1412                data.push(u8::from(point.iter().all(|value| value.is_finite())));
1413            }
1414        }
1415        BinaryMask::try_new(self.image.width(), self.image.height(), data)
1416    }
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421    use super::{
1422        approximate_polygon, connected_components, connected_components_u8, decode_rle,
1423        distance_transform_edt, distance_transform_edt_u8_into,
1424        distance_transform_edt_with_spacing, encode_rle, find_contours, BinaryMask, Connectivity,
1425        DepthMap, DistanceTransformWorkspace, FlowField, PointMap, RleOrder,
1426    };
1427    use spatialrust_image::Image;
1428    use std::collections::VecDeque;
1429
1430    #[test]
1431    fn threshold_and_components_find_two_regions() {
1432        let scores = Image::<f32, 1>::try_new(
1433            4,
1434            3,
1435            vec![1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
1436        )
1437        .unwrap();
1438        let mask = BinaryMask::from_threshold(scores.view(), 0.5).unwrap();
1439        let result = connected_components(&mask, Connectivity::Four).unwrap();
1440        assert_eq!(result.components.len(), 2);
1441        assert_eq!(result.components[0].area, 4);
1442        assert_eq!(result.components[1].area, 2);
1443        assert_eq!(result.components[0].centroid, [1.0, 1.0]);
1444    }
1445
1446    #[test]
1447    fn run_length_components_match_pixel_flood_fill() {
1448        let mut state = 0x9e37_79b9_u32;
1449        for connectivity in [Connectivity::Four, Connectivity::Eight] {
1450            for case in 0..96 {
1451                let width = 1 + case % 37;
1452                let height = 1 + (case * 11) % 29;
1453                let cutoff = match case % 4 {
1454                    0 => 40,
1455                    1 => 250,
1456                    2 => 600,
1457                    _ => 850,
1458                };
1459                let data = (0..width * height)
1460                    .map(|_| {
1461                        state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
1462                        u8::from(state % 1_000 < cutoff)
1463                    })
1464                    .collect::<Vec<_>>();
1465                let mask = BinaryMask::try_new(width, height, data.clone()).unwrap();
1466                let actual = connected_components(&mask, connectivity).unwrap();
1467                let expected = flood_fill_labels(&data, width, height, connectivity);
1468                assert_eq!(actual.labels.as_slice(), expected, "case {case:?} {connectivity:?}");
1469
1470                let max_label = expected.iter().copied().max().unwrap_or(0) as usize;
1471                assert_eq!(actual.components.len(), max_label);
1472                for component in &actual.components {
1473                    let mut area = 0;
1474                    let mut min_x = width;
1475                    let mut min_y = height;
1476                    let mut max_x = 0;
1477                    let mut max_y = 0;
1478                    let mut sum_x = 0.0;
1479                    let mut sum_y = 0.0;
1480                    for (index, &label) in expected.iter().enumerate() {
1481                        if label == component.label {
1482                            let (x, y) = (index % width, index / width);
1483                            area += 1;
1484                            min_x = min_x.min(x);
1485                            min_y = min_y.min(y);
1486                            max_x = max_x.max(x + 1);
1487                            max_y = max_y.max(y + 1);
1488                            sum_x += x as f64 + 0.5;
1489                            sum_y += y as f64 + 0.5;
1490                        }
1491                    }
1492                    assert_eq!(component.area, area);
1493                    assert_eq!(component.bbox.x_min, min_x as f32);
1494                    assert_eq!(component.bbox.y_min, min_y as f32);
1495                    assert_eq!(component.bbox.x_max, max_x as f32);
1496                    assert_eq!(component.bbox.y_max, max_y as f32);
1497                    assert_eq!(component.centroid, [sum_x / area as f64, sum_y / area as f64]);
1498                }
1499            }
1500        }
1501    }
1502
1503    #[test]
1504    fn borrowed_u8_components_accept_all_nonzero_values() {
1505        let pixels = [0, 2, 0, 255, 0, 7];
1506        let result = connected_components_u8(3, 2, &pixels, Connectivity::Eight).unwrap();
1507        assert_eq!(result.labels.as_slice(), [0, 1, 0, 1, 0, 1]);
1508        assert_eq!(result.components[0].area, 3);
1509        assert!(connected_components_u8(3, 2, &pixels[..5], Connectivity::Eight).is_err());
1510    }
1511
1512    fn flood_fill_labels(
1513        data: &[u8],
1514        width: usize,
1515        height: usize,
1516        connectivity: Connectivity,
1517    ) -> Vec<u32> {
1518        let offsets: &[(isize, isize)] = match connectivity {
1519            Connectivity::Four => &[(0, -1), (-1, 0), (1, 0), (0, 1)],
1520            Connectivity::Eight => {
1521                &[(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]
1522            }
1523        };
1524        let mut labels = vec![0_u32; data.len()];
1525        let mut queue = VecDeque::new();
1526        let mut next_label = 1_u32;
1527        for seed in 0..data.len() {
1528            if data[seed] == 0 || labels[seed] != 0 {
1529                continue;
1530            }
1531            labels[seed] = next_label;
1532            queue.push_back((seed % width, seed / width));
1533            while let Some((x, y)) = queue.pop_front() {
1534                for &(dx, dy) in offsets {
1535                    let Some(nx) = x.checked_add_signed(dx) else {
1536                        continue;
1537                    };
1538                    let Some(ny) = y.checked_add_signed(dy) else {
1539                        continue;
1540                    };
1541                    if nx >= width || ny >= height {
1542                        continue;
1543                    }
1544                    let index = ny * width + nx;
1545                    if data[index] != 0 && labels[index] == 0 {
1546                        labels[index] = next_label;
1547                        queue.push_back((nx, ny));
1548                    }
1549                }
1550            }
1551            next_label += 1;
1552        }
1553        labels
1554    }
1555
1556    #[test]
1557    fn contours_trace_outer_and_hole_loops() {
1558        let mask = BinaryMask::try_new(3, 3, vec![1, 1, 1, 1, 0, 1, 1, 1, 1]).unwrap();
1559        let contours = find_contours(&mask);
1560        assert_eq!(contours.len(), 2);
1561        assert!(contours.iter().all(|contour| contour.points.len() >= 4));
1562        let simplified = approximate_polygon(&contours[0], 0.1).unwrap();
1563        assert!(simplified.points.len() <= contours[0].points.len());
1564    }
1565
1566    #[test]
1567    fn rle_roundtrips_both_orders() {
1568        let mask = BinaryMask::try_new(3, 2, vec![0, 1, 1, 1, 0, 1]).unwrap();
1569        for order in [RleOrder::RowMajor, RleOrder::CocoColumnMajor] {
1570            let encoded = encode_rle(&mask, order);
1571            assert_eq!(decode_rle(&encoded).unwrap(), mask);
1572        }
1573    }
1574
1575    #[test]
1576    fn exact_distance_transform_matches_known_grid() {
1577        let mask = BinaryMask::try_new(4, 3, vec![0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]).unwrap();
1578        let distances = distance_transform_edt(&mask).unwrap();
1579        let expected = [
1580            0.0,
1581            1.0,
1582            2.0,
1583            3.0,
1584            1.0,
1585            2.0_f32.sqrt(),
1586            5.0_f32.sqrt(),
1587            10.0_f32.sqrt(),
1588            2.0,
1589            5.0_f32.sqrt(),
1590            8.0_f32.sqrt(),
1591            13.0_f32.sqrt(),
1592        ];
1593        for (&actual, expected) in distances.as_slice().iter().zip(expected) {
1594            assert!((actual - expected).abs() <= 1e-6, "{actual} != {expected}");
1595        }
1596    }
1597
1598    #[test]
1599    fn distance_transform_respects_anisotropic_spacing() {
1600        let mask = BinaryMask::try_new(3, 2, vec![0, 1, 1, 1, 1, 1]).unwrap();
1601        let distances = distance_transform_edt_with_spacing(&mask, 2.0, 3.0).unwrap();
1602        let expected = [0.0, 2.0, 4.0, 3.0, 13.0_f32.sqrt(), 5.0];
1603        for (&actual, expected) in distances.as_slice().iter().zip(expected) {
1604            assert!((actual - expected).abs() <= 1e-6, "{actual} != {expected}");
1605        }
1606    }
1607
1608    #[test]
1609    fn unit_distance_transform_matches_brute_force_on_irregular_mask() {
1610        let (width, height) = (31, 23);
1611        let data = (0..width * height)
1612            .map(|index| u8::from(index % 37 != 0 && index % 101 != 7))
1613            .collect::<Vec<_>>();
1614        let background = data
1615            .iter()
1616            .enumerate()
1617            .filter(|(_, value)| **value == 0)
1618            .map(|(index, _)| (index % width, index / width))
1619            .collect::<Vec<_>>();
1620        let mask = BinaryMask::try_new(width, height, data).unwrap();
1621        let actual = distance_transform_edt(&mask).unwrap();
1622        for y in 0..height {
1623            for x in 0..width {
1624                let expected = background
1625                    .iter()
1626                    .map(|&(bx, by)| (x.abs_diff(bx) as f32).hypot(y.abs_diff(by) as f32))
1627                    .fold(f32::INFINITY, f32::min);
1628                assert_eq!(actual[(x, y)][0], expected);
1629            }
1630        }
1631    }
1632
1633    #[test]
1634    fn reusable_distance_transform_accepts_packed_255_masks() {
1635        let input = [0_u8, 255, 255, 255, 255, 255];
1636        let mut output = [0.0_f32; 6];
1637        let mut workspace = DistanceTransformWorkspace::new();
1638        distance_transform_edt_u8_into(&input, 3, 2, &mut output, &mut workspace).unwrap();
1639        assert_eq!(output, [0.0, 1.0, 2.0, 1.0, 2.0_f32.sqrt(), 5.0_f32.sqrt()]);
1640        let capacity = workspace.capacity();
1641        distance_transform_edt_u8_into(&input, 3, 2, &mut output, &mut workspace).unwrap();
1642        assert_eq!(workspace.capacity(), capacity);
1643        assert!(distance_transform_edt_u8_into(&input, 3, 2, &mut output[..5], &mut workspace,)
1644            .is_err());
1645    }
1646
1647    #[test]
1648    fn distance_transform_defines_empty_and_rejects_missing_background() {
1649        let empty = BinaryMask::try_new(0, 0, Vec::new()).unwrap();
1650        assert!(distance_transform_edt(&empty).unwrap().as_slice().is_empty());
1651
1652        let foreground = BinaryMask::try_new(2, 2, vec![1; 4]).unwrap();
1653        assert!(distance_transform_edt(&foreground).is_err());
1654        let background = BinaryMask::try_new(2, 2, vec![0; 4]).unwrap();
1655        assert_eq!(distance_transform_edt(&background).unwrap().as_slice(), &[0.0; 4]);
1656        assert!(distance_transform_edt_with_spacing(&background, 0.0, 1.0).is_err());
1657        assert!(distance_transform_edt_with_spacing(&background, 1.0, f32::NAN).is_err());
1658    }
1659
1660    #[test]
1661    fn depth_flow_and_point_maps_expose_validity() {
1662        let depth = DepthMap::try_new(2, 1, vec![1.0, f32::NAN]).unwrap();
1663        assert_eq!(depth.valid_mask(0.1, 10.0).unwrap().image().as_slice(), &[1, 0]);
1664        let flow = FlowField::try_new(2, 1, vec![1.0, 2.0, -1.0, 0.0]).unwrap();
1665        let (mx, my) = flow.to_remap().unwrap();
1666        assert_eq!(mx.as_slice(), &[1.0, 0.0]);
1667        assert_eq!(my.as_slice(), &[2.0, 0.0]);
1668        let points = PointMap::try_new(2, 1, vec![0.0, 0.0, 1.0, f32::NAN, 0.0, 1.0]).unwrap();
1669        assert_eq!(points.valid_mask().unwrap().image().as_slice(), &[1, 0]);
1670    }
1671}