Skip to main content

iris/morphology/
mod.rs

1pub mod ops;
2
3pub use ops::{MorphOp, MorphShape, Morphology};
4
5use crate::error::{IrisError, Result};
6use crate::image::Image;
7use burn::tensor::{Tensor, TensorData, backend::Backend};
8
9impl<B: Backend> Image<B> {
10    /// Dilates the image using the given structuring element.
11    /// `kernel` is a 2D binary kernel (`1` = active, `0` = inactive).
12    /// For each pixel, it takes the maximum value in the active neighborhood.
13    pub fn dilate_with_kernel(self, kernel: &[&[u8]]) -> Result<Self> {
14        let kh = kernel.len();
15        if kh == 0 || kernel[0].is_empty() {
16            return Err(IrisError::InvalidParameter(
17                "Kernel must be non-empty".into(),
18            ));
19        }
20        let kw = kernel[0].len();
21
22        let dims = self.tensor.dims();
23        let c = dims[0];
24        let h = dims[1];
25        let w = dims[2];
26
27        let device = self.tensor.device();
28        let tensor_data = self.tensor.into_data();
29        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
30        let mut out_vals = vec![0.0f32; c * h * w];
31
32        let ay = kh as isize / 2;
33        let ax = kw as isize / 2;
34
35        for ch in 0..c {
36            for y in 0..h {
37                for x in 0..w {
38                    let mut max_val = f32::MIN;
39                    for ky in 0..kh {
40                        for kx in 0..kw {
41                            if kernel[ky][kx] == 0 {
42                                continue;
43                            }
44                            let sy = y as isize + ky as isize - ay;
45                            let sx = x as isize + kx as isize - ax;
46                            if sy >= 0 && sy < h as isize && sx >= 0 && sx < w as isize {
47                                let val = flat_vals[ch * h * w + sy as usize * w + sx as usize];
48                                if val > max_val {
49                                    max_val = val;
50                                }
51                            }
52                        }
53                    }
54                    out_vals[ch * h * w + y * w + x] = if max_val == f32::MIN {
55                        flat_vals[ch * h * w + y * w + x]
56                    } else {
57                        max_val
58                    };
59                }
60            }
61        }
62
63        let new_data = TensorData::new(out_vals, [c, h, w]);
64        let new_tensor = Tensor::<B, 3>::from_data(new_data, &device);
65        Ok(Image::new(new_tensor))
66    }
67
68    /// Erodes the image using the given structuring element.
69    /// `kernel` is a 2D binary kernel (`1` = active, `0` = inactive).
70    /// For each pixel, it takes the minimum value in the active neighborhood.
71    pub fn erode_with_kernel(self, kernel: &[&[u8]]) -> Result<Self> {
72        let kh = kernel.len();
73        if kh == 0 || kernel[0].is_empty() {
74            return Err(IrisError::InvalidParameter(
75                "Kernel must be non-empty".into(),
76            ));
77        }
78        let kw = kernel[0].len();
79
80        let dims = self.tensor.dims();
81        let c = dims[0];
82        let h = dims[1];
83        let w = dims[2];
84
85        let device = self.tensor.device();
86        let tensor_data = self.tensor.into_data();
87        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
88        let mut out_vals = vec![0.0f32; c * h * w];
89
90        let ay = kh as isize / 2;
91        let ax = kw as isize / 2;
92
93        for ch in 0..c {
94            for y in 0..h {
95                for x in 0..w {
96                    let mut min_val = f32::MAX;
97                    for ky in 0..kh {
98                        for kx in 0..kw {
99                            if kernel[ky][kx] == 0 {
100                                continue;
101                            }
102                            let sy = y as isize + ky as isize - ay;
103                            let sx = x as isize + kx as isize - ax;
104                            if sy >= 0 && sy < h as isize && sx >= 0 && sx < w as isize {
105                                let val = flat_vals[ch * h * w + sy as usize * w + sx as usize];
106                                if val < min_val {
107                                    min_val = val;
108                                }
109                            }
110                        }
111                    }
112                    out_vals[ch * h * w + y * w + x] = if min_val == f32::MAX {
113                        flat_vals[ch * h * w + y * w + x]
114                    } else {
115                        min_val
116                    };
117                }
118            }
119        }
120
121        let new_data = TensorData::new(out_vals, [c, h, w]);
122        let new_tensor = Tensor::<B, 3>::from_data(new_data, &device);
123        Ok(Image::new(new_tensor))
124    }
125
126    /// Dilates the image by using a rectangular structuring element of the given size.
127    /// For each pixel, it takes the maximum value in the neighborhood.
128    pub fn dilate(self, kernel_size: usize) -> Result<Self> {
129        if kernel_size.is_multiple_of(2) {
130            return Err(IrisError::InvalidParameter(
131                "Kernel size must be odd".into(),
132            ));
133        }
134
135        let dims = self.tensor.dims();
136        let c = dims[0];
137        let h = dims[1];
138        let w = dims[2];
139
140        let device = self.tensor.device();
141        let tensor_data = self.tensor.into_data();
142        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
143        let mut out_vals = vec![0.0f32; c * h * w];
144
145        let rad = (kernel_size / 2) as isize;
146
147        {
148            use rayon::prelude::*;
149            out_vals
150                .par_chunks_exact_mut(w)
151                .enumerate()
152                .for_each(|(idx, row)| {
153                    let ch = idx / h;
154                    let y = idx % h;
155
156                    for x in 0..w {
157                        let mut max_val = f32::MIN;
158                        for ky in -rad..=rad {
159                            let py = y as isize + ky;
160                            if py >= 0 && py < h as isize {
161                                for kx in -rad..=rad {
162                                    let px = x as isize + kx;
163                                    if px >= 0 && px < w as isize {
164                                        let val = flat_vals
165                                            [ch * h * w + (py as usize) * w + (px as usize)];
166                                        if val > max_val {
167                                            max_val = val;
168                                        }
169                                    }
170                                }
171                            }
172                        }
173                        row[x] = max_val;
174                    }
175                });
176        }
177
178        let new_data = TensorData::new(out_vals, [c, h, w]);
179        let new_tensor = Tensor::<B, 3>::from_data(new_data, &device);
180        Ok(Image::new(new_tensor))
181    }
182
183    /// Erodes the image by using a rectangular structuring element of the given size.
184    /// For each pixel, it takes the minimum value in the neighborhood.
185    pub fn erode(self, kernel_size: usize) -> Result<Self> {
186        if kernel_size.is_multiple_of(2) {
187            return Err(IrisError::InvalidParameter(
188                "Kernel size must be odd".into(),
189            ));
190        }
191
192        let dims = self.tensor.dims();
193        let c = dims[0];
194        let h = dims[1];
195        let w = dims[2];
196
197        let device = self.tensor.device();
198        let tensor_data = self.tensor.into_data();
199        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
200        let mut out_vals = vec![0.0f32; c * h * w];
201
202        let rad = (kernel_size / 2) as isize;
203
204        {
205            use rayon::prelude::*;
206            out_vals
207                .par_chunks_exact_mut(w)
208                .enumerate()
209                .for_each(|(idx, row)| {
210                    let ch = idx / h;
211                    let y = idx % h;
212
213                    for x in 0..w {
214                        let mut min_val = f32::MAX;
215                        for ky in -rad..=rad {
216                            let py = y as isize + ky;
217                            if py >= 0 && py < h as isize {
218                                for kx in -rad..=rad {
219                                    let px = x as isize + kx;
220                                    if px >= 0 && px < w as isize {
221                                        let val = flat_vals
222                                            [ch * h * w + (py as usize) * w + (px as usize)];
223                                        if val < min_val {
224                                            min_val = val;
225                                        }
226                                    }
227                                }
228                            }
229                        }
230                        row[x] = min_val;
231                    }
232                });
233        }
234
235        let new_data = TensorData::new(out_vals, [c, h, w]);
236        let new_tensor = Tensor::<B, 3>::from_data(new_data, &device);
237        Ok(Image::new(new_tensor))
238    }
239
240    /// Morphological opening (erosion followed by dilation).
241    pub fn morph_open(self, kernel_size: usize) -> Result<Self> {
242        self.erode(kernel_size)?.dilate(kernel_size)
243    }
244
245    /// Morphological closing (dilation followed by erosion).
246    pub fn morph_close(self, kernel_size: usize) -> Result<Self> {
247        self.dilate(kernel_size)?.erode(kernel_size)
248    }
249
250    /// Hit-or-miss transform for binary pattern matching.
251    ///
252    /// `pattern` defines the foreground (1) pixels to match and `bg_pattern`
253    /// defines the background (1 = must-be-background) pixels to match.
254    /// Pixels in either pattern set to 0 are "don't care".
255    /// Returns a binary image where matched structuring element origins are set to 1.0.
256    pub fn hit_or_miss(&self, pattern: &[&[u8]], bg_pattern: &[&[u8]]) -> Result<Self> {
257        let dims = self.tensor.dims();
258        let c = dims[0];
259        let h = dims[1];
260        let w = dims[2];
261
262        if pattern.is_empty() || pattern[0].is_empty() {
263            return Err(IrisError::InvalidParameter(
264                "Pattern must be non-empty".into(),
265            ));
266        }
267        if bg_pattern.len() != pattern.len() || bg_pattern[0].len() != pattern[0].len() {
268            return Err(IrisError::InvalidParameter(
269                "Background pattern must match foreground pattern dimensions".into(),
270            ));
271        }
272
273        let ph = pattern.len();
274        let pw = pattern[0].len();
275        let ay = ph as isize / 2;
276        let ax = pw as isize / 2;
277
278        let device = self.tensor.device();
279        let tensor_data = self.tensor.clone().into_data();
280        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
281        let mut out_vals = vec![0.0f32; c * h * w];
282
283        for ch in 0..c {
284            for y in 0..h {
285                for x in 0..w {
286                    let mut matched = true;
287
288                    for ky in 0..ph {
289                        for kx in 0..pw {
290                            let sy = y as isize + ky as isize - ay;
291                            let sx = x as isize + kx as isize - ax;
292
293                            if sy < 0 || sy >= h as isize || sx < 0 || sx >= w as isize {
294                                if pattern[ky][kx] == 1 || bg_pattern[ky][kx] == 1 {
295                                    matched = false;
296                                    break;
297                                }
298                                continue;
299                            }
300
301                            let val = flat_vals[ch * h * w + sy as usize * w + sx as usize];
302                            let is_foreground = val > 0.5;
303
304                            if pattern[ky][kx] == 1 && !is_foreground {
305                                matched = false;
306                                break;
307                            }
308                            if bg_pattern[ky][kx] == 1 && is_foreground {
309                                matched = false;
310                                break;
311                            }
312                        }
313                        if !matched {
314                            break;
315                        }
316                    }
317
318                    if matched {
319                        out_vals[ch * h * w + y * w + x] = 1.0;
320                    }
321                }
322            }
323        }
324
325        let new_data = TensorData::new(out_vals, [c, h, w]);
326        let new_tensor = Tensor::<B, 3>::from_data(new_data, &device);
327        Ok(Image::new(new_tensor))
328    }
329
330    /// Zhang-Suen thinning algorithm for binary images.
331    ///
332    /// Iteratively removes boundary pixels that are not essential to
333    /// connectivity until the skeleton is a single pixel wide.
334    /// Expects a binary image with foreground = 1.0, background = 0.0.
335    pub fn thin(&self) -> Result<Self> {
336        let dims = self.tensor.dims();
337        let c = dims[0];
338        let h = dims[1];
339        let w = dims[2];
340
341        let device = self.tensor.device();
342        let tensor_data = self.tensor.clone().into_data();
343        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
344
345        let mut grid: Vec<u8> = flat_vals
346            .iter()
347            .map(|&v| if v > 0.5 { 1u8 } else { 0u8 })
348            .collect();
349
350        let count_transitions = |grid: &[u8], w: usize, h: usize, x: isize, y: isize| -> u8 {
351            let dx = [1, 1, 0, -1, -1, -1, 0, 1];
352            let dy = [0, 1, 1, 1, 0, -1, -1, -1];
353            let mut count = 0u8;
354            for i in 0..8 {
355                let i2 = (i + 1) % 8;
356                let x1 = x + dx[i];
357                let y1 = y + dy[i];
358                let x2 = x + dx[i2];
359                let y2 = y + dy[i2];
360
361                let v1 = if x1 >= 0 && x1 < w as isize && y1 >= 0 && y1 < h as isize {
362                    grid[y1 as usize * w + x1 as usize]
363                } else {
364                    0
365                };
366                let v2 = if x2 >= 0 && x2 < w as isize && y2 >= 0 && y2 < h as isize {
367                    grid[y2 as usize * w + x2 as usize]
368                } else {
369                    0
370                };
371
372                if v1 == 0 && v2 == 1 {
373                    count += 1;
374                }
375            }
376            count
377        };
378
379        let count_neighbors = |grid: &[u8], w: usize, h: usize, x: isize, y: isize| -> u32 {
380            let dx = [1, 1, 0, -1, -1, -1, 0, 1];
381            let dy = [0, 1, 1, 1, 0, -1, -1, -1];
382            let mut sum = 0u32;
383            for i in 0..8 {
384                let nx = x + dx[i];
385                let ny = y + dy[i];
386                if nx >= 0 && nx < w as isize && ny >= 0 && ny < h as isize {
387                    sum += grid[ny as usize * w + nx as usize] as u32;
388                }
389            }
390            sum
391        };
392
393        let mut changed = true;
394        while changed {
395            changed = false;
396
397            // Step 1
398            let mut to_remove = Vec::new();
399            for y in 1..(h - 1) {
400                for x in 1..(w - 1) {
401                    let xi = x as isize;
402                    let yi = y as isize;
403
404                    let p = grid[yi as usize * w + xi as usize];
405                    if p != 1 {
406                        continue;
407                    }
408
409                    let n = count_neighbors(&grid, w, h, xi, yi);
410                    let t = count_transitions(&grid, w, h, xi, yi);
411
412                    let p2 = grid[((yi - 1).max(0)) as usize * w + xi as usize];
413                    let p4 = grid[yi as usize * w + ((xi + 1).min(w as isize - 1)) as usize];
414                    let p6 = grid[((yi + 1).min(h as isize - 1)) as usize * w + xi as usize];
415                    let p8 = grid[yi as usize * w + ((xi - 1).max(0)) as usize];
416
417                    if (2..=6).contains(&n) && t == 1 && p4 == 0 && p6 == 0 && (p2 == 0 || p8 == 0)
418                    {
419                        to_remove.push((y, x));
420                    }
421                }
422            }
423
424            for (y, x) in &to_remove {
425                grid[y * w + x] = 0;
426                changed = true;
427            }
428
429            // Step 2
430            let mut to_remove = Vec::new();
431            for y in 1..(h - 1) {
432                for x in 1..(w - 1) {
433                    let xi = x as isize;
434                    let yi = y as isize;
435
436                    let p = grid[yi as usize * w + xi as usize];
437                    if p != 1 {
438                        continue;
439                    }
440
441                    let n = count_neighbors(&grid, w, h, xi, yi);
442                    let t = count_transitions(&grid, w, h, xi, yi);
443
444                    let p2 = grid[((yi - 1).max(0)) as usize * w + xi as usize];
445                    let p4 = grid[yi as usize * w + ((xi + 1).min(w as isize - 1)) as usize];
446                    let p6 = grid[((yi + 1).min(h as isize - 1)) as usize * w + xi as usize];
447                    let p8 = grid[yi as usize * w + ((xi - 1).max(0)) as usize];
448
449                    if (2..=6).contains(&n) && t == 1 && p2 == 0 && p8 == 0 && (p4 == 0 || p6 == 0)
450                    {
451                        to_remove.push((y, x));
452                    }
453                }
454            }
455
456            for (y, x) in &to_remove {
457                grid[y * w + x] = 0;
458                changed = true;
459            }
460        }
461
462        let out_vals: Vec<f32> = grid.iter().map(|&v| v as f32).collect();
463        let new_data = TensorData::new(out_vals, [c, h, w]);
464        let new_tensor = Tensor::<B, 3>::from_data(new_data, &device);
465        Ok(Image::new(new_tensor))
466    }
467
468    /// Morphological skeleton extraction.
469    ///
470    /// Computes the skeleton by iteratively applying morphological opening
471    /// with a structuring element and subtracting the opened result from the
472    /// original, then accumulating the residuals. Uses a 3x3 cross kernel.
473    pub fn skeleton(&self) -> Result<Self> {
474        let dims = self.tensor.dims();
475        let c = dims[0];
476        let h = dims[1];
477        let w = dims[2];
478
479        let device = self.tensor.device();
480        let tensor_data = self.tensor.clone().into_data();
481        let flat_vals: Vec<f32> = tensor_data.iter::<f32>().collect();
482
483        let cross_kernel: Vec<&[u8]> = vec![&[0, 1, 0], &[1, 1, 1], &[0, 1, 0]];
484
485        let mut current = flat_vals.clone();
486        let mut skeleton = vec![0.0f32; c * h * w];
487
488        let mut iter_count = 0;
489        let max_iters = h + w; // skeleton converges in at most min(h,w) iterations
490
491        while iter_count < max_iters {
492            iter_count += 1;
493
494            // Erosion
495            let eroded = Self::erode_flat(&current, c, h, w, &cross_kernel);
496
497            // Dilation of eroded
498            let opened = Self::dilate_flat(&eroded, c, h, w, &cross_kernel);
499
500            // Subtract opened from current: temp = current - opened
501            let mut temp = vec![0.0f32; c * h * w];
502            let mut any_nonzero = false;
503            for i in 0..(c * h * w) {
504                let diff = current[i] - opened[i];
505                if diff > 0.5 {
506                    temp[i] = 1.0;
507                    skeleton[i] = 1.0;
508                    any_nonzero = true;
509                }
510            }
511
512            // current = opened
513            current = opened;
514
515            if !any_nonzero {
516                break;
517            }
518        }
519
520        let new_data = TensorData::new(skeleton, [c, h, w]);
521        let new_tensor = Tensor::<B, 3>::from_data(new_data, &device);
522        Ok(Image::new(new_tensor))
523    }
524
525    /// Helper: flat erosion with a custom kernel operating on an f32 slice.
526    fn erode_flat(input: &[f32], c: usize, h: usize, w: usize, kernel: &[&[u8]]) -> Vec<f32> {
527        let kh = kernel.len();
528        let kw = kernel[0].len();
529        let ay = kh as isize / 2;
530        let ax = kw as isize / 2;
531        let mut out = vec![0.0f32; c * h * w];
532
533        for ch in 0..c {
534            for y in 0..h {
535                for x in 0..w {
536                    let mut min_val = f32::MAX;
537                    for ky in 0..kh {
538                        for kx in 0..kw {
539                            if kernel[ky][kx] == 0 {
540                                continue;
541                            }
542                            let sy = y as isize + ky as isize - ay;
543                            let sx = x as isize + kx as isize - ax;
544                            if sy >= 0 && sy < h as isize && sx >= 0 && sx < w as isize {
545                                let val = input[ch * h * w + sy as usize * w + sx as usize];
546                                if val < min_val {
547                                    min_val = val;
548                                }
549                            }
550                        }
551                    }
552                    out[ch * h * w + y * w + x] = if min_val == f32::MAX {
553                        input[ch * h * w + y * w + x]
554                    } else {
555                        min_val
556                    };
557                }
558            }
559        }
560        out
561    }
562
563    /// Helper: flat dilation with a custom kernel operating on an f32 slice.
564    fn dilate_flat(input: &[f32], c: usize, h: usize, w: usize, kernel: &[&[u8]]) -> Vec<f32> {
565        let kh = kernel.len();
566        let kw = kernel[0].len();
567        let ay = kh as isize / 2;
568        let ax = kw as isize / 2;
569        let mut out = vec![0.0f32; c * h * w];
570
571        for ch in 0..c {
572            for y in 0..h {
573                for x in 0..w {
574                    let mut max_val = f32::MIN;
575                    for ky in 0..kh {
576                        for kx in 0..kw {
577                            if kernel[ky][kx] == 0 {
578                                continue;
579                            }
580                            let sy = y as isize + ky as isize - ay;
581                            let sx = x as isize + kx as isize - ax;
582                            if sy >= 0 && sy < h as isize && sx >= 0 && sx < w as isize {
583                                let val = input[ch * h * w + sy as usize * w + sx as usize];
584                                if val > max_val {
585                                    max_val = val;
586                                }
587                            }
588                        }
589                    }
590                    out[ch * h * w + y * w + x] = if max_val == f32::MIN {
591                        input[ch * h * w + y * w + x]
592                    } else {
593                        max_val
594                    };
595                }
596            }
597        }
598        out
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::test_helpers::{TestBackend, test_device};
606
607    #[test]
608    fn test_morphology() {
609        let device = test_device();
610        let flat_data = vec![0.5f32; 3 * 8 * 8];
611        let tensor_data = TensorData::new(flat_data, [3, 8, 8]);
612        let img = Image::new(Tensor::<TestBackend, 3>::from_data(tensor_data, &device));
613
614        let dilated = img.clone().dilate(3).unwrap();
615        assert_eq!(dilated.shape(), [3, 8, 8]);
616
617        let eroded = img.clone().erode(3).unwrap();
618        assert_eq!(eroded.shape(), [3, 8, 8]);
619
620        let opened = img.clone().morph_open(3).unwrap();
621        assert_eq!(opened.shape(), [3, 8, 8]);
622
623        let closed = img.clone().morph_close(3).unwrap();
624        assert_eq!(closed.shape(), [3, 8, 8]);
625    }
626
627    #[test]
628    fn test_dilate_with_cross_kernel() {
629        let device = test_device();
630        let flat_data = vec![0.0f32; 3 * 8 * 8];
631        let tensor_data = TensorData::new(flat_data, [3, 8, 8]);
632        let img = Image::new(Tensor::<TestBackend, 3>::from_data(tensor_data, &device));
633
634        let kernel: Vec<&[u8]> = vec![&[0, 1, 0], &[1, 1, 1], &[0, 1, 0]];
635        let dilated = img.dilate_with_kernel(&kernel).unwrap();
636        assert_eq!(dilated.shape(), [3, 8, 8]);
637    }
638
639    #[test]
640    fn test_erode_with_ellipse_kernel() {
641        let device = test_device();
642        let flat_data = vec![0.5f32; 3 * 8 * 8];
643        let tensor_data = TensorData::new(flat_data, [3, 8, 8]);
644        let img = Image::new(Tensor::<TestBackend, 3>::from_data(tensor_data, &device));
645
646        let kernel: Vec<&[u8]> = vec![&[0, 1, 0], &[1, 1, 1], &[0, 1, 0]];
647        let eroded = img.erode_with_kernel(&kernel).unwrap();
648        assert_eq!(eroded.shape(), [3, 8, 8]);
649    }
650
651    #[test]
652    fn test_empty_kernel() {
653        let device = test_device();
654        let data = vec![0.5f32; 3 * 8 * 8];
655        let tensor = Tensor::<TestBackend, 3>::from_data(TensorData::new(data, [3, 8, 8]), &device);
656        let img = Image::new(tensor);
657        let empty: Vec<&[u8]> = vec![];
658        assert!(img.dilate_with_kernel(&empty).is_err());
659    }
660
661    #[test]
662    fn test_hit_or_miss() {
663        let device = test_device();
664        // Create a simple image with a vertical line at x=4
665        let mut flat_data = vec![0.0f32; 8 * 8];
666        for y in 1..7 {
667            flat_data[y * 8 + 4] = 1.0;
668        }
669        let tensor =
670            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [1, 8, 8]), &device);
671        let img = Image::new(tensor);
672
673        // Pattern: a vertical line of 3 foreground pixels
674        let pattern: Vec<&[u8]> = vec![&[0, 0, 0], &[0, 1, 0], &[0, 1, 0], &[0, 1, 0], &[0, 0, 0]];
675        // Background: nothing required
676        let bg_pattern: Vec<&[u8]> =
677            vec![&[0, 0, 0], &[1, 0, 1], &[1, 0, 1], &[1, 0, 1], &[0, 0, 0]];
678
679        let result = img.hit_or_miss(&pattern, &bg_pattern).unwrap();
680        assert_eq!(result.shape(), [1, 8, 8]);
681        let vals: Vec<f32> = result.tensor.into_data().iter::<f32>().collect();
682        // At least one pixel should match the hit-or-miss pattern
683        assert!(vals.iter().any(|&v| v > 0.5));
684    }
685
686    #[test]
687    fn test_thin() {
688        let device = test_device();
689        // Create a thick 5x5 block
690        let mut flat_data = vec![0.0f32; 10 * 10];
691        for y in 2..7 {
692            for x in 2..7 {
693                flat_data[y * 10 + x] = 1.0;
694            }
695        }
696        let orig_count = flat_data.iter().filter(|&&v| v > 0.5).count();
697        let tensor =
698            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [1, 10, 10]), &device);
699        let img = Image::new(tensor);
700
701        let result = img.thin().unwrap();
702        assert_eq!(result.shape(), [1, 10, 10]);
703        let vals: Vec<f32> = result.tensor.into_data().iter::<f32>().collect();
704        let thin_count = vals.iter().filter(|&&v| v > 0.5).count();
705        assert!(thin_count <= orig_count);
706        assert!(thin_count > 0);
707    }
708
709    #[test]
710    fn test_skeleton() {
711        let device = test_device();
712        // Create a rectangular block
713        let mut flat_data = vec![0.0f32; 12 * 12];
714        for y in 2..10 {
715            for x in 2..10 {
716                flat_data[y * 12 + x] = 1.0;
717            }
718        }
719        let tensor =
720            Tensor::<TestBackend, 3>::from_data(TensorData::new(flat_data, [1, 12, 12]), &device);
721        let img = Image::new(tensor);
722
723        let result = img.skeleton().unwrap();
724        assert_eq!(result.shape(), [1, 12, 12]);
725        let vals: Vec<f32> = result.tensor.into_data().iter::<f32>().collect();
726        let skel_count = vals.iter().filter(|&&v| v > 0.5).count();
727        // Skeleton should have fewer pixels than original block
728        assert!(skel_count > 0);
729    }
730}