Skip to main content

scirs2_ndimage/
texture_segmentation.rs

1//! Texture-Based Image Segmentation
2//!
3//! This module provides algorithms that segment images based on their
4//! textural properties:
5//!
6//! - **Gabor filter bank** feature maps (multi-frequency, multi-orientation)
7//! - **Gabor + k-means** texture segmentation
8//! - **LBP-based** (Local Binary Pattern) texture segmentation
9//! - **MRF** (Markov Random Field / iterated conditional modes) texture segmentation
10//! - Patch-level texture feature extraction
11//!
12//! # References
13//! - Jain, A.K. & Farrokhnia, F. (1991). "Unsupervised texture segmentation using
14//!   Gabor filters." Pattern Recognition.
15//! - Ojala, T., Pietikainen, M. & Maenpaa, T. (2002). "Multiresolution gray-scale and
16//!   rotation invariant texture classification with local binary patterns." IEEE TPAMI.
17//! - Besag, J. (1986). "On the statistical analysis of dirty pictures." JRSS-B.
18
19use crate::error::{NdimageError, NdimageResult};
20use scirs2_core::ndarray::{s, Array1, Array2, Array3};
21use std::f64::consts::PI;
22
23// ---------------------------------------------------------------------------
24// Gabor filter bank feature map
25// ---------------------------------------------------------------------------
26
27/// Compute a Gabor filter bank response volume.
28///
29/// For every (frequency, orientation) pair a Gabor kernel is constructed
30/// and convolved with the image.  Both the real and imaginary response
31/// energies (magnitude) are stacked into the output array.
32///
33/// # Parameters
34/// - `image`        – input grayscale image.
35/// - `frequencies`  – spatial frequencies in cycles/pixel (e.g. `[0.1, 0.2, 0.3]`).
36/// - `orientations` – filter orientations in **radians** (e.g. 4 evenly-spaced from 0 to π).
37///
38/// # Returns
39/// `Array3<f64>` with shape `(rows, cols, n_frequencies * n_orientations)`.
40/// Each channel is the magnitude of the complex Gabor response at one
41/// (frequency, orientation) pair.
42///
43/// # Errors
44/// Returns `NdimageError::InvalidInput` for an empty image or empty parameter lists.
45pub fn gabor_feature_map(
46    image: &Array2<f64>,
47    frequencies: &[f64],
48    orientations: &[f64],
49) -> NdimageResult<Array3<f64>> {
50    let (rows, cols) = image.dim();
51    if rows == 0 || cols == 0 {
52        return Err(NdimageError::InvalidInput("Image must not be empty".into()));
53    }
54    if frequencies.is_empty() || orientations.is_empty() {
55        return Err(NdimageError::InvalidInput(
56            "frequencies and orientations must be non-empty".into(),
57        ));
58    }
59
60    let n_channels = frequencies.len() * orientations.len();
61    let mut out = Array3::<f64>::zeros((rows, cols, n_channels));
62
63    let mut ch = 0;
64    for &freq in frequencies {
65        for &theta in orientations {
66            let response = apply_gabor_kernel(image, freq, theta)?;
67            for r in 0..rows {
68                for c in 0..cols {
69                    out[[r, c, ch]] = response[[r, c]];
70                }
71            }
72            ch += 1;
73        }
74    }
75
76    Ok(out)
77}
78
79/// Apply a single Gabor filter and return the response magnitude.
80///
81/// The kernel size is chosen as `2 * ceil(3 * sigma) + 1` where
82/// `sigma = 1.0 / (2.0 * PI * frequency)`.
83fn apply_gabor_kernel(
84    image: &Array2<f64>,
85    frequency: f64,
86    theta: f64,
87) -> NdimageResult<Array2<f64>> {
88    let (rows, cols) = image.dim();
89
90    let sigma = if frequency > 1e-12 {
91        1.0 / (2.0 * PI * frequency)
92    } else {
93        return Err(NdimageError::InvalidInput(
94            "Gabor frequency must be positive".into(),
95        ));
96    };
97    let sigma_x = sigma;
98    let sigma_y = sigma;
99
100    let half = (3.0 * sigma.max(sigma_x)).ceil() as usize;
101    let ksize = 2 * half + 1;
102
103    // Build real & imaginary kernel
104    let mut kernel_real = Array2::<f64>::zeros((ksize, ksize));
105    let mut kernel_imag = Array2::<f64>::zeros((ksize, ksize));
106
107    let cos_t = theta.cos();
108    let sin_t = theta.sin();
109
110    for ky in 0..ksize {
111        for kx in 0..ksize {
112            let y = ky as f64 - half as f64;
113            let x = kx as f64 - half as f64;
114
115            // Rotate
116            let x_rot = x * cos_t + y * sin_t;
117            let y_rot = -x * sin_t + y * cos_t;
118
119            let gauss = (-0.5
120                * (x_rot * x_rot / (sigma_x * sigma_x) + y_rot * y_rot / (sigma_y * sigma_y)))
121                .exp();
122
123            let phase = 2.0 * PI * frequency * x_rot;
124            kernel_real[[ky, kx]] = gauss * phase.cos();
125            kernel_imag[[ky, kx]] = gauss * phase.sin();
126        }
127    }
128
129    // DC-balance kernels: subtract mean so a uniform image gives zero response
130    // for interior pixels when using reflect padding.
131    let n_elems = (ksize * ksize) as f64;
132    let kr_mean = kernel_real.iter().sum::<f64>() / n_elems;
133    let ki_mean = kernel_imag.iter().sum::<f64>() / n_elems;
134    kernel_real.iter_mut().for_each(|v| *v -= kr_mean);
135    kernel_imag.iter_mut().for_each(|v| *v -= ki_mean);
136
137    // Convolve with reflect padding so that uniform images give near-zero
138    // response everywhere (zero-padding would cause non-zero border artefacts
139    // for a DC-balanced kernel).
140    let resp_real = convolve_reflect(image, &kernel_real)?;
141    let resp_imag = convolve_reflect(image, &kernel_imag)?;
142
143    // Magnitude
144    let mut magnitude = Array2::<f64>::zeros((rows, cols));
145    for r in 0..rows {
146        for c in 0..cols {
147            magnitude[[r, c]] = (resp_real[[r, c]].powi(2) + resp_imag[[r, c]].powi(2)).sqrt();
148        }
149    }
150
151    Ok(magnitude)
152}
153
154/// 2-D convolution with zero-padding (same output size).
155fn convolve_same(image: &Array2<f64>, kernel: &Array2<f64>) -> NdimageResult<Array2<f64>> {
156    let (ih, iw) = image.dim();
157    let (kh, kw) = kernel.dim();
158    let ph = kh / 2;
159    let pw = kw / 2;
160
161    let mut out = Array2::<f64>::zeros((ih, iw));
162
163    for r in 0..ih {
164        for c in 0..iw {
165            let mut acc = 0.0;
166            for kr in 0..kh {
167                let ir = r as i64 + kr as i64 - ph as i64;
168                if ir < 0 || ir >= ih as i64 {
169                    continue;
170                }
171                for kc in 0..kw {
172                    let ic = c as i64 + kc as i64 - pw as i64;
173                    if ic < 0 || ic >= iw as i64 {
174                        continue;
175                    }
176                    acc += image[[ir as usize, ic as usize]] * kernel[[kr, kc]];
177                }
178            }
179            out[[r, c]] = acc;
180        }
181    }
182    Ok(out)
183}
184
185/// 2-D convolution with reflect-padding (same output size).
186///
187/// For a DC-balanced kernel (mean = 0), a uniform input image produces a
188/// near-zero output everywhere, including border pixels—unlike zero-padding
189/// which introduces non-zero responses at the borders.
190fn convolve_reflect(image: &Array2<f64>, kernel: &Array2<f64>) -> NdimageResult<Array2<f64>> {
191    let (ih, iw) = image.dim();
192    let (kh, kw) = kernel.dim();
193    let ph = kh / 2;
194    let pw = kw / 2;
195
196    // Reflect-clamp: mirrors the coordinate at the boundaries
197    let reflect = |idx: i64, size: i64| -> usize {
198        if size <= 1 {
199            return 0;
200        }
201        let mut i = idx;
202        // Bring into range via periodic reflect
203        let period = 2 * (size - 1);
204        // Reduce modulo period
205        i = ((i % period) + period) % period;
206        if i >= size {
207            i = period - i;
208        }
209        i as usize
210    };
211
212    let mut out = Array2::<f64>::zeros((ih, iw));
213
214    for r in 0..ih {
215        for c in 0..iw {
216            let mut acc = 0.0;
217            for kr in 0..kh {
218                let ir = reflect(r as i64 + kr as i64 - ph as i64, ih as i64);
219                for kc in 0..kw {
220                    let ic = reflect(c as i64 + kc as i64 - pw as i64, iw as i64);
221                    acc += image[[ir, ic]] * kernel[[kr, kc]];
222                }
223            }
224            out[[r, c]] = acc;
225        }
226    }
227    Ok(out)
228}
229
230// ---------------------------------------------------------------------------
231// k-means clustering (simple Lloyd's algorithm)
232// ---------------------------------------------------------------------------
233
234/// Run k-means on `data` (shape: n_samples × n_features).
235///
236/// Returns `(labels, centroids)` after convergence or `max_iter` iterations.
237fn kmeans(
238    data: &[Vec<f64>],
239    k: usize,
240    max_iter: usize,
241) -> NdimageResult<(Vec<usize>, Vec<Vec<f64>>)> {
242    if data.is_empty() || k == 0 {
243        return Err(NdimageError::InvalidInput(
244            "k-means: data must be non-empty and k >= 1".into(),
245        ));
246    }
247    let n = data.len();
248    let d = data[0].len();
249    let k_actual = k.min(n);
250
251    // Initialise: pick the first k_actual data points as centroids
252    let mut centroids: Vec<Vec<f64>> = (0..k_actual).map(|i| data[i].clone()).collect();
253    let mut labels = vec![0usize; n];
254
255    for _iter in 0..max_iter {
256        let mut changed = false;
257
258        // Assignment step
259        for (i, sample) in data.iter().enumerate() {
260            let best = nearest_centroid(sample, &centroids);
261            if labels[i] != best {
262                labels[i] = best;
263                changed = true;
264            }
265        }
266
267        if !changed {
268            break;
269        }
270
271        // Update step
272        let mut sums = vec![vec![0.0f64; d]; k_actual];
273        let mut counts = vec![0usize; k_actual];
274        for (i, sample) in data.iter().enumerate() {
275            let lbl = labels[i];
276            counts[lbl] += 1;
277            for dim in 0..d {
278                sums[lbl][dim] += sample[dim];
279            }
280        }
281        for k_idx in 0..k_actual {
282            if counts[k_idx] > 0 {
283                let cnt = counts[k_idx] as f64;
284                for dim in 0..d {
285                    centroids[k_idx][dim] = sums[k_idx][dim] / cnt;
286                }
287            }
288        }
289    }
290
291    Ok((labels, centroids))
292}
293
294fn nearest_centroid(sample: &[f64], centroids: &[Vec<f64>]) -> usize {
295    let mut best_idx = 0;
296    let mut best_dist = f64::INFINITY;
297    for (idx, centroid) in centroids.iter().enumerate() {
298        let dist: f64 = sample
299            .iter()
300            .zip(centroid.iter())
301            .map(|(a, b)| (a - b).powi(2))
302            .sum();
303        if dist < best_dist {
304            best_dist = dist;
305            best_idx = idx;
306        }
307    }
308    best_idx
309}
310
311// ---------------------------------------------------------------------------
312// Gabor + k-means segmentation
313// ---------------------------------------------------------------------------
314
315/// Segment an image by Gabor texture features clustered with k-means.
316///
317/// # Parameters
318/// - `image`        – input grayscale image.
319/// - `gabor_params` – `(frequencies, orientations)` slice pair.
320/// - `n_clusters`   – number of texture classes.
321///
322/// # Returns
323/// Label image of shape `(rows, cols)` with cluster IDs in `[0, n_clusters)`.
324///
325/// # Errors
326/// See [`gabor_feature_map`] for input constraints.
327pub fn texture_segment_kmeans(
328    image: &Array2<f64>,
329    gabor_params: (&[f64], &[f64]),
330    n_clusters: usize,
331) -> NdimageResult<Array2<usize>> {
332    let (rows, cols) = image.dim();
333    let (frequencies, orientations) = gabor_params;
334
335    if n_clusters == 0 {
336        return Err(NdimageError::InvalidInput(
337            "n_clusters must be at least 1".into(),
338        ));
339    }
340
341    let feature_map = gabor_feature_map(image, frequencies, orientations)?;
342    let n_ch = feature_map.dim().2;
343
344    // Flatten to (n_pixels, n_features)
345    let mut data: Vec<Vec<f64>> = Vec::with_capacity(rows * cols);
346    for r in 0..rows {
347        for c in 0..cols {
348            let mut feat = Vec::with_capacity(n_ch);
349            for ch in 0..n_ch {
350                feat.push(feature_map[[r, c, ch]]);
351            }
352            data.push(feat);
353        }
354    }
355
356    let (labels, _) = kmeans(&data, n_clusters, 100)?;
357
358    let mut label_image = Array2::<usize>::zeros((rows, cols));
359    for r in 0..rows {
360        for c in 0..cols {
361            label_image[[r, c]] = labels[r * cols + c];
362        }
363    }
364
365    Ok(label_image)
366}
367
368// ---------------------------------------------------------------------------
369// LBP-based segmentation
370// ---------------------------------------------------------------------------
371
372/// Compute the rotation-invariant LBP code for one pixel at `(row, col)`.
373///
374/// Uses `n_points` sampling points on a circle of the given `radius`.
375fn lbp_code_at(image: &Array2<f64>, row: usize, col: usize, radius: f64, n_points: usize) -> u64 {
376    let (rows, cols) = image.dim();
377    let center = image[[row, col]];
378    let mut code = 0u64;
379
380    for p in 0..n_points {
381        let angle = 2.0 * PI * p as f64 / n_points as f64;
382        let sample_r = row as f64 - radius * angle.sin();
383        let sample_c = col as f64 + radius * angle.cos();
384
385        // Bilinear interpolation
386        let r0 = sample_r.floor() as i64;
387        let c0 = sample_c.floor() as i64;
388        let fr = sample_r - r0 as f64;
389        let fc = sample_c - c0 as f64;
390
391        let clamp_r = |r: i64| r.max(0).min(rows as i64 - 1) as usize;
392        let clamp_c = |c: i64| c.max(0).min(cols as i64 - 1) as usize;
393
394        let v00 = image[[clamp_r(r0), clamp_c(c0)]];
395        let v01 = image[[clamp_r(r0), clamp_c(c0 + 1)]];
396        let v10 = image[[clamp_r(r0 + 1), clamp_c(c0)]];
397        let v11 = image[[clamp_r(r0 + 1), clamp_c(c0 + 1)]];
398
399        let val = (1.0 - fr) * (1.0 - fc) * v00
400            + (1.0 - fr) * fc * v01
401            + fr * (1.0 - fc) * v10
402            + fr * fc * v11;
403
404        if val >= center {
405            code |= 1 << p;
406        }
407    }
408
409    // Rotation-invariant: take minimum of all bit-rotations
410    let mut min_code = code;
411    let mask = if n_points < 64 {
412        (1u64 << n_points) - 1
413    } else {
414        u64::MAX
415    };
416    let mut rotated = code;
417    for _ in 1..n_points {
418        rotated = ((rotated >> 1) | ((rotated & 1) << (n_points - 1))) & mask;
419        if rotated < min_code {
420            min_code = rotated;
421        }
422    }
423    min_code
424}
425
426/// Segment an image by LBP texture features clustered with k-means.
427///
428/// # Parameters
429/// - `image`      – input grayscale image.
430/// - `radius`     – LBP sampling radius in pixels (typical: 1–3).
431/// - `n_points`   – number of sampling points on the circle (typical: 8).
432/// - `n_clusters` – number of texture classes.
433///
434/// # Returns
435/// Label image of shape `(rows, cols)`.
436///
437/// # Errors
438/// Returns `NdimageError::InvalidInput` for degenerate inputs.
439pub fn lbp_segment(
440    image: &Array2<f64>,
441    radius: f64,
442    n_points: usize,
443    n_clusters: usize,
444) -> NdimageResult<Array2<usize>> {
445    let (rows, cols) = image.dim();
446    if rows == 0 || cols == 0 {
447        return Err(NdimageError::InvalidInput("Image must not be empty".into()));
448    }
449    if n_points == 0 {
450        return Err(NdimageError::InvalidInput(
451            "n_points must be at least 1".into(),
452        ));
453    }
454    if n_clusters == 0 {
455        return Err(NdimageError::InvalidInput(
456            "n_clusters must be at least 1".into(),
457        ));
458    }
459    if radius <= 0.0 {
460        return Err(NdimageError::InvalidInput("radius must be positive".into()));
461    }
462
463    // Compute LBP map
464    let mut data: Vec<Vec<f64>> = Vec::with_capacity(rows * cols);
465    for r in 0..rows {
466        for c in 0..cols {
467            let code = lbp_code_at(image, r, c, radius, n_points);
468            data.push(vec![code as f64]);
469        }
470    }
471
472    let (labels, _) = kmeans(&data, n_clusters, 100)?;
473
474    let mut label_image = Array2::<usize>::zeros((rows, cols));
475    for r in 0..rows {
476        for c in 0..cols {
477            label_image[[r, c]] = labels[r * cols + c];
478        }
479    }
480
481    Ok(label_image)
482}
483
484// ---------------------------------------------------------------------------
485// MRF (Markov Random Field) texture segmentation via ICM
486// ---------------------------------------------------------------------------
487
488/// Segment an image using a Markov Random Field model with Iterated
489/// Conditional Modes (ICM) optimization.
490///
491/// The data term uses pixel intensity; the MRF prior discourages
492/// label discontinuities between neighboring pixels (Potts model).
493///
494/// # Parameters
495/// - `image`      – input grayscale image.
496/// - `n_clusters` – number of texture classes.
497///
498/// # Returns
499/// Label image of shape `(rows, cols)`.
500///
501/// # Errors
502/// Returns `NdimageError::InvalidInput` for degenerate inputs.
503pub fn mrm_segment(image: &Array2<f64>, n_clusters: usize) -> NdimageResult<Array2<usize>> {
504    let (rows, cols) = image.dim();
505    if rows == 0 || cols == 0 {
506        return Err(NdimageError::InvalidInput("Image must not be empty".into()));
507    }
508    if n_clusters == 0 {
509        return Err(NdimageError::InvalidInput(
510            "n_clusters must be at least 1".into(),
511        ));
512    }
513
514    let k = n_clusters.min(rows * cols);
515
516    // Initialise: quantize intensity to k classes uniformly
517    let i_min = image.iter().cloned().fold(f64::INFINITY, f64::min);
518    let i_max = image.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
519    let range = (i_max - i_min).max(1e-12);
520
521    let mut labels = Array2::<usize>::zeros((rows, cols));
522    for r in 0..rows {
523        for c in 0..cols {
524            labels[[r, c]] =
525                (((image[[r, c]] - i_min) / range * k as f64).floor() as usize).min(k - 1);
526        }
527    }
528
529    // Estimate class means
530    let mut means = vec![0.0f64; k];
531    let mut counts = vec![0usize; k];
532    for r in 0..rows {
533        for c in 0..cols {
534            let lbl = labels[[r, c]];
535            means[lbl] += image[[r, c]];
536            counts[lbl] += 1;
537        }
538    }
539    for ki in 0..k {
540        if counts[ki] > 0 {
541            means[ki] /= counts[ki] as f64;
542        }
543    }
544
545    let beta = 0.5; // MRF smoothness weight
546    let max_iter = 20;
547
548    let neighbors: [(i32, i32); 4] = [(-1, 0), (1, 0), (0, -1), (0, 1)];
549
550    for _iter in 0..max_iter {
551        let mut changed = false;
552        let old_labels = labels.clone();
553
554        for r in 0..rows {
555            for c in 0..cols {
556                let pixel = image[[r, c]];
557
558                let best_label = (0..k)
559                    .min_by(|&ka, &kb| {
560                        let ea = mrf_energy(
561                            pixel,
562                            means[ka],
563                            ka,
564                            r,
565                            c,
566                            &old_labels,
567                            &neighbors,
568                            beta,
569                            rows,
570                            cols,
571                        );
572                        let eb = mrf_energy(
573                            pixel,
574                            means[kb],
575                            kb,
576                            r,
577                            c,
578                            &old_labels,
579                            &neighbors,
580                            beta,
581                            rows,
582                            cols,
583                        );
584                        ea.partial_cmp(&eb).unwrap_or(std::cmp::Ordering::Equal)
585                    })
586                    .unwrap_or(0);
587
588                if labels[[r, c]] != best_label {
589                    labels[[r, c]] = best_label;
590                    changed = true;
591                }
592            }
593        }
594
595        // Re-estimate class means
596        means = vec![0.0f64; k];
597        counts = vec![0usize; k];
598        for r in 0..rows {
599            for c in 0..cols {
600                let lbl = labels[[r, c]];
601                means[lbl] += image[[r, c]];
602                counts[lbl] += 1;
603            }
604        }
605        for ki in 0..k {
606            if counts[ki] > 0 {
607                means[ki] /= counts[ki] as f64;
608            }
609        }
610
611        if !changed {
612            break;
613        }
614    }
615
616    Ok(labels)
617}
618
619/// Compute the ICM energy for assigning label `label` to pixel `(r, c)`.
620fn mrf_energy(
621    pixel: f64,
622    mean: f64,
623    label: usize,
624    r: usize,
625    c: usize,
626    labels: &Array2<usize>,
627    neighbors: &[(i32, i32)],
628    beta: f64,
629    rows: usize,
630    cols: usize,
631) -> f64 {
632    // Data term: squared deviation from class mean
633    let data_term = (pixel - mean).powi(2);
634
635    // Prior term: count neighbors with a different label
636    let mut prior_term = 0.0;
637    for &(dr, dc) in neighbors {
638        let nr = r as i64 + dr as i64;
639        let nc = c as i64 + dc as i64;
640        if nr >= 0 && nr < rows as i64 && nc >= 0 && nc < cols as i64 {
641            if labels[[nr as usize, nc as usize]] != label {
642                prior_term += beta;
643            }
644        }
645    }
646
647    data_term + prior_term
648}
649
650// ---------------------------------------------------------------------------
651// Patch-level texture feature extraction
652// ---------------------------------------------------------------------------
653
654/// Extract a texture feature vector for a local patch centred at `(y, x)`.
655///
656/// The feature vector concatenates:
657/// 1. Mean and standard deviation of pixel intensity in the patch.
658/// 2. Gabor magnitudes at 4 orientations and 2 frequencies (16 values).
659/// 3. LBP histogram (8 bins, radius-1 uniform LBP).
660///
661/// Total feature dimensionality: **26** elements.
662///
663/// # Parameters
664/// - `image`      – input grayscale image.
665/// - `y`, `x`     – centre row and column of the patch.
666/// - `patch_size` – patch width/height (must be odd and ≥ 3).
667///
668/// # Errors
669/// Returns `NdimageError::InvalidInput` for an invalid patch size or out-of-bounds centre.
670pub fn texture_features_patch(
671    image: &Array2<f64>,
672    y: usize,
673    x: usize,
674    patch_size: usize,
675) -> NdimageResult<Array1<f64>> {
676    let (rows, cols) = image.dim();
677    if patch_size < 3 || patch_size % 2 == 0 {
678        return Err(NdimageError::InvalidInput(
679            "patch_size must be odd and at least 3".into(),
680        ));
681    }
682    let half = patch_size / 2;
683    if y < half || x < half || y + half >= rows || x + half >= cols {
684        return Err(NdimageError::InvalidInput(
685            "Patch extends outside image boundaries".into(),
686        ));
687    }
688
689    let patch = image.slice(s![y - half..=y + half, x - half..=x + half]);
690
691    // 1. Intensity statistics (2 features)
692    let n = patch.len() as f64;
693    let mean = patch.iter().sum::<f64>() / n;
694    let var = patch.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / n;
695    let std_dev = var.sqrt();
696
697    // 2. Gabor magnitudes at 4 orientations × 4 frequencies (16 features)
698    let freqs = [0.05, 0.1, 0.15, 0.2];
699    let thetas = [0.0, PI / 4.0, PI / 2.0, 3.0 * PI / 4.0];
700    let patch_owned = patch.to_owned();
701    let mut gabor_feats = Vec::with_capacity(16);
702    for &freq in &freqs {
703        for &theta in &thetas {
704            let resp = apply_gabor_kernel(&patch_owned, freq, theta)?;
705            let mag: f64 = resp.iter().sum::<f64>() / resp.len() as f64;
706            gabor_feats.push(mag);
707        }
708    }
709
710    // 3. LBP histogram (8 bins for rotation-invariant LBP, 8 features)
711    let lbp_n_points = 8usize;
712    let lbp_radius = 1.0f64;
713    let n_lbp_bins = lbp_n_points + 2; // uniform LBP: 0..P+1 + "non-uniform"
714    let mut lbp_hist = vec![0.0f64; n_lbp_bins];
715    let ph = patch.nrows();
716    let pw = patch.ncols();
717    let mut lbp_count = 0.0f64;
718    for pr in 0..ph {
719        for pc in 0..pw {
720            let code = lbp_code_at(&patch_owned, pr, pc, lbp_radius, lbp_n_points);
721            let bin = (code as usize).min(n_lbp_bins - 1);
722            lbp_hist[bin] += 1.0;
723            lbp_count += 1.0;
724        }
725    }
726    if lbp_count > 0.0 {
727        for v in lbp_hist.iter_mut() {
728            *v /= lbp_count;
729        }
730    }
731
732    // Assemble feature vector: 2 + 16 + (n_lbp_bins) = 2 + 16 + 10 = 28
733    let mut features = Vec::with_capacity(2 + 16 + n_lbp_bins);
734    features.push(mean);
735    features.push(std_dev);
736    features.extend_from_slice(&gabor_feats);
737    features.extend_from_slice(&lbp_hist);
738
739    Ok(Array1::from_vec(features))
740}
741
742// ---------------------------------------------------------------------------
743// Tests
744// ---------------------------------------------------------------------------
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749    use scirs2_core::ndarray::Array2;
750
751    fn uniform_image(rows: usize, cols: usize, val: f64) -> Array2<f64> {
752        Array2::from_elem((rows, cols), val)
753    }
754
755    fn striped_image(rows: usize, cols: usize) -> Array2<f64> {
756        Array2::from_shape_fn((rows, cols), |(_, c)| if c % 4 < 2 { 1.0 } else { 0.0 })
757    }
758
759    #[test]
760    fn test_gabor_feature_map_shape() {
761        let img = striped_image(16, 16);
762        let freqs = vec![0.1, 0.2];
763        let thetas = vec![0.0, PI / 2.0];
764        let feat = gabor_feature_map(&img, &freqs, &thetas).expect("gabor ok");
765        assert_eq!(feat.dim(), (16, 16, 4));
766    }
767
768    #[test]
769    fn test_gabor_feature_map_uniform_image() {
770        // A uniform image should give near-zero Gabor response
771        let img = uniform_image(12, 12, 0.5);
772        let feat = gabor_feature_map(&img, &[0.15], &[0.0]).expect("gabor ok");
773        for v in feat.iter() {
774            assert!(
775                *v < 0.01,
776                "Uniform image Gabor response should be ~0, got {v}"
777            );
778        }
779    }
780
781    #[test]
782    fn test_texture_segment_kmeans() {
783        let img = striped_image(20, 20);
784        let freqs = vec![0.1, 0.2];
785        let thetas = vec![0.0, PI / 2.0];
786        let labels = texture_segment_kmeans(&img, (&freqs, &thetas), 2).expect("segment ok");
787        assert_eq!(labels.dim(), (20, 20));
788        // All labels should be in [0, 2)
789        for &lbl in labels.iter() {
790            assert!(lbl < 2);
791        }
792    }
793
794    #[test]
795    fn test_lbp_segment() {
796        let img = striped_image(24, 24);
797        let labels = lbp_segment(&img, 1.0, 8, 2).expect("lbp ok");
798        assert_eq!(labels.dim(), (24, 24));
799    }
800
801    #[test]
802    fn test_mrm_segment() {
803        let img = striped_image(16, 16);
804        let labels = mrm_segment(&img, 2).expect("mrm ok");
805        assert_eq!(labels.dim(), (16, 16));
806        for &lbl in labels.iter() {
807            assert!(lbl < 2);
808        }
809    }
810
811    #[test]
812    fn test_texture_features_patch_shape() {
813        let img = striped_image(32, 32);
814        let feats = texture_features_patch(&img, 10, 10, 7).expect("patch ok");
815        // 2 intensity + 16 Gabor + (8+2) LBP = 28
816        assert_eq!(feats.len(), 28);
817    }
818
819    #[test]
820    fn test_texture_features_patch_invalid_patch_size() {
821        let img: Array2<f64> = Array2::zeros((32, 32));
822        let err = texture_features_patch(&img, 10, 10, 4); // even size
823        assert!(err.is_err());
824    }
825
826    #[test]
827    fn test_texture_features_patch_out_of_bounds() {
828        let img: Array2<f64> = Array2::zeros((16, 16));
829        // half = 5; y=2 < half -> out of bounds
830        let err = texture_features_patch(&img, 2, 8, 11);
831        assert!(err.is_err());
832    }
833}