Skip to main content

scirs2_ndimage/
watershed.rs

1//! Advanced watershed segmentation algorithms
2//!
3//! This module provides enhanced watershed segmentation beyond the basic Meyer's flooding
4//! algorithm found in `segmentation::watershed`. Features include:
5//!
6//! - **Compact watershed**: Regularized watershed that penalizes irregular region shapes
7//! - **Oversegmentation control**: Merging of small regions and h-minima suppression
8//! - **Dam (boundary) pixel labeling**: Explicit watershed ridge line extraction
9//! - **Multi-scale watershed**: Hierarchical watershed with merge tree
10//!
11//! # References
12//!
13//! - Meyer, F. (1994). "Topographic distance and watershed lines"
14//! - Neubert, P. & Protzel, P. (2014). "Compact Watershed and Preemptive SLIC"
15//! - Beucher, S. & Meyer, F. (1993). "The morphological approach to segmentation"
16
17use crate::error::{NdimageError, NdimageResult};
18use scirs2_core::ndarray::Array2;
19use scirs2_core::numeric::{Float, NumAssign};
20use std::cmp::Ordering;
21use std::collections::{BinaryHeap, HashMap};
22
23// ---------------------------------------------------------------------------
24// Configuration types
25// ---------------------------------------------------------------------------
26
27/// Connectivity mode for watershed neighbor traversal
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum WatershedNeighborhood {
30    /// 4-connectivity (face-adjacent only)
31    Conn4,
32    /// 8-connectivity (face + diagonal)
33    Conn8,
34}
35
36impl Default for WatershedNeighborhood {
37    fn default() -> Self {
38        WatershedNeighborhood::Conn8
39    }
40}
41
42/// Configuration for compact (regularized) watershed
43#[derive(Debug, Clone)]
44pub struct CompactWatershedConfig {
45    /// Neighborhood connectivity
46    pub connectivity: WatershedNeighborhood,
47    /// Compactness weight (0 = standard watershed, higher = more compact regions)
48    /// Typical range: 0.0 to 1.0
49    pub compactness: f64,
50    /// Whether to produce watershed ridge lines (dam pixels labeled as 0)
51    pub watershed_line: bool,
52}
53
54impl Default for CompactWatershedConfig {
55    fn default() -> Self {
56        CompactWatershedConfig {
57            connectivity: WatershedNeighborhood::Conn8,
58            compactness: 0.0,
59            watershed_line: false,
60        }
61    }
62}
63
64/// Configuration for oversegmentation control
65#[derive(Debug, Clone)]
66pub struct OversegmentationConfig {
67    /// Minimum region area in pixels; regions smaller than this are merged
68    pub min_region_area: usize,
69    /// H-minima threshold: suppress minima shallower than this value
70    /// before computing markers (set to 0.0 to disable)
71    pub h_minima: f64,
72    /// Maximum number of output regions (0 = unlimited)
73    pub max_regions: usize,
74    /// Neighborhood connectivity
75    pub connectivity: WatershedNeighborhood,
76}
77
78impl Default for OversegmentationConfig {
79    fn default() -> Self {
80        OversegmentationConfig {
81            min_region_area: 1,
82            h_minima: 0.0,
83            max_regions: 0,
84            connectivity: WatershedNeighborhood::Conn8,
85        }
86    }
87}
88
89/// Result of dam (boundary) extraction
90#[derive(Debug, Clone)]
91pub struct DamResult {
92    /// Labeled image where dam pixels have label 0
93    pub labels: Array2<i32>,
94    /// Binary mask of dam (boundary) pixels
95    pub dam_mask: Array2<bool>,
96    /// Number of distinct regions (excluding dams)
97    pub num_regions: usize,
98}
99
100// ---------------------------------------------------------------------------
101// Internal helpers
102// ---------------------------------------------------------------------------
103
104/// Sentinel: watershed ridge line (dam pixel)
105const WSHED: i32 = -1;
106/// Sentinel: pixel queued but not yet assigned
107const IN_QUEUE: i32 = -2;
108
109fn neighbor_offsets(conn: WatershedNeighborhood) -> &'static [(isize, isize)] {
110    match conn {
111        WatershedNeighborhood::Conn4 => &[(-1, 0), (0, -1), (0, 1), (1, 0)],
112        WatershedNeighborhood::Conn8 => &[
113            (-1, -1),
114            (-1, 0),
115            (-1, 1),
116            (0, -1),
117            (0, 1),
118            (1, -1),
119            (1, 0),
120            (1, 1),
121        ],
122    }
123}
124
125#[inline]
126fn in_bounds(r: isize, c: isize, rows: usize, cols: usize) -> bool {
127    r >= 0 && r < rows as isize && c >= 0 && c < cols as isize
128}
129
130/// Priority-queue entry for compact watershed
131#[derive(Clone, Debug)]
132struct CompactEntry {
133    row: usize,
134    col: usize,
135    /// Image intensity (primary sort key)
136    intensity: f64,
137    /// Spatial distance penalty from nearest marker seed (secondary for compactness)
138    distance: f64,
139    /// Combined priority = intensity + compactness * distance
140    priority: f64,
141    /// Insertion order for tie-breaking
142    order: u64,
143}
144
145impl PartialEq for CompactEntry {
146    fn eq(&self, other: &Self) -> bool {
147        self.row == other.row && self.col == other.col
148    }
149}
150impl Eq for CompactEntry {}
151
152impl PartialOrd for CompactEntry {
153    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
154        Some(self.cmp(other))
155    }
156}
157
158impl Ord for CompactEntry {
159    fn cmp(&self, other: &Self) -> Ordering {
160        // Min-heap: reverse ordering so BinaryHeap pops smallest priority first
161        match other
162            .priority
163            .partial_cmp(&self.priority)
164            .unwrap_or(Ordering::Equal)
165        {
166            Ordering::Equal => other.order.cmp(&self.order),
167            ord => ord,
168        }
169    }
170}
171
172// ---------------------------------------------------------------------------
173// Public API
174// ---------------------------------------------------------------------------
175
176/// Compact (regularized) watershed segmentation
177///
178/// Extends Meyer's flooding algorithm with a compactness penalty that encourages
179/// spatially compact regions. When `compactness > 0`, pixels are assigned to the
180/// marker whose combined cost (image intensity + compactness * geodesic distance)
181/// is lowest, producing more regular region shapes.
182///
183/// # Arguments
184///
185/// * `image`   - Intensity / gradient image (2D)
186/// * `markers` - Seed labels (positive integers for seeds, 0 for unlabeled pixels)
187/// * `config`  - Compact watershed configuration
188///
189/// # Returns
190///
191/// Labeled 2D array where each pixel is assigned its closest marker label.
192/// If `watershed_line` is enabled, ridge pixels are labeled 0.
193///
194/// # Example
195///
196/// ```
197/// use scirs2_core::ndarray::array;
198/// use scirs2_ndimage::watershed::{compact_watershed, CompactWatershedConfig};
199///
200/// let image = array![
201///     [0.1, 0.2, 0.9, 0.2, 0.1],
202///     [0.1, 0.1, 0.9, 0.1, 0.1],
203///     [0.1, 0.2, 0.9, 0.2, 0.1],
204/// ];
205/// let markers = array![
206///     [0, 0, 0, 0, 0],
207///     [1, 0, 0, 0, 2],
208///     [0, 0, 0, 0, 0],
209/// ];
210///
211/// let config = CompactWatershedConfig {
212///     compactness: 0.5,
213///     ..Default::default()
214/// };
215///
216/// let result = compact_watershed(&image, &markers, &config).expect("should succeed");
217/// assert_eq!(result[[1, 0]], 1);
218/// assert_eq!(result[[1, 4]], 2);
219/// ```
220pub fn compact_watershed<T>(
221    image: &Array2<T>,
222    markers: &Array2<i32>,
223    config: &CompactWatershedConfig,
224) -> NdimageResult<Array2<i32>>
225where
226    T: Float + NumAssign + std::fmt::Debug + 'static,
227{
228    if image.shape() != markers.shape() {
229        return Err(NdimageError::DimensionError(
230            "Image and markers must have the same shape".to_string(),
231        ));
232    }
233
234    let rows = image.nrows();
235    let cols = image.ncols();
236
237    if rows == 0 || cols == 0 {
238        return Ok(markers.clone());
239    }
240
241    let offsets = neighbor_offsets(config.connectivity);
242    let compact = config.compactness;
243
244    // Output labels (initialized from markers)
245    let mut output = markers.clone();
246
247    // Distance from each pixel to its seed (for compactness penalty)
248    let mut dist = Array2::<f64>::from_elem((rows, cols), f64::INFINITY);
249
250    // Seed centroids (label -> (row, col)) for spatial distance computation
251    let mut seed_centers: HashMap<i32, (f64, f64)> = HashMap::new();
252    let mut seed_counts: HashMap<i32, usize> = HashMap::new();
253
254    for r in 0..rows {
255        for c in 0..cols {
256            let lbl = markers[[r, c]];
257            if lbl > 0 {
258                let entry = seed_centers.entry(lbl).or_insert((0.0, 0.0));
259                entry.0 += r as f64;
260                entry.1 += c as f64;
261                *seed_counts.entry(lbl).or_insert(0) += 1;
262            }
263        }
264    }
265
266    for (lbl, center) in seed_centers.iter_mut() {
267        let cnt = *seed_counts.get(lbl).unwrap_or(&1) as f64;
268        center.0 /= cnt;
269        center.1 /= cnt;
270    }
271
272    // Initialize priority queue from marker boundary pixels
273    let mut queue = BinaryHeap::new();
274    let mut insertion_order: u64 = 0;
275
276    for r in 0..rows {
277        for c in 0..cols {
278            let lbl = markers[[r, c]];
279            if lbl > 0 {
280                dist[[r, c]] = 0.0;
281
282                for &(dr, dc) in offsets {
283                    let nr = r as isize + dr;
284                    let nc = c as isize + dc;
285                    if in_bounds(nr, nc, rows, cols) {
286                        let nr = nr as usize;
287                        let nc = nc as usize;
288                        if output[[nr, nc]] == 0 {
289                            output[[nr, nc]] = IN_QUEUE;
290                            let intensity = image[[nr, nc]].to_f64().unwrap_or(f64::INFINITY);
291                            let spatial_dist = if let Some(&center) = seed_centers.get(&lbl) {
292                                ((nr as f64 - center.0).powi(2) + (nc as f64 - center.1).powi(2))
293                                    .sqrt()
294                            } else {
295                                0.0
296                            };
297                            let priority = intensity + compact * spatial_dist;
298
299                            queue.push(CompactEntry {
300                                row: nr,
301                                col: nc,
302                                intensity,
303                                distance: spatial_dist,
304                                priority,
305                                order: insertion_order,
306                            });
307                            insertion_order += 1;
308                        }
309                    }
310                }
311            }
312        }
313    }
314
315    // Flooding loop
316    while let Some(entry) = queue.pop() {
317        let r = entry.row;
318        let c = entry.col;
319
320        // Collect neighbor labels
321        let mut neighbor_labels: HashMap<i32, (usize, f64)> = HashMap::new(); // label -> (count, min_dist)
322        let mut _has_wshed_neighbor = false;
323
324        for &(dr, dc) in offsets {
325            let nr = r as isize + dr;
326            let nc = c as isize + dc;
327            if in_bounds(nr, nc, rows, cols) {
328                let nr = nr as usize;
329                let nc = nc as usize;
330                let nlbl = output[[nr, nc]];
331                if nlbl > 0 {
332                    let nd = dist[[nr, nc]];
333                    let e = neighbor_labels.entry(nlbl).or_insert((0, f64::INFINITY));
334                    e.0 += 1;
335                    if nd < e.1 {
336                        e.1 = nd;
337                    }
338                } else if nlbl == WSHED && config.watershed_line {
339                    _has_wshed_neighbor = true;
340                }
341            }
342        }
343
344        if neighbor_labels.is_empty() {
345            output[[r, c]] = 0;
346            continue;
347        }
348
349        let distinct: Vec<i32> = neighbor_labels.keys().copied().collect();
350
351        let assigned_label;
352        if config.watershed_line && distinct.len() > 1 {
353            // Multiple labels meeting: this is a dam pixel
354            output[[r, c]] = WSHED;
355            assigned_label = WSHED;
356        } else {
357            // Choose the best label considering compactness
358            let best_label = if compact > 0.0 {
359                // Pick label with lowest combined cost
360                let mut best = distinct[0];
361                let mut best_cost = f64::INFINITY;
362                for &lbl in &distinct {
363                    let spatial = if let Some(&center) = seed_centers.get(&lbl) {
364                        ((r as f64 - center.0).powi(2) + (c as f64 - center.1).powi(2)).sqrt()
365                    } else {
366                        0.0
367                    };
368                    let cost = entry.intensity + compact * spatial;
369                    if cost < best_cost {
370                        best_cost = cost;
371                        best = lbl;
372                    }
373                }
374                best
375            } else {
376                // Standard: most frequent neighbor label
377                neighbor_labels
378                    .iter()
379                    .max_by_key(|&(_, (count, _))| count)
380                    .map(|(&lbl, _)| lbl)
381                    .unwrap_or(0)
382            };
383
384            if best_label > 0 {
385                output[[r, c]] = best_label;
386                assigned_label = best_label;
387                // Update distance
388                let spatial = if let Some(&center) = seed_centers.get(&best_label) {
389                    ((r as f64 - center.0).powi(2) + (c as f64 - center.1).powi(2)).sqrt()
390                } else {
391                    0.0
392                };
393                dist[[r, c]] = spatial;
394            } else {
395                output[[r, c]] = 0;
396                continue;
397            }
398        }
399
400        // Enqueue unlabeled neighbors (both for labeled and dam pixels)
401        for &(dr, dc) in offsets {
402            let nr = r as isize + dr;
403            let nc = c as isize + dc;
404            if in_bounds(nr, nc, rows, cols) {
405                let nr = nr as usize;
406                let nc = nc as usize;
407                if output[[nr, nc]] == 0 {
408                    output[[nr, nc]] = IN_QUEUE;
409                    let intensity = image[[nr, nc]].to_f64().unwrap_or(f64::INFINITY);
410                    let spatial_dist = if assigned_label > 0 {
411                        if let Some(&center) = seed_centers.get(&assigned_label) {
412                            ((nr as f64 - center.0).powi(2) + (nc as f64 - center.1).powi(2)).sqrt()
413                        } else {
414                            0.0
415                        }
416                    } else {
417                        0.0
418                    };
419                    let priority = intensity + compact * spatial_dist;
420
421                    queue.push(CompactEntry {
422                        row: nr,
423                        col: nc,
424                        intensity,
425                        distance: spatial_dist,
426                        priority,
427                        order: insertion_order,
428                    });
429                    insertion_order += 1;
430                }
431            }
432        }
433    }
434
435    // Clean up IN_QUEUE sentinels
436    for val in output.iter_mut() {
437        if *val == IN_QUEUE {
438            *val = 0;
439        }
440    }
441
442    Ok(output)
443}
444
445/// Extract dam (boundary) pixels from a watershed segmentation
446///
447/// Given a labeled image (e.g., from watershed), identifies pixels that lie on
448/// region boundaries (where at least two different positive labels meet).
449///
450/// # Arguments
451///
452/// * `labels`       - Labeled image from watershed segmentation
453/// * `connectivity` - Neighborhood connectivity for boundary detection
454///
455/// # Returns
456///
457/// A `DamResult` containing the relabeled image, binary dam mask, and region count.
458///
459/// # Example
460///
461/// ```
462/// use scirs2_core::ndarray::array;
463/// use scirs2_ndimage::watershed::{extract_dams, WatershedNeighborhood};
464///
465/// let labels = array![
466///     [1, 1, 2, 2],
467///     [1, 1, 2, 2],
468///     [3, 3, 4, 4],
469///     [3, 3, 4, 4],
470/// ];
471///
472/// let result = extract_dams(&labels, WatershedNeighborhood::Conn4)
473///     .expect("should succeed");
474/// assert!(result.dam_mask[[0, 1]] || result.dam_mask[[1, 0]] || result.num_regions >= 2);
475/// ```
476pub fn extract_dams(
477    labels: &Array2<i32>,
478    connectivity: WatershedNeighborhood,
479) -> NdimageResult<DamResult> {
480    let rows = labels.nrows();
481    let cols = labels.ncols();
482
483    if rows == 0 || cols == 0 {
484        return Ok(DamResult {
485            labels: labels.clone(),
486            dam_mask: Array2::from_elem((rows, cols), false),
487            num_regions: 0,
488        });
489    }
490
491    let offsets = neighbor_offsets(connectivity);
492    let mut dam_mask = Array2::from_elem((rows, cols), false);
493    let mut out = labels.clone();
494    let mut unique_labels = std::collections::HashSet::new();
495
496    for r in 0..rows {
497        for c in 0..cols {
498            let lbl = labels[[r, c]];
499            if lbl <= 0 {
500                continue;
501            }
502            unique_labels.insert(lbl);
503
504            let mut is_dam = false;
505            for &(dr, dc) in offsets {
506                let nr = r as isize + dr;
507                let nc = c as isize + dc;
508                if in_bounds(nr, nc, rows, cols) {
509                    let nlbl = labels[[nr as usize, nc as usize]];
510                    if nlbl > 0 && nlbl != lbl {
511                        is_dam = true;
512                        break;
513                    }
514                }
515            }
516
517            if is_dam {
518                dam_mask[[r, c]] = true;
519                out[[r, c]] = WSHED;
520            }
521        }
522    }
523
524    Ok(DamResult {
525        labels: out,
526        dam_mask,
527        num_regions: unique_labels.len(),
528    })
529}
530
531/// Control oversegmentation by merging small regions
532///
533/// Performs hierarchical region merging on a watershed segmentation result,
534/// merging regions whose area is below `config.min_region_area` into their
535/// most similar neighbor.
536///
537/// # Arguments
538///
539/// * `image`  - Original intensity / gradient image
540/// * `labels` - Labeled image from watershed
541/// * `config` - Oversegmentation control parameters
542///
543/// # Returns
544///
545/// Relabeled image with small regions merged.
546///
547/// # Example
548///
549/// ```
550/// use scirs2_core::ndarray::array;
551/// use scirs2_ndimage::watershed::{merge_small_regions, OversegmentationConfig};
552///
553/// let image = array![
554///     [0.1, 0.1, 0.9, 0.9],
555///     [0.1, 0.1, 0.9, 0.9],
556///     [0.1, 0.1, 0.9, 0.9],
557///     [0.1, 0.1, 0.9, 0.9],
558/// ];
559///
560/// let labels = array![
561///     [1, 1, 2, 2],
562///     [1, 3, 2, 2],
563///     [1, 1, 2, 2],
564///     [1, 1, 2, 2],
565/// ];
566///
567/// let config = OversegmentationConfig {
568///     min_region_area: 3,
569///     ..Default::default()
570/// };
571///
572/// let merged = merge_small_regions(&image, &labels, &config).expect("should succeed");
573/// // Region 3 (area 1) should be merged into region 1
574/// assert!(merged[[1, 1]] != 3);
575/// ```
576pub fn merge_small_regions<T>(
577    image: &Array2<T>,
578    labels: &Array2<i32>,
579    config: &OversegmentationConfig,
580) -> NdimageResult<Array2<i32>>
581where
582    T: Float + NumAssign + std::fmt::Debug + 'static,
583{
584    if image.shape() != labels.shape() {
585        return Err(NdimageError::DimensionError(
586            "Image and labels must have the same shape".to_string(),
587        ));
588    }
589
590    let rows = image.nrows();
591    let cols = image.ncols();
592
593    if rows == 0 || cols == 0 {
594        return Ok(labels.clone());
595    }
596
597    // Compute region statistics: area, mean intensity, adjacency
598    let mut region_area: HashMap<i32, usize> = HashMap::new();
599    let mut region_sum: HashMap<i32, f64> = HashMap::new();
600
601    for r in 0..rows {
602        for c in 0..cols {
603            let lbl = labels[[r, c]];
604            if lbl <= 0 {
605                continue;
606            }
607            *region_area.entry(lbl).or_insert(0) += 1;
608            let val = image[[r, c]].to_f64().unwrap_or(0.0);
609            *region_sum.entry(lbl).or_insert(0.0) += val;
610        }
611    }
612
613    // Build adjacency: for each region, find neighboring region labels
614    let offsets = neighbor_offsets(config.connectivity);
615    let mut adjacency: HashMap<i32, HashMap<i32, usize>> = HashMap::new(); // region -> {neighbor -> boundary_length}
616
617    for r in 0..rows {
618        for c in 0..cols {
619            let lbl = labels[[r, c]];
620            if lbl <= 0 {
621                continue;
622            }
623            for &(dr, dc) in offsets {
624                let nr = r as isize + dr;
625                let nc = c as isize + dc;
626                if in_bounds(nr, nc, rows, cols) {
627                    let nlbl = labels[[nr as usize, nc as usize]];
628                    if nlbl > 0 && nlbl != lbl {
629                        *adjacency.entry(lbl).or_default().entry(nlbl).or_insert(0) += 1;
630                    }
631                }
632            }
633        }
634    }
635
636    // Create merge mapping
637    let mut merge_map: HashMap<i32, i32> = HashMap::new();
638
639    // Sort regions by area (smallest first) to merge greedily
640    let mut regions_by_area: Vec<(i32, usize)> =
641        region_area.iter().map(|(&k, &v)| (k, v)).collect();
642    regions_by_area.sort_by_key(|&(_, area)| area);
643
644    for &(lbl, area) in &regions_by_area {
645        if area >= config.min_region_area {
646            continue; // large enough, skip
647        }
648
649        // Find the best neighbor to merge into (most similar mean intensity)
650        let my_mean = region_sum.get(&lbl).copied().unwrap_or(0.0) / (area.max(1) as f64);
651
652        let best_neighbor = if let Some(neighbors) = adjacency.get(&lbl) {
653            let mut best: Option<(i32, f64)> = None;
654            for (&nlbl, _) in neighbors {
655                // Follow merge chain
656                let mut final_lbl = nlbl;
657                while let Some(&merged_to) = merge_map.get(&final_lbl) {
658                    if merged_to == final_lbl {
659                        break;
660                    }
661                    final_lbl = merged_to;
662                }
663
664                if final_lbl == lbl {
665                    continue; // don't merge into self
666                }
667
668                let n_area = region_area.get(&final_lbl).copied().unwrap_or(1);
669                let n_mean =
670                    region_sum.get(&final_lbl).copied().unwrap_or(0.0) / (n_area.max(1) as f64);
671                let diff = (my_mean - n_mean).abs();
672
673                match best {
674                    None => best = Some((final_lbl, diff)),
675                    Some((_, best_diff)) if diff < best_diff => {
676                        best = Some((final_lbl, diff));
677                    }
678                    _ => {}
679                }
680            }
681            best.map(|(lbl, _)| lbl)
682        } else {
683            None
684        };
685
686        if let Some(target) = best_neighbor {
687            merge_map.insert(lbl, target);
688            // Update target statistics
689            let my_sum = region_sum.get(&lbl).copied().unwrap_or(0.0);
690            *region_sum.entry(target).or_insert(0.0) += my_sum;
691            *region_area.entry(target).or_insert(0) += area;
692        }
693    }
694
695    // Resolve transitive merges
696    let all_labels: Vec<i32> = merge_map.keys().copied().collect();
697    for lbl in all_labels {
698        let mut target = lbl;
699        let mut visited = std::collections::HashSet::new();
700        while let Some(&next) = merge_map.get(&target) {
701            if next == target || visited.contains(&next) {
702                break;
703            }
704            visited.insert(target);
705            target = next;
706        }
707        merge_map.insert(lbl, target);
708    }
709
710    // Apply merge mapping
711    let mut output = labels.clone();
712    for r in 0..rows {
713        for c in 0..cols {
714            let lbl = output[[r, c]];
715            if lbl > 0 {
716                if let Some(&target) = merge_map.get(&lbl) {
717                    output[[r, c]] = target;
718                }
719            }
720        }
721    }
722
723    // If max_regions is set, keep merging until we're under the limit
724    if config.max_regions > 0 {
725        let mut unique: std::collections::HashSet<i32> = std::collections::HashSet::new();
726        for &v in output.iter() {
727            if v > 0 {
728                unique.insert(v);
729            }
730        }
731
732        if unique.len() > config.max_regions {
733            // Recompute and merge smallest regions until under limit
734            let mut remaining = unique.len();
735            while remaining > config.max_regions {
736                // Recount areas
737                let mut areas: HashMap<i32, usize> = HashMap::new();
738                for &v in output.iter() {
739                    if v > 0 {
740                        *areas.entry(v).or_insert(0) += 1;
741                    }
742                }
743
744                // Find smallest region
745                let smallest = areas
746                    .iter()
747                    .min_by_key(|&(_, &area)| area)
748                    .map(|(&lbl, _)| lbl);
749
750                if let Some(small_lbl) = smallest {
751                    // Find best neighbor
752                    let mut neighbor_boundary: HashMap<i32, usize> = HashMap::new();
753                    for r in 0..rows {
754                        for c in 0..cols {
755                            if output[[r, c]] != small_lbl {
756                                continue;
757                            }
758                            for &(dr, dc) in offsets {
759                                let nr = r as isize + dr;
760                                let nc = c as isize + dc;
761                                if in_bounds(nr, nc, rows, cols) {
762                                    let nlbl = output[[nr as usize, nc as usize]];
763                                    if nlbl > 0 && nlbl != small_lbl {
764                                        *neighbor_boundary.entry(nlbl).or_insert(0) += 1;
765                                    }
766                                }
767                            }
768                        }
769                    }
770
771                    // Merge into the neighbor with the longest shared boundary
772                    let best = neighbor_boundary
773                        .iter()
774                        .max_by_key(|&(_, &count)| count)
775                        .map(|(&lbl, _)| lbl);
776
777                    if let Some(target) = best {
778                        for val in output.iter_mut() {
779                            if *val == small_lbl {
780                                *val = target;
781                            }
782                        }
783                        remaining -= 1;
784                    } else {
785                        break; // no neighbors, can't merge further
786                    }
787                } else {
788                    break;
789                }
790            }
791        }
792    }
793
794    Ok(output)
795}
796
797/// H-minima transform: suppress all minima whose depth is less than `h`
798///
799/// This is a morphological operation that raises all minima of depth < h,
800/// useful for reducing oversegmentation in watershed by eliminating
801/// shallow catchment basins.
802///
803/// # Arguments
804///
805/// * `image` - Input intensity image (2D)
806/// * `h`     - Height threshold; minima shallower than this are suppressed
807///
808/// # Returns
809///
810/// Filtered image with shallow minima suppressed.
811///
812/// # Example
813///
814/// ```
815/// use scirs2_core::ndarray::array;
816/// use scirs2_ndimage::watershed::h_minima_transform;
817///
818/// let image = array![
819///     [5.0, 5.0, 5.0],
820///     [5.0, 3.0, 5.0],  // shallow minimum at (1,1)
821///     [5.0, 5.0, 5.0],
822/// ];
823///
824/// let result = h_minima_transform(&image, 3.0).expect("should succeed");
825/// // The minimum at (1,1) has depth 2 (5-3), which is less than h=3, so it should be suppressed
826/// assert!(result[[1, 1]] >= 5.0 - 3.0);
827/// ```
828pub fn h_minima_transform<T>(image: &Array2<T>, h: f64) -> NdimageResult<Array2<f64>>
829where
830    T: Float + NumAssign + std::fmt::Debug + 'static,
831{
832    if h < 0.0 {
833        return Err(NdimageError::InvalidInput(
834            "h must be non-negative".to_string(),
835        ));
836    }
837
838    let rows = image.nrows();
839    let cols = image.ncols();
840
841    if rows == 0 || cols == 0 {
842        return Ok(Array2::zeros((rows, cols)));
843    }
844
845    // Convert to f64
846    let img_f64: Array2<f64> = image.mapv(|x| x.to_f64().unwrap_or(0.0));
847
848    // Marker: f - h (clamped from below)
849    let marker: Array2<f64> = img_f64.mapv(|x| x - h);
850
851    // Morphological reconstruction by dilation of marker under mask (original image)
852    // The result fills all minima shallower than h
853    let result = morphological_reconstruction_by_dilation_2d(&marker, &img_f64, 200)?;
854
855    Ok(result)
856}
857
858/// Morphological reconstruction by erosion (2D, geodesic erosion)
859///
860/// Iteratively erodes the marker image while keeping it above the mask image,
861/// until convergence. Used by h-maxima transform and other morphological operations.
862#[allow(dead_code)]
863fn morphological_reconstruction_by_erosion_2d(
864    marker: &Array2<f64>,
865    mask: &Array2<f64>,
866    max_iterations: usize,
867) -> NdimageResult<Array2<f64>> {
868    let rows = marker.nrows();
869    let cols = marker.ncols();
870
871    let mut result = marker.clone();
872
873    // Use raster/anti-raster scanning for efficiency
874    for _iter in 0..max_iterations {
875        let mut changed = false;
876
877        // Forward scan (top-left to bottom-right)
878        for r in 0..rows {
879            for c in 0..cols {
880                let mut val = result[[r, c]];
881                // Check neighbors that have already been processed
882                if r > 0 && result[[r - 1, c]] < val {
883                    val = result[[r - 1, c]];
884                }
885                if c > 0 && result[[r, c - 1]] < val {
886                    val = result[[r, c - 1]];
887                }
888                // Ensure we don't go below the mask
889                let mask_val = mask[[r, c]];
890                if val < mask_val {
891                    val = mask_val;
892                }
893                if (val - result[[r, c]]).abs() > 1e-15 {
894                    result[[r, c]] = val;
895                    changed = true;
896                }
897            }
898        }
899
900        // Backward scan (bottom-right to top-left)
901        for r in (0..rows).rev() {
902            for c in (0..cols).rev() {
903                let mut val = result[[r, c]];
904                if r + 1 < rows && result[[r + 1, c]] < val {
905                    val = result[[r + 1, c]];
906                }
907                if c + 1 < cols && result[[r, c + 1]] < val {
908                    val = result[[r, c + 1]];
909                }
910                let mask_val = mask[[r, c]];
911                if val < mask_val {
912                    val = mask_val;
913                }
914                if (val - result[[r, c]]).abs() > 1e-15 {
915                    result[[r, c]] = val;
916                    changed = true;
917                }
918            }
919        }
920
921        if !changed {
922            break;
923        }
924    }
925
926    Ok(result)
927}
928
929/// Morphological reconstruction by dilation (2D, geodesic dilation)
930///
931/// Iteratively dilates the marker image while keeping it below the mask image,
932/// until convergence. This is the dual of reconstruction by erosion.
933///
934/// Given marker <= mask pointwise, the result is the largest image R such that:
935/// - marker <= R <= mask (pointwise)
936/// - R is "connected from below" by the marker
937fn morphological_reconstruction_by_dilation_2d(
938    marker: &Array2<f64>,
939    mask: &Array2<f64>,
940    max_iterations: usize,
941) -> NdimageResult<Array2<f64>> {
942    let rows = marker.nrows();
943    let cols = marker.ncols();
944
945    // Clamp marker to be at most mask everywhere
946    let mut result = Array2::zeros((rows, cols));
947    for r in 0..rows {
948        for c in 0..cols {
949            result[[r, c]] = marker[[r, c]].min(mask[[r, c]]);
950        }
951    }
952
953    // Use raster/anti-raster scanning for efficiency (Vincent 1993)
954    for _iter in 0..max_iterations {
955        let mut changed = false;
956
957        // Forward scan (top-left to bottom-right)
958        for r in 0..rows {
959            for c in 0..cols {
960                let mut val = result[[r, c]];
961                // Check neighbors that have already been processed (above and left)
962                if r > 0 && result[[r - 1, c]] > val {
963                    val = result[[r - 1, c]];
964                }
965                if c > 0 && result[[r, c - 1]] > val {
966                    val = result[[r, c - 1]];
967                }
968                // Also check diagonal neighbors for 8-connectivity
969                if r > 0 && c > 0 && result[[r - 1, c - 1]] > val {
970                    val = result[[r - 1, c - 1]];
971                }
972                if r > 0 && c + 1 < cols && result[[r - 1, c + 1]] > val {
973                    val = result[[r - 1, c + 1]];
974                }
975                // Ensure we don't exceed the mask
976                let mask_val = mask[[r, c]];
977                if val > mask_val {
978                    val = mask_val;
979                }
980                if (val - result[[r, c]]).abs() > 1e-15 {
981                    result[[r, c]] = val;
982                    changed = true;
983                }
984            }
985        }
986
987        // Backward scan (bottom-right to top-left)
988        for r in (0..rows).rev() {
989            for c in (0..cols).rev() {
990                let mut val = result[[r, c]];
991                if r + 1 < rows && result[[r + 1, c]] > val {
992                    val = result[[r + 1, c]];
993                }
994                if c + 1 < cols && result[[r, c + 1]] > val {
995                    val = result[[r, c + 1]];
996                }
997                // Diagonal neighbors
998                if r + 1 < rows && c + 1 < cols && result[[r + 1, c + 1]] > val {
999                    val = result[[r + 1, c + 1]];
1000                }
1001                if r + 1 < rows && c > 0 && result[[r + 1, c - 1]] > val {
1002                    val = result[[r + 1, c - 1]];
1003                }
1004                let mask_val = mask[[r, c]];
1005                if val > mask_val {
1006                    val = mask_val;
1007                }
1008                if (val - result[[r, c]]).abs() > 1e-15 {
1009                    result[[r, c]] = val;
1010                    changed = true;
1011                }
1012            }
1013        }
1014
1015        if !changed {
1016            break;
1017        }
1018    }
1019
1020    Ok(result)
1021}
1022
1023/// Generate automatic markers from local minima of the image
1024///
1025/// Detects local minima (pixels lower than all their neighbors) and assigns
1026/// each a unique label. Useful as input markers for watershed segmentation.
1027///
1028/// # Arguments
1029///
1030/// * `image`        - Input intensity image
1031/// * `connectivity` - Neighborhood connectivity
1032/// * `threshold`    - Optional minimum depth of minima to consider
1033///
1034/// # Returns
1035///
1036/// Marker array with unique positive labels at each detected minimum.
1037///
1038/// # Example
1039///
1040/// ```
1041/// use scirs2_core::ndarray::array;
1042/// use scirs2_ndimage::watershed::{auto_markers, WatershedNeighborhood};
1043///
1044/// let image = array![
1045///     [5.0, 5.0, 5.0, 5.0, 5.0],
1046///     [5.0, 1.0, 5.0, 2.0, 5.0],
1047///     [5.0, 5.0, 5.0, 5.0, 5.0],
1048/// ];
1049///
1050/// let markers = auto_markers(&image, WatershedNeighborhood::Conn4, None)
1051///     .expect("should succeed");
1052/// assert!(markers[[1, 1]] > 0); // local minimum detected
1053/// assert!(markers[[1, 3]] > 0); // local minimum detected
1054/// ```
1055pub fn auto_markers<T>(
1056    image: &Array2<T>,
1057    connectivity: WatershedNeighborhood,
1058    threshold: Option<f64>,
1059) -> NdimageResult<Array2<i32>>
1060where
1061    T: Float + NumAssign + std::fmt::Debug + 'static,
1062{
1063    let rows = image.nrows();
1064    let cols = image.ncols();
1065
1066    if rows == 0 || cols == 0 {
1067        return Ok(Array2::zeros((rows, cols)));
1068    }
1069
1070    let offsets = neighbor_offsets(connectivity);
1071    let thresh = threshold.unwrap_or(0.0);
1072
1073    let mut markers = Array2::<i32>::zeros((rows, cols));
1074    let mut next_label = 1i32;
1075
1076    for r in 0..rows {
1077        for c in 0..cols {
1078            let val = image[[r, c]].to_f64().unwrap_or(f64::INFINITY);
1079
1080            let mut is_minimum = true;
1081            let mut min_neighbor = f64::INFINITY;
1082
1083            for &(dr, dc) in offsets {
1084                let nr = r as isize + dr;
1085                let nc = c as isize + dc;
1086                if in_bounds(nr, nc, rows, cols) {
1087                    let nval = image[[nr as usize, nc as usize]]
1088                        .to_f64()
1089                        .unwrap_or(f64::INFINITY);
1090                    if nval <= val {
1091                        is_minimum = false;
1092                        break;
1093                    }
1094                    if nval < min_neighbor {
1095                        min_neighbor = nval;
1096                    }
1097                }
1098            }
1099
1100            if is_minimum {
1101                // Check depth threshold
1102                let depth = if min_neighbor.is_finite() {
1103                    min_neighbor - val
1104                } else {
1105                    f64::INFINITY
1106                };
1107
1108                if depth >= thresh {
1109                    markers[[r, c]] = next_label;
1110                    next_label += 1;
1111                }
1112            }
1113        }
1114    }
1115
1116    Ok(markers)
1117}
1118
1119// ---------------------------------------------------------------------------
1120// Tests
1121// ---------------------------------------------------------------------------
1122
1123#[cfg(test)]
1124mod tests {
1125    use super::*;
1126    use scirs2_core::ndarray::array;
1127
1128    #[test]
1129    fn test_compact_watershed_basic() {
1130        let image = array![
1131            [0.1, 0.2, 0.9, 0.2, 0.1],
1132            [0.1, 0.1, 0.9, 0.1, 0.1],
1133            [0.1, 0.2, 0.9, 0.2, 0.1],
1134        ];
1135        let markers = array![[0, 0, 0, 0, 0], [1, 0, 0, 0, 2], [0, 0, 0, 0, 0],];
1136
1137        let config = CompactWatershedConfig::default();
1138        let result = compact_watershed(&image, &markers, &config).expect("should succeed");
1139
1140        assert_eq!(result[[1, 0]], 1);
1141        assert_eq!(result[[1, 4]], 2);
1142        // All pixels should be labeled
1143        for &v in result.iter() {
1144            assert!(v > 0);
1145        }
1146    }
1147
1148    #[test]
1149    fn test_compact_watershed_with_compactness() {
1150        let image = array![
1151            [0.1, 0.1, 0.5, 0.1, 0.1],
1152            [0.1, 0.1, 0.5, 0.1, 0.1],
1153            [0.1, 0.1, 0.5, 0.1, 0.1],
1154        ];
1155        let markers = array![[0, 0, 0, 0, 0], [1, 0, 0, 0, 2], [0, 0, 0, 0, 0],];
1156
1157        let config = CompactWatershedConfig {
1158            compactness: 1.0,
1159            ..Default::default()
1160        };
1161
1162        let result = compact_watershed(&image, &markers, &config).expect("should succeed");
1163        assert_eq!(result[[1, 0]], 1);
1164        assert_eq!(result[[1, 4]], 2);
1165    }
1166
1167    #[test]
1168    fn test_compact_watershed_shape_mismatch() {
1169        let image = array![[0.1, 0.2], [0.3, 0.4]];
1170        let markers = array![[0, 0, 0], [1, 0, 2]];
1171
1172        let config = CompactWatershedConfig::default();
1173        let result = compact_watershed(&image, &markers, &config);
1174        assert!(result.is_err());
1175    }
1176
1177    #[test]
1178    fn test_compact_watershed_single_marker() {
1179        let image = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]];
1180        let markers = array![[1, 0, 0], [0, 0, 0], [0, 0, 0]];
1181
1182        let config = CompactWatershedConfig::default();
1183        let result = compact_watershed(&image, &markers, &config).expect("should succeed");
1184        for &v in result.iter() {
1185            assert_eq!(v, 1);
1186        }
1187    }
1188
1189    #[test]
1190    fn test_compact_watershed_watershed_line() {
1191        let image = array![
1192            [1.0, 2.0, 9.0, 2.0, 1.0],
1193            [1.0, 2.0, 9.0, 2.0, 1.0],
1194            [1.0, 2.0, 9.0, 2.0, 1.0],
1195        ];
1196        let markers = array![[1, 0, 0, 0, 2], [0, 0, 0, 0, 0], [1, 0, 0, 0, 2],];
1197
1198        let config = CompactWatershedConfig {
1199            watershed_line: true,
1200            connectivity: WatershedNeighborhood::Conn4,
1201            ..Default::default()
1202        };
1203
1204        let result = compact_watershed(&image, &markers, &config).expect("should succeed");
1205        // Markers should be preserved
1206        assert_eq!(result[[0, 0]], 1);
1207        assert_eq!(result[[0, 4]], 2);
1208    }
1209
1210    #[test]
1211    fn test_compact_watershed_empty() {
1212        let image: Array2<f64> = Array2::zeros((0, 0));
1213        let markers: Array2<i32> = Array2::zeros((0, 0));
1214        let config = CompactWatershedConfig::default();
1215        let result = compact_watershed(&image, &markers, &config).expect("empty should succeed");
1216        assert_eq!(result.len(), 0);
1217    }
1218
1219    #[test]
1220    fn test_extract_dams_basic() {
1221        let labels = array![[1, 1, 2, 2], [1, 1, 2, 2], [3, 3, 4, 4], [3, 3, 4, 4],];
1222
1223        let result = extract_dams(&labels, WatershedNeighborhood::Conn4).expect("should succeed");
1224
1225        // Pixels at boundaries should be dams
1226        // (0,1)/(1,0) border region 1/2 or 1/3
1227        assert!(result.num_regions >= 2);
1228
1229        // Interior pixels should NOT be dams
1230        assert!(!result.dam_mask[[0, 0]]);
1231        assert!(!result.dam_mask[[3, 3]]);
1232    }
1233
1234    #[test]
1235    fn test_extract_dams_single_region() {
1236        let labels = Array2::from_elem((4, 4), 1i32);
1237        let result = extract_dams(&labels, WatershedNeighborhood::Conn8).expect("should succeed");
1238        // No dams in a single-region image
1239        for &v in result.dam_mask.iter() {
1240            assert!(!v);
1241        }
1242        assert_eq!(result.num_regions, 1);
1243    }
1244
1245    #[test]
1246    fn test_merge_small_regions_basic() {
1247        let image = array![
1248            [0.1, 0.1, 0.9, 0.9],
1249            [0.1, 0.1, 0.9, 0.9],
1250            [0.1, 0.1, 0.9, 0.9],
1251            [0.1, 0.1, 0.9, 0.9],
1252        ];
1253
1254        let labels = array![
1255            [1, 1, 2, 2],
1256            [1, 3, 2, 2], // region 3 has area 1
1257            [1, 1, 2, 2],
1258            [1, 1, 2, 2],
1259        ];
1260
1261        let config = OversegmentationConfig {
1262            min_region_area: 3,
1263            ..Default::default()
1264        };
1265
1266        let result = merge_small_regions(&image, &labels, &config).expect("should succeed");
1267
1268        // Region 3 (area 1) should be merged
1269        assert_ne!(result[[1, 1]], 3);
1270        // It should be merged into region 1 (most similar mean intensity)
1271        assert_eq!(result[[1, 1]], 1);
1272    }
1273
1274    #[test]
1275    fn test_merge_small_regions_max_regions() {
1276        let image = Array2::<f64>::from_elem((6, 6), 0.5);
1277
1278        let mut labels = Array2::<i32>::zeros((6, 6));
1279        // Create 4 regions
1280        for r in 0..3 {
1281            for c in 0..3 {
1282                labels[[r, c]] = 1;
1283            }
1284        }
1285        for r in 0..3 {
1286            for c in 3..6 {
1287                labels[[r, c]] = 2;
1288            }
1289        }
1290        for r in 3..6 {
1291            for c in 0..3 {
1292                labels[[r, c]] = 3;
1293            }
1294        }
1295        for r in 3..6 {
1296            for c in 3..6 {
1297                labels[[r, c]] = 4;
1298            }
1299        }
1300
1301        let config = OversegmentationConfig {
1302            min_region_area: 1,
1303            max_regions: 2,
1304            ..Default::default()
1305        };
1306
1307        let result = merge_small_regions(&image, &labels, &config).expect("should succeed");
1308
1309        // Count unique labels
1310        let mut unique = std::collections::HashSet::new();
1311        for &v in result.iter() {
1312            if v > 0 {
1313                unique.insert(v);
1314            }
1315        }
1316        assert!(
1317            unique.len() <= 2,
1318            "Expected <= 2 regions, got {}",
1319            unique.len()
1320        );
1321    }
1322
1323    #[test]
1324    fn test_h_minima_transform() {
1325        // h-minima transform: HMIN_h(f) = reconstruction_by_dilation(f - h, f)
1326        //
1327        // For a minimum at pixel p with depth d (= surrounding_level - f(p)):
1328        // - If d <= h: the minimum is SUPPRESSED (filled flat with its plateau)
1329        //   Result: surrounding_level - h (same as neighboring pixels in reconstruction)
1330        // - If d > h: the minimum is PRESERVED but with reduced depth d - h
1331        //   Result: f(p) (clamped by mask, stays at original value)
1332
1333        let image = array![
1334            [5.0, 5.0, 5.0, 5.0, 5.0],
1335            [5.0, 3.0, 5.0, 1.0, 5.0],
1336            [5.0, 5.0, 5.0, 5.0, 5.0],
1337        ];
1338        // Minimum at (1,1): value=3, depth=2 (5-3)
1339        // Minimum at (1,3): value=1, depth=4 (5-1)
1340
1341        // h=1.5: both minima deeper than h, both remain as local minima
1342        // marker = f - 1.5 => surrounding=3.5, (1,1)=1.5, (1,3)=-0.5
1343        // After reconstruction:
1344        //   (1,1): reconstruction raises marker to min(3.5, mask=3) = 3.0
1345        //   (1,3): reconstruction tries 3.5 but clamped by mask=1 => 1.0
1346        //   neighbors: all become 3.5 (< mask=5)
1347        // So both remain as minima in the reconstructed image
1348        let result = h_minima_transform(&image, 1.5).expect("should succeed");
1349
1350        // Flat plateau should be at 5.0 - 1.5 = 3.5
1351        assert!((result[[0, 0]] - 3.5).abs() < 1e-10);
1352        // (1,1) has depth 2 > h=1.5: still a minimum, value = original (3.0)
1353        //   actually, reconstruction of marker=1.5 under mask=3.0 =>
1354        //   neighbors dilate to 3.5 then clamp to min(3.5, 3.0) = 3.0
1355        //   So (1,1) = 3.0, plateau = 3.5 => depth = 0.5 = 2.0 - 1.5 = d - h
1356        assert!(result[[1, 1]] < result[[0, 0]]); // still a minimum
1357                                                  // (1,3) has depth 4 > h=1.5: still a minimum
1358        assert!(result[[1, 3]] < result[[0, 0]]); // still a minimum
1359
1360        // h=3.0: minimum at (1,1) with depth 2 <= h=3 is SUPPRESSED
1361        //   marker = f - 3 => surrounding=2, (1,1)=0, (1,3)=-2
1362        //   After reconstruction:
1363        //     (1,1): neighbors dilate to 2, min(2, mask=3) = 2 => same as plateau
1364        //     (1,3): neighbors dilate to 2, min(2, mask=1) = 1 => still a minimum
1365        //     Plateau pixels: all 2 (= 5 - 3)
1366        let result2 = h_minima_transform(&image, 3.0).expect("should succeed");
1367
1368        // Plateau level = 5 - 3 = 2.0
1369        let plateau_level = result2[[0, 0]];
1370        assert!((plateau_level - 2.0).abs() < 1e-10);
1371
1372        // (1,1) depth=2 <= h=3: SUPPRESSED -- raised to plateau level
1373        assert!(
1374            (result2[[1, 1]] - plateau_level).abs() < 1e-10,
1375            "expected (1,1) to be suppressed to plateau {}, got {}",
1376            plateau_level,
1377            result2[[1, 1]]
1378        );
1379
1380        // (1,3) depth=4 > h=3: PRESERVED as a minimum
1381        // Reconstruction: marker=-2, neighbors=2, mask=1 => min(2, 1) = 1
1382        assert!(
1383            result2[[1, 3]] < plateau_level,
1384            "expected (1,3) to still be a minimum (below plateau {}), got {}",
1385            plateau_level,
1386            result2[[1, 3]]
1387        );
1388        assert!((result2[[1, 3]] - 1.0).abs() < 1e-10);
1389    }
1390
1391    #[test]
1392    fn test_auto_markers_basic() {
1393        let image = array![
1394            [5.0, 5.0, 5.0, 5.0, 5.0],
1395            [5.0, 1.0, 5.0, 2.0, 5.0],
1396            [5.0, 5.0, 5.0, 5.0, 5.0],
1397        ];
1398
1399        let markers =
1400            auto_markers(&image, WatershedNeighborhood::Conn4, None).expect("should succeed");
1401
1402        // Should detect two local minima
1403        assert!(markers[[1, 1]] > 0);
1404        assert!(markers[[1, 3]] > 0);
1405        assert_ne!(markers[[1, 1]], markers[[1, 3]]);
1406
1407        // Non-minimum pixels should be 0
1408        assert_eq!(markers[[0, 0]], 0);
1409    }
1410
1411    #[test]
1412    fn test_auto_markers_with_threshold() {
1413        let image = array![
1414            [5.0, 5.0, 5.0, 5.0, 5.0],
1415            [5.0, 4.5, 5.0, 1.0, 5.0],
1416            [5.0, 5.0, 5.0, 5.0, 5.0],
1417        ];
1418
1419        // Threshold = 1.0: only minima with depth >= 1.0
1420        let markers =
1421            auto_markers(&image, WatershedNeighborhood::Conn4, Some(1.0)).expect("should succeed");
1422
1423        // (1,1) has depth 0.5 < threshold: not a marker
1424        assert_eq!(markers[[1, 1]], 0);
1425        // (1,3) has depth 4.0 >= threshold: is a marker
1426        assert!(markers[[1, 3]] > 0);
1427    }
1428
1429    #[test]
1430    fn test_auto_markers_empty() {
1431        let image: Array2<f64> = Array2::zeros((0, 0));
1432        let markers =
1433            auto_markers(&image, WatershedNeighborhood::Conn4, None).expect("empty should succeed");
1434        assert_eq!(markers.len(), 0);
1435    }
1436
1437    #[test]
1438    fn test_compact_watershed_conn4() {
1439        let image = array![[0.1, 0.9, 0.1], [0.9, 0.9, 0.9], [0.1, 0.9, 0.1],];
1440        let markers = array![[1, 0, 2], [0, 0, 0], [3, 0, 4],];
1441
1442        let config = CompactWatershedConfig {
1443            connectivity: WatershedNeighborhood::Conn4,
1444            compactness: 0.0,
1445            watershed_line: false,
1446        };
1447
1448        let result = compact_watershed(&image, &markers, &config).expect("should succeed");
1449        assert_eq!(result[[0, 0]], 1);
1450        assert_eq!(result[[0, 2]], 2);
1451        assert_eq!(result[[2, 0]], 3);
1452        assert_eq!(result[[2, 2]], 4);
1453    }
1454}