Skip to main content

scirs2_ndimage/
deep_features.rs

1//! CNN-inspired deep image feature extraction (no neural network training required).
2//!
3//! Provides:
4//! - Gabor filter bank for texture analysis
5//! - Simplified SIFT keypoint detection and description
6//! - Histogram of Oriented Gradients (HOG) descriptor
7
8use crate::error::{NdimageError, NdimageResult};
9use std::f64::consts::PI;
10
11// ─── Gabor Filter Bank ───────────────────────────────────────────────────────
12
13/// Gabor filter bank for multi-orientation, multi-frequency texture analysis.
14///
15/// A Gabor filter is a Gaussian kernel modulated by a sinusoidal plane wave.
16/// It provides optimal joint localization in spatial and frequency domains,
17/// making it ideal for texture discrimination.
18#[derive(Debug, Clone)]
19pub struct GaborFilterBank {
20    /// Filter orientations in radians
21    pub orientations: Vec<f64>,
22    /// Spatial frequencies (cycles per pixel)
23    pub frequencies: Vec<f64>,
24    /// Standard deviation of the Gaussian envelope
25    pub sigma: f64,
26    /// Kernel half-size (full kernel is (2*kernel_size+1) × (2*kernel_size+1))
27    pub kernel_size: usize,
28}
29
30impl GaborFilterBank {
31    /// Create a new Gabor filter bank.
32    ///
33    /// # Arguments
34    /// * `n_orientations` – Number of evenly spaced orientations in `[0, π)`.
35    /// * `frequencies`    – Slice of spatial frequencies to include.
36    /// * `sigma`          – Gaussian envelope standard deviation.
37    pub fn new(n_orientations: usize, frequencies: &[f64], sigma: f64) -> Self {
38        let orientations: Vec<f64> = (0..n_orientations)
39            .map(|i| i as f64 * PI / n_orientations as f64)
40            .collect();
41        let kernel_size = (3.0 * sigma).ceil() as usize;
42        GaborFilterBank {
43            orientations,
44            frequencies: frequencies.to_vec(),
45            sigma,
46            kernel_size,
47        }
48    }
49
50    /// Compute a single real-valued Gabor kernel.
51    ///
52    /// Returns a 2-D kernel of size `(2*kernel_size+1) × (2*kernel_size+1)`.
53    pub fn kernel(&self, theta: f64, frequency: f64) -> Vec<Vec<f64>> {
54        let half = self.kernel_size as isize;
55        let side = (2 * self.kernel_size + 1) as usize;
56        let sigma_sq = self.sigma * self.sigma;
57        let cos_t = theta.cos();
58        let sin_t = theta.sin();
59
60        let mut kern = vec![vec![0.0f64; side]; side];
61        for (ri, row) in kern.iter_mut().enumerate() {
62            for (ci, cell) in row.iter_mut().enumerate() {
63                let y = ri as isize - half;
64                let x = ci as isize - half;
65                let xp = x as f64 * cos_t + y as f64 * sin_t;
66                let yp = -x as f64 * sin_t + y as f64 * cos_t;
67                let gaussian = (-(xp * xp + yp * yp) / (2.0 * sigma_sq)).exp();
68                let wave = (2.0 * PI * frequency * xp).cos();
69                *cell = gaussian * wave;
70            }
71        }
72        kern
73    }
74
75    /// Apply all filters in the bank and return feature maps.
76    ///
77    /// Returns a vector of shape `[n_filters][rows][cols]`, where
78    /// `n_filters = orientations.len() * frequencies.len()`.
79    pub fn apply(&self, image: &[Vec<f64>]) -> Vec<Vec<Vec<f64>>> {
80        let rows = image.len();
81        if rows == 0 {
82            return Vec::new();
83        }
84        let cols = image[0].len();
85        let mut feature_maps = Vec::new();
86
87        for &theta in &self.orientations {
88            for &freq in &self.frequencies {
89                let kern = self.kernel(theta, freq);
90                let filtered = convolve2d(image, &kern, rows, cols);
91                feature_maps.push(filtered);
92            }
93        }
94        feature_maps
95    }
96
97    /// Compute energy features: for each filter, compute mean and std of |response|.
98    ///
99    /// Returns a vector of length `2 * n_filters` (mean then std for each filter).
100    pub fn energy_features(&self, image: &[Vec<f64>]) -> Vec<f64> {
101        let maps = self.apply(image);
102        let mut feats = Vec::with_capacity(maps.len() * 2);
103        for map in &maps {
104            let flat: Vec<f64> = map.iter().flat_map(|r| r.iter().map(|v| v.abs())).collect();
105            let n = flat.len() as f64;
106            if n == 0.0 {
107                feats.push(0.0);
108                feats.push(0.0);
109                continue;
110            }
111            let mean = flat.iter().sum::<f64>() / n;
112            let var = flat.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
113            feats.push(mean);
114            feats.push(var.sqrt());
115        }
116        feats
117    }
118}
119
120// ─── SIFT ────────────────────────────────────────────────────────────────────
121
122/// A detected SIFT keypoint with its 128-dimensional descriptor.
123#[derive(Debug, Clone)]
124pub struct SiftKeypoint {
125    /// Sub-pixel column position
126    pub x: f64,
127    /// Sub-pixel row position
128    pub y: f64,
129    /// Characteristic scale (sigma of the detecting Gaussian)
130    pub scale: f64,
131    /// Dominant orientation in radians
132    pub orientation: f64,
133    /// 128-dimensional SIFT descriptor (normalised to unit length)
134    pub descriptor: Vec<f64>,
135}
136
137/// Detect SIFT keypoints in a grayscale image.
138///
139/// Implements the Difference-of-Gaussians (DoG) scale-space extrema detection
140/// pipeline from Lowe (2004), followed by keypoint localisation, orientation
141/// assignment, and 128-D descriptor computation.
142///
143/// # Arguments
144/// * `image`                – 2-D grayscale image (rows × cols).
145/// * `n_octaves`            – Number of octaves in the scale space.
146/// * `n_scales_per_octave`  – Number of DoG levels per octave (typically 3).
147/// * `contrast_threshold`   – Minimum |DoG| response (typical 0.04).
148/// * `edge_threshold`       – Harris-like ratio to reject edge keypoints (typical 10.0).
149pub fn detect_sift_keypoints(
150    image: &[Vec<f64>],
151    n_octaves: usize,
152    n_scales_per_octave: usize,
153    contrast_threshold: f64,
154    edge_threshold: f64,
155) -> Vec<SiftKeypoint> {
156    let rows = image.len();
157    if rows == 0 {
158        return Vec::new();
159    }
160    let cols = image[0].len();
161    if cols == 0 {
162        return Vec::new();
163    }
164
165    let k = 2.0f64.powf(1.0 / n_scales_per_octave as f64);
166    let initial_sigma = 1.6f64;
167    let mut keypoints = Vec::new();
168
169    // Work on the original image (first octave only for simplicity).
170    // Down-sample by 2× for each successive octave.
171    let mut oct_image: Vec<Vec<f64>> = image.to_vec();
172
173    for _oct in 0..n_octaves {
174        let oct_rows = oct_image.len();
175        let oct_cols = if oct_rows > 0 { oct_image[0].len() } else { 0 };
176        if oct_rows < 4 || oct_cols < 4 {
177            break;
178        }
179
180        // Build Gaussian scale-space for this octave
181        let n_gauss = n_scales_per_octave + 3;
182        let mut gauss_stack: Vec<Vec<Vec<f64>>> = Vec::with_capacity(n_gauss);
183        gauss_stack.push(oct_image.clone());
184        for s in 1..n_gauss {
185            let sigma = initial_sigma * k.powi(s as i32);
186            let blurred = gaussian_blur(&gauss_stack[s - 1], sigma);
187            gauss_stack.push(blurred);
188        }
189
190        // Difference-of-Gaussians
191        let mut dog_stack: Vec<Vec<Vec<f64>>> = Vec::with_capacity(n_gauss - 1);
192        for s in 0..(n_gauss - 1) {
193            let dog = subtract_images(&gauss_stack[s + 1], &gauss_stack[s]);
194            dog_stack.push(dog);
195        }
196
197        // Find extrema in DoG stack
198        let n_dog = dog_stack.len();
199        if n_dog < 3 {
200            break;
201        }
202        for s in 1..(n_dog - 1) {
203            for r in 1..(oct_rows.saturating_sub(1)) {
204                for c in 1..(oct_cols.saturating_sub(1)) {
205                    let val = dog_stack[s][r][c];
206                    if val.abs() < contrast_threshold {
207                        continue;
208                    }
209                    if !is_extremum(&dog_stack, s, r, c) {
210                        continue;
211                    }
212                    // Edge rejection via principal curvature ratio
213                    if is_edge_point(&dog_stack[s], r, c, edge_threshold) {
214                        continue;
215                    }
216                    // Dominant orientation
217                    let sigma = initial_sigma * k.powi(s as i32);
218                    let orient = dominant_orientation(&gauss_stack[s], r, c, sigma);
219                    let kp = SiftKeypoint {
220                        x: c as f64,
221                        y: r as f64,
222                        scale: sigma,
223                        orientation: orient,
224                        descriptor: Vec::new(), // filled later
225                    };
226                    keypoints.push(kp);
227                }
228            }
229        }
230
231        // Down-sample for next octave
232        oct_image = downsample2x(&oct_image);
233    }
234
235    keypoints
236}
237
238/// Compute 128-dimensional SIFT descriptors for the provided keypoints.
239///
240/// Each descriptor is computed from a 16×16 pixel patch around the keypoint,
241/// divided into a 4×4 grid of 8-bin gradient-orientation histograms.
242pub fn compute_sift_descriptor(image: &[Vec<f64>], keypoints: &[SiftKeypoint]) -> Vec<Vec<f64>> {
243    let rows = image.len();
244    if rows == 0 {
245        return Vec::new();
246    }
247    let cols = image[0].len();
248    let (grad_mag, grad_ori) = gradient_images(image);
249
250    keypoints
251        .iter()
252        .map(|kp| {
253            let cx = kp.x as isize;
254            let cy = kp.y as isize;
255            let patch_half = 8isize;
256            let n_bins = 8usize;
257            // 4×4 spatial cells × 8 orientation bins = 128
258            let n_cells = 4usize;
259            let cell_size = (patch_half * 2 / n_cells as isize).max(1);
260            let mut desc = vec![0.0f64; 128];
261
262            for pr in 0..(patch_half * 2) {
263                for pc in 0..(patch_half * 2) {
264                    let r = (cy - patch_half + pr).max(0).min(rows as isize - 1) as usize;
265                    let c = (cx - patch_half + pc).max(0).min(cols as isize - 1) as usize;
266                    let mag = grad_mag[r][c];
267                    let ori = (grad_ori[r][c] - kp.orientation).rem_euclid(2.0 * PI);
268                    let cell_r = (pr / cell_size).min((n_cells - 1) as isize) as usize;
269                    let cell_c = (pc / cell_size).min((n_cells - 1) as isize) as usize;
270                    let bin = ((ori / (2.0 * PI)) * n_bins as f64) as usize % n_bins;
271                    let idx = cell_r * n_cells * n_bins + cell_c * n_bins + bin;
272                    if idx < 128 {
273                        desc[idx] += mag;
274                    }
275                }
276            }
277            // L2 normalise, clamp at 0.2, re-normalise
278            let norm = desc.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-12);
279            desc.iter_mut().for_each(|v| *v = (*v / norm).min(0.2));
280            let norm2 = desc.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-12);
281            desc.iter_mut().for_each(|v| *v /= norm2);
282            desc
283        })
284        .collect()
285}
286
287// ─── HOG descriptor ──────────────────────────────────────────────────────────
288
289/// Histogram of Oriented Gradients (HOG) descriptor.
290///
291/// Divides the image into `cells_per_block`-normalized blocks of cells,
292/// where each cell contains an `orientations`-bin gradient orientation histogram.
293#[derive(Debug, Clone)]
294pub struct HogDescriptor {
295    /// Number of orientation bins (typically 9)
296    pub orientations: usize,
297    /// Pixels per cell (height, width)
298    pub pixels_per_cell: (usize, usize),
299    /// Cells per block (height, width) for L2-Hys normalisation
300    pub cells_per_block: (usize, usize),
301}
302
303impl HogDescriptor {
304    /// Create a new HOG descriptor configuration.
305    pub fn new(
306        orientations: usize,
307        pixels_per_cell: (usize, usize),
308        cells_per_block: (usize, usize),
309    ) -> Self {
310        HogDescriptor {
311            orientations,
312            pixels_per_cell,
313            cells_per_block,
314        }
315    }
316
317    /// Compute the HOG descriptor vector for a grayscale image.
318    pub fn compute(&self, image: &[Vec<f64>]) -> Vec<f64> {
319        let rows = image.len();
320        if rows == 0 {
321            return Vec::new();
322        }
323        let cols = image[0].len();
324        let (ph, pw) = self.pixels_per_cell;
325        let (bh, bw) = self.cells_per_block;
326        let n_bins = self.orientations;
327
328        let n_cells_y = rows / ph;
329        let n_cells_x = cols / pw;
330        if n_cells_y == 0 || n_cells_x == 0 {
331            return Vec::new();
332        }
333
334        // Build per-cell orientation histograms (unsigned: 0..PI)
335        let mut cell_hists = vec![vec![vec![0.0f64; n_bins]; n_cells_x]; n_cells_y];
336        let (grad_mag, grad_ori) = gradient_images(image);
337
338        for ry in 0..rows {
339            for cx in 0..cols {
340                let cy_idx = ry / ph;
341                let cx_idx = cx / pw;
342                if cy_idx >= n_cells_y || cx_idx >= n_cells_x {
343                    continue;
344                }
345                let mag = grad_mag[ry][cx];
346                // Unsigned orientation: fold into [0, π)
347                let ori = grad_ori[ry][cx].rem_euclid(PI);
348                let bin = ((ori / PI) * n_bins as f64) as usize % n_bins;
349                cell_hists[cy_idx][cx_idx][bin] += mag;
350            }
351        }
352
353        // Block normalisation (L2-Hys)
354        let n_blocks_y = n_cells_y.saturating_sub(bh - 1);
355        let n_blocks_x = n_cells_x.saturating_sub(bw - 1);
356        let block_len = bh * bw * n_bins;
357        let mut descriptor = Vec::with_capacity(n_blocks_y * n_blocks_x * block_len);
358
359        for by in 0..n_blocks_y {
360            for bx in 0..n_blocks_x {
361                let mut block = Vec::with_capacity(block_len);
362                for dy in 0..bh {
363                    for dx in 0..bw {
364                        for &v in &cell_hists[by + dy][bx + dx] {
365                            block.push(v);
366                        }
367                    }
368                }
369                // L2 normalise
370                let norm = block.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
371                block.iter_mut().for_each(|v| *v /= norm);
372                // Clamp at 0.2
373                block.iter_mut().for_each(|v| *v = v.min(0.2));
374                // Re-normalise
375                let norm2 = block.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
376                block.iter_mut().for_each(|v| *v /= norm2);
377                descriptor.extend(block);
378            }
379        }
380        descriptor
381    }
382
383    /// Return the expected descriptor length for an image of the given shape.
384    pub fn feature_size(&self, image_shape: (usize, usize)) -> usize {
385        let (rows, cols) = image_shape;
386        let (ph, pw) = self.pixels_per_cell;
387        let (bh, bw) = self.cells_per_block;
388        let n_cells_y = rows / ph;
389        let n_cells_x = cols / pw;
390        let n_blocks_y = n_cells_y.saturating_sub(bh - 1);
391        let n_blocks_x = n_cells_x.saturating_sub(bw - 1);
392        n_blocks_y * n_blocks_x * bh * bw * self.orientations
393    }
394}
395
396// ─── Internal helpers ─────────────────────────────────────────────────────────
397
398/// Compute gradient magnitude and orientation images.
399fn gradient_images(image: &[Vec<f64>]) -> (Vec<Vec<f64>>, Vec<Vec<f64>>) {
400    let rows = image.len();
401    let cols = if rows > 0 { image[0].len() } else { 0 };
402    let mut mag = vec![vec![0.0f64; cols]; rows];
403    let mut ori = vec![vec![0.0f64; cols]; rows];
404    for r in 0..rows {
405        for c in 0..cols {
406            let dx = if c + 1 < cols {
407                image[r][c + 1]
408            } else {
409                image[r][c]
410            } - if c > 0 { image[r][c - 1] } else { image[r][c] };
411            let dy = if r + 1 < rows {
412                image[r + 1][c]
413            } else {
414                image[r][c]
415            } - if r > 0 { image[r - 1][c] } else { image[r][c] };
416            mag[r][c] = (dx * dx + dy * dy).sqrt();
417            ori[r][c] = dy.atan2(dx);
418        }
419    }
420    (mag, ori)
421}
422
423/// Apply 2-D convolution with zero-padding.
424fn convolve2d(image: &[Vec<f64>], kernel: &[Vec<f64>], rows: usize, cols: usize) -> Vec<Vec<f64>> {
425    let kh = kernel.len();
426    let kw = if kh > 0 { kernel[0].len() } else { 0 };
427    let khr = (kh / 2) as isize;
428    let kwr = (kw / 2) as isize;
429    let mut out = vec![vec![0.0f64; cols]; rows];
430    for r in 0..rows {
431        for c in 0..cols {
432            let mut acc = 0.0f64;
433            for kr in 0..kh {
434                for kc in 0..kw {
435                    let nr = r as isize + kr as isize - khr;
436                    let nc = c as isize + kc as isize - kwr;
437                    if nr >= 0 && nc >= 0 && (nr as usize) < rows && (nc as usize) < cols {
438                        acc += image[nr as usize][nc as usize] * kernel[kr][kc];
439                    }
440                }
441            }
442            out[r][c] = acc;
443        }
444    }
445    out
446}
447
448/// Gaussian blur with a separable kernel approximation.
449fn gaussian_blur(image: &[Vec<f64>], sigma: f64) -> Vec<Vec<f64>> {
450    let radius = (3.0 * sigma).ceil() as usize;
451    let side = 2 * radius + 1;
452    let mut k1d = vec![0.0f64; side];
453    for i in 0..side {
454        let x = i as f64 - radius as f64;
455        k1d[i] = (-x * x / (2.0 * sigma * sigma)).exp();
456    }
457    let sum: f64 = k1d.iter().sum();
458    k1d.iter_mut().for_each(|v| *v /= sum);
459
460    let rows = image.len();
461    if rows == 0 {
462        return Vec::new();
463    }
464    let cols = image[0].len();
465    // Horizontal pass
466    let mut tmp = vec![vec![0.0f64; cols]; rows];
467    for r in 0..rows {
468        for c in 0..cols {
469            let mut acc = 0.0f64;
470            for (ki, &kv) in k1d.iter().enumerate() {
471                let nc = c as isize + ki as isize - radius as isize;
472                let nc = nc.max(0).min(cols as isize - 1) as usize;
473                acc += image[r][nc] * kv;
474            }
475            tmp[r][c] = acc;
476        }
477    }
478    // Vertical pass
479    let mut out = vec![vec![0.0f64; cols]; rows];
480    for r in 0..rows {
481        for c in 0..cols {
482            let mut acc = 0.0f64;
483            for (ki, &kv) in k1d.iter().enumerate() {
484                let nr = r as isize + ki as isize - radius as isize;
485                let nr = nr.max(0).min(rows as isize - 1) as usize;
486                acc += tmp[nr][c] * kv;
487            }
488            out[r][c] = acc;
489        }
490    }
491    out
492}
493
494/// Pixel-wise subtraction of two same-shape images.
495fn subtract_images(a: &[Vec<f64>], b: &[Vec<f64>]) -> Vec<Vec<f64>> {
496    a.iter()
497        .zip(b.iter())
498        .map(|(ra, rb)| ra.iter().zip(rb.iter()).map(|(va, vb)| va - vb).collect())
499        .collect()
500}
501
502/// Down-sample image by 2× (take every other pixel).
503fn downsample2x(image: &[Vec<f64>]) -> Vec<Vec<f64>> {
504    let rows = image.len();
505    let cols = if rows > 0 { image[0].len() } else { 0 };
506    let new_rows = (rows + 1) / 2;
507    let new_cols = (cols + 1) / 2;
508    let mut out = vec![vec![0.0f64; new_cols]; new_rows];
509    for r in 0..new_rows {
510        for c in 0..new_cols {
511            out[r][c] = image[2 * r][2 * c];
512        }
513    }
514    out
515}
516
517/// Check whether `(s, r, c)` is a local extremum in 3-D DoG stack.
518fn is_extremum(dog: &[Vec<Vec<f64>>], s: usize, r: usize, c: usize) -> bool {
519    let val = dog[s][r][c];
520    let is_max = val > 0.0;
521    for ds in 0..3usize {
522        let ss = s + ds - 1;
523        if ss >= dog.len() {
524            continue;
525        }
526        for dr in 0..3usize {
527            let rr = r + dr - 1;
528            if rr >= dog[ss].len() {
529                continue;
530            }
531            for dc in 0..3usize {
532                let cc = c + dc - 1;
533                if cc >= dog[ss][rr].len() {
534                    continue;
535                }
536                if ds == 1 && dr == 1 && dc == 1 {
537                    continue;
538                }
539                let v = dog[ss][rr][cc];
540                if is_max && v >= val {
541                    return false;
542                }
543                if !is_max && v <= val {
544                    return false;
545                }
546            }
547        }
548    }
549    true
550}
551
552/// Harris-ratio edge rejection test.
553fn is_edge_point(dog: &[Vec<f64>], r: usize, c: usize, threshold: f64) -> bool {
554    let rows = dog.len();
555    let cols = if rows > 0 { dog[0].len() } else { 0 };
556    if r == 0 || c == 0 || r + 1 >= rows || c + 1 >= cols {
557        return false;
558    }
559    let dxx = dog[r][c + 1] + dog[r][c - 1] - 2.0 * dog[r][c];
560    let dyy = dog[r + 1][c] + dog[r - 1][c] - 2.0 * dog[r][c];
561    let dxy = (dog[r + 1][c + 1] + dog[r - 1][c - 1] - dog[r + 1][c - 1] - dog[r - 1][c + 1]) / 4.0;
562    let trace = dxx + dyy;
563    let det = dxx * dyy - dxy * dxy;
564    if det <= 0.0 {
565        return true;
566    }
567    let ratio = trace * trace / det;
568    let r_thresh = (threshold + 1.0).powi(2) / threshold;
569    ratio >= r_thresh
570}
571
572/// Compute dominant gradient orientation at `(r, c)` using a circular histogram.
573fn dominant_orientation(image: &[Vec<f64>], r: usize, c: usize, sigma: f64) -> f64 {
574    let rows = image.len();
575    let cols = if rows > 0 { image[0].len() } else { 0 };
576    let radius = (1.5 * sigma).ceil() as isize;
577    let mut hist = vec![0.0f64; 36];
578    for dr in -radius..=radius {
579        for dc in -radius..=radius {
580            let nr = (r as isize + dr).max(0).min(rows as isize - 1) as usize;
581            let nc = (c as isize + dc).max(0).min(cols as isize - 1) as usize;
582            let dy = if nr + 1 < rows {
583                image[nr + 1][nc]
584            } else {
585                image[nr][nc]
586            } - if nr > 0 {
587                image[nr - 1][nc]
588            } else {
589                image[nr][nc]
590            };
591            let dx = if nc + 1 < cols {
592                image[nr][nc + 1]
593            } else {
594                image[nr][nc]
595            } - if nc > 0 {
596                image[nr][nc - 1]
597            } else {
598                image[nr][nc]
599            };
600            let mag = (dx * dx + dy * dy).sqrt();
601            let ori = dy.atan2(dx).rem_euclid(2.0 * PI);
602            let dist_sq = (dr * dr + dc * dc) as f64;
603            let weight = (-dist_sq / (2.0 * (1.5 * sigma).powi(2))).exp();
604            let bin = ((ori / (2.0 * PI)) * 36.0) as usize % 36;
605            hist[bin] += weight * mag;
606        }
607    }
608    let best_bin = hist
609        .iter()
610        .enumerate()
611        .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
612        .map(|(i, _)| i)
613        .unwrap_or(0);
614    best_bin as f64 * 2.0 * PI / 36.0
615}
616
617// ─── Public error-returning wrappers ─────────────────────────────────────────
618
619/// Compute HOG descriptor with input validation.
620pub fn compute_hog(
621    image: &[Vec<f64>],
622    orientations: usize,
623    pixels_per_cell: (usize, usize),
624    cells_per_block: (usize, usize),
625) -> NdimageResult<Vec<f64>> {
626    if image.is_empty() {
627        return Err(NdimageError::InvalidInput("Image must not be empty".into()));
628    }
629    if orientations == 0 {
630        return Err(NdimageError::InvalidInput(
631            "orientations must be at least 1".into(),
632        ));
633    }
634    if pixels_per_cell.0 == 0 || pixels_per_cell.1 == 0 {
635        return Err(NdimageError::InvalidInput(
636            "pixels_per_cell dimensions must be at least 1".into(),
637        ));
638    }
639    if cells_per_block.0 == 0 || cells_per_block.1 == 0 {
640        return Err(NdimageError::InvalidInput(
641            "cells_per_block dimensions must be at least 1".into(),
642        ));
643    }
644    let hog = HogDescriptor::new(orientations, pixels_per_cell, cells_per_block);
645    Ok(hog.compute(image))
646}
647
648// ─── Tests ────────────────────────────────────────────────────────────────────
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    fn make_checkerboard(rows: usize, cols: usize) -> Vec<Vec<f64>> {
655        (0..rows)
656            .map(|r| {
657                (0..cols)
658                    .map(|c| if (r / 4 + c / 4) % 2 == 0 { 1.0 } else { 0.0 })
659                    .collect()
660            })
661            .collect()
662    }
663
664    #[test]
665    fn test_gabor_bank_construction() {
666        let bank = GaborFilterBank::new(4, &[0.1, 0.2], 1.0);
667        assert_eq!(bank.orientations.len(), 4);
668        assert_eq!(bank.frequencies.len(), 2);
669        let kern = bank.kernel(0.0, 0.1);
670        assert!(!kern.is_empty());
671    }
672
673    #[test]
674    fn test_gabor_apply_shape() {
675        let bank = GaborFilterBank::new(2, &[0.1], 1.0);
676        let img = make_checkerboard(32, 32);
677        let maps = bank.apply(&img);
678        assert_eq!(maps.len(), 2); // 2 orientations × 1 frequency
679        assert_eq!(maps[0].len(), 32);
680        assert_eq!(maps[0][0].len(), 32);
681    }
682
683    #[test]
684    fn test_gabor_energy_features_length() {
685        let bank = GaborFilterBank::new(3, &[0.1, 0.2], 1.5);
686        let img = make_checkerboard(32, 32);
687        let feats = bank.energy_features(&img);
688        // 3 orientations × 2 frequencies × 2 (mean+std)
689        assert_eq!(feats.len(), 12);
690    }
691
692    #[test]
693    fn test_sift_detection_runs() {
694        let img = make_checkerboard(64, 64);
695        let kps = detect_sift_keypoints(&img, 2, 3, 0.03, 10.0);
696        // Just check it doesn't panic; result count depends on image content
697        let _ = kps.len();
698    }
699
700    #[test]
701    fn test_sift_descriptor_shape() {
702        let img = make_checkerboard(64, 64);
703        let kps = detect_sift_keypoints(&img, 2, 3, 0.01, 10.0);
704        let descs = compute_sift_descriptor(&img, &kps);
705        assert_eq!(descs.len(), kps.len());
706        for d in &descs {
707            assert_eq!(d.len(), 128);
708        }
709    }
710
711    #[test]
712    fn test_hog_feature_size() {
713        let hog = HogDescriptor::new(9, (8, 8), (2, 2));
714        let img = make_checkerboard(64, 64);
715        let desc = hog.compute(&img);
716        let expected = hog.feature_size((64, 64));
717        assert_eq!(desc.len(), expected);
718        assert!(!desc.is_empty());
719    }
720
721    #[test]
722    fn test_hog_compute_hog_wrapper() {
723        let img = make_checkerboard(32, 32);
724        let result = compute_hog(&img, 9, (8, 8), (2, 2));
725        assert!(result.is_ok());
726    }
727
728    #[test]
729    fn test_hog_invalid_inputs() {
730        let img: Vec<Vec<f64>> = Vec::new();
731        assert!(compute_hog(&img, 9, (8, 8), (2, 2)).is_err());
732        let img2 = make_checkerboard(32, 32);
733        assert!(compute_hog(&img2, 0, (8, 8), (2, 2)).is_err());
734        assert!(compute_hog(&img2, 9, (0, 8), (2, 2)).is_err());
735    }
736}