Skip to main content

scirs2_vision/
object_detection.rs

1//! # Generic Object Detection Utilities
2//!
3//! This module provides fundamental building blocks for object detection pipelines,
4//! including bounding box representations, Non-Maximum Suppression (NMS) algorithms,
5//! sliding window generation, anchor box creation, and IoU computation.
6//!
7//! ## Features
8//!
9//! - **`BoundingBox`**: float-coordinate box with score and class_id
10//! - **`nms()`**: Standard greedy Non-Maximum Suppression
11//! - **`soft_nms()`**: Soft-NMS with Gaussian or linear score decay
12//! - **`sliding_window()`**: sliding window iterator with stride and scale
13//! - **`anchor_boxes()`**: grid-based anchor box generation for SSD/YOLO style
14//! - **`compute_iou()`**: Intersection over Union between two boxes
15//!
16//! ## Example
17//!
18//! ```rust
19//! use scirs2_vision::object_detection::{BoundingBox, nms, compute_iou};
20//!
21//! let b1 = BoundingBox::new(0.0, 0.0, 10.0, 10.0, 0.9, 0);
22//! let b2 = BoundingBox::new(1.0, 1.0, 11.0, 11.0, 0.7, 0);
23//! let b3 = BoundingBox::new(50.0, 50.0, 60.0, 60.0, 0.85, 1);
24//!
25//! let kept = nms(&[b1, b2, b3], 0.5);
26//! assert_eq!(kept.len(), 2);
27//! ```
28
29use crate::error::{Result, VisionError};
30
31// ---------------------------------------------------------------------------
32// BoundingBox
33// ---------------------------------------------------------------------------
34
35/// Floating-point bounding box for detection pipelines.
36///
37/// Coordinates use (x1, y1) = top-left corner and (x2, y2) = bottom-right
38/// corner in pixel space.  `score` is the detection confidence ∈ [0, 1]
39/// and `class_id` is a zero-indexed class label.
40#[derive(Clone, Debug, PartialEq)]
41pub struct BoundingBox {
42    /// Top-left x coordinate (inclusive)
43    pub x1: f64,
44    /// Top-left y coordinate (inclusive)
45    pub y1: f64,
46    /// Bottom-right x coordinate (exclusive)
47    pub x2: f64,
48    /// Bottom-right y coordinate (exclusive)
49    pub y2: f64,
50    /// Detection confidence score ∈ [0, 1]
51    pub score: f64,
52    /// Class identifier (0-indexed)
53    pub class_id: usize,
54}
55
56impl BoundingBox {
57    /// Create a new `BoundingBox`, normalising corners so x1 ≤ x2 and y1 ≤ y2.
58    ///
59    /// # Arguments
60    /// * `x1`, `y1` – top-left pixel coordinate
61    /// * `x2`, `y2` – bottom-right pixel coordinate
62    /// * `score`    – confidence score
63    /// * `class_id` – class index
64    pub fn new(x1: f64, y1: f64, x2: f64, y2: f64, score: f64, class_id: usize) -> Self {
65        Self {
66            x1: x1.min(x2),
67            y1: y1.min(y2),
68            x2: x1.max(x2),
69            y2: y1.max(y2),
70            score,
71            class_id,
72        }
73    }
74
75    /// Create a box from centre coordinates and width/height.
76    pub fn from_center(cx: f64, cy: f64, w: f64, h: f64, score: f64, class_id: usize) -> Self {
77        let hw = w.abs() * 0.5;
78        let hh = h.abs() * 0.5;
79        Self::new(cx - hw, cy - hh, cx + hw, cy + hh, score, class_id)
80    }
81
82    /// Width in pixels.
83    #[inline]
84    pub fn width(&self) -> f64 {
85        (self.x2 - self.x1).max(0.0)
86    }
87
88    /// Height in pixels.
89    #[inline]
90    pub fn height(&self) -> f64 {
91        (self.y2 - self.y1).max(0.0)
92    }
93
94    /// Area in pixels².
95    #[inline]
96    pub fn area(&self) -> f64 {
97        self.width() * self.height()
98    }
99
100    /// Centre coordinates (cx, cy).
101    #[inline]
102    pub fn center(&self) -> (f64, f64) {
103        ((self.x1 + self.x2) * 0.5, (self.y1 + self.y2) * 0.5)
104    }
105
106    /// Expand the box by a factor around its centre.
107    ///
108    /// A factor of 1.0 returns the original box; 1.2 expands 20% in each direction.
109    pub fn scale(&self, factor: f64) -> Self {
110        let (cx, cy) = self.center();
111        let hw = self.width() * 0.5 * factor;
112        let hh = self.height() * 0.5 * factor;
113        Self::new(
114            cx - hw,
115            cy - hh,
116            cx + hw,
117            cy + hh,
118            self.score,
119            self.class_id,
120        )
121    }
122
123    /// Clip the box to an image boundary `(width, height)`.
124    pub fn clip(&self, img_w: f64, img_h: f64) -> Self {
125        Self::new(
126            self.x1.max(0.0),
127            self.y1.max(0.0),
128            self.x2.min(img_w),
129            self.y2.min(img_h),
130            self.score,
131            self.class_id,
132        )
133    }
134}
135
136// ---------------------------------------------------------------------------
137// IoU
138// ---------------------------------------------------------------------------
139
140/// Compute Intersection over Union (IoU) between two bounding boxes.
141///
142/// Returns 0.0 when the union is zero (e.g. both boxes have zero area).
143///
144/// # Example
145/// ```rust
146/// use scirs2_vision::object_detection::{BoundingBox, compute_iou};
147/// let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0, 1.0, 0);
148/// let b = BoundingBox::new(5.0, 5.0, 15.0, 15.0, 1.0, 0);
149/// let iou = compute_iou(&a, &b);
150/// assert!((iou - 25.0 / 175.0).abs() < 1e-10);
151/// ```
152pub fn compute_iou(a: &BoundingBox, b: &BoundingBox) -> f64 {
153    let ix1 = a.x1.max(b.x1);
154    let iy1 = a.y1.max(b.y1);
155    let ix2 = a.x2.min(b.x2);
156    let iy2 = a.y2.min(b.y2);
157
158    let inter_w = (ix2 - ix1).max(0.0);
159    let inter_h = (iy2 - iy1).max(0.0);
160    let inter = inter_w * inter_h;
161
162    let union = a.area() + b.area() - inter;
163    if union < 1e-12 {
164        0.0
165    } else {
166        inter / union
167    }
168}
169
170// ---------------------------------------------------------------------------
171// NMS
172// ---------------------------------------------------------------------------
173
174/// Standard greedy Non-Maximum Suppression.
175///
176/// Boxes are sorted by descending score. A box is suppressed if its IoU with
177/// any previously kept box of the same class exceeds `iou_threshold`.
178///
179/// # Arguments
180/// * `boxes`         – candidate boxes (will be cloned and sorted)
181/// * `iou_threshold` – suppress if IoU > this value (typical: 0.4 – 0.5)
182///
183/// # Returns
184/// Kept boxes in descending score order.
185pub fn nms(boxes: &[BoundingBox], iou_threshold: f64) -> Vec<BoundingBox> {
186    if boxes.is_empty() {
187        return Vec::new();
188    }
189
190    // Sort by descending score
191    let mut sorted: Vec<&BoundingBox> = boxes.iter().collect();
192    sorted.sort_by(|a, b| {
193        b.score
194            .partial_cmp(&a.score)
195            .unwrap_or(std::cmp::Ordering::Equal)
196    });
197
198    let n = sorted.len();
199    let mut suppressed = vec![false; n];
200    let mut kept = Vec::new();
201
202    for i in 0..n {
203        if suppressed[i] {
204            continue;
205        }
206        kept.push(sorted[i].clone());
207        for j in (i + 1)..n {
208            if suppressed[j] {
209                continue;
210            }
211            // Only suppress same-class boxes
212            if sorted[i].class_id == sorted[j].class_id
213                && compute_iou(sorted[i], sorted[j]) > iou_threshold
214            {
215                suppressed[j] = true;
216            }
217        }
218    }
219    kept
220}
221
222// ---------------------------------------------------------------------------
223// Soft-NMS
224// ---------------------------------------------------------------------------
225
226/// Score decay mode for [`soft_nms`].
227#[derive(Clone, Debug, Copy, PartialEq)]
228pub enum SoftNmsMethod {
229    /// Linear decay: score ← score × (1 − IoU)
230    Linear,
231    /// Gaussian decay: score ← score × exp(−IoU² / σ²)
232    Gaussian {
233        /// Gaussian bandwidth (typical: 0.5)
234        sigma: f64,
235    },
236}
237
238/// Soft Non-Maximum Suppression with score decay rather than hard elimination.
239///
240/// Unlike standard NMS, overlapping detections are not removed but their scores
241/// are reduced proportionally to the overlap. Boxes with a final score below
242/// `score_threshold` are discarded.
243///
244/// # Arguments
245/// * `boxes`           – candidate boxes (cloned internally)
246/// * `iou_threshold`   – IoU at which score decay kicks in
247/// * `score_threshold` – minimum final score to keep a box
248/// * `method`          – decay function ([`SoftNmsMethod::Linear`] or [`SoftNmsMethod::Gaussian`])
249///
250/// # Returns
251/// Kept boxes sorted by final (decayed) score descending.
252pub fn soft_nms(
253    boxes: &[BoundingBox],
254    iou_threshold: f64,
255    score_threshold: f64,
256    method: SoftNmsMethod,
257) -> Vec<BoundingBox> {
258    if boxes.is_empty() {
259        return Vec::new();
260    }
261
262    let mut candidates: Vec<BoundingBox> = boxes.to_vec();
263    let mut kept: Vec<BoundingBox> = Vec::with_capacity(candidates.len());
264
265    while !candidates.is_empty() {
266        // Find the candidate with the highest score
267        let best_idx = candidates
268            .iter()
269            .enumerate()
270            .max_by(|(_, a), (_, b)| {
271                a.score
272                    .partial_cmp(&b.score)
273                    .unwrap_or(std::cmp::Ordering::Equal)
274            })
275            .map(|(i, _)| i)
276            .unwrap_or(0);
277
278        let best = candidates.swap_remove(best_idx);
279
280        // Decay scores of remaining boxes
281        for candidate in candidates.iter_mut() {
282            let iou = compute_iou(&best, candidate);
283            if iou > iou_threshold {
284                match method {
285                    SoftNmsMethod::Linear => {
286                        candidate.score *= 1.0 - iou;
287                    }
288                    SoftNmsMethod::Gaussian { sigma } => {
289                        candidate.score *= (-iou * iou / (sigma * sigma)).exp();
290                    }
291                }
292            }
293        }
294
295        kept.push(best);
296        // Remove boxes with decayed score below threshold
297        candidates.retain(|b| b.score >= score_threshold);
298    }
299
300    // Sort result by descending score
301    kept.sort_by(|a, b| {
302        b.score
303            .partial_cmp(&a.score)
304            .unwrap_or(std::cmp::Ordering::Equal)
305    });
306    kept
307}
308
309// ---------------------------------------------------------------------------
310// Sliding window
311// ---------------------------------------------------------------------------
312
313/// A single sliding window entry: pixel position and size.
314#[derive(Clone, Debug, PartialEq)]
315pub struct WindowSpec {
316    /// Top-left x pixel
317    pub x: usize,
318    /// Top-left y pixel
319    pub y: usize,
320    /// Window width in pixels
321    pub width: usize,
322    /// Window height in pixels
323    pub height: usize,
324    /// Scale factor relative to the base window size
325    pub scale: f64,
326}
327
328/// Generate sliding window positions over an image at multiple scales.
329///
330/// Returns a list of [`WindowSpec`] entries covering the image from top-left
331/// to bottom-right, stepping by `stride` pixels at each scale level.  Each
332/// successive scale multiplies the window by `scale_factor`.
333///
334/// # Arguments
335/// * `img_width`    – image width in pixels
336/// * `img_height`   – image height in pixels
337/// * `win_width`    – base window width
338/// * `win_height`   – base window height
339/// * `stride`       – step size (pixels at the *base* scale)
340/// * `scale_factor` – multiplicative scale step (e.g. 1.25 → 25% larger each step)
341/// * `min_size`     – minimum window size; stop scaling down past this
342/// * `num_scales`   – maximum number of scale levels to generate
343///
344/// # Errors
345/// Returns [`VisionError::InvalidInput`] if `win_width` or `win_height` is 0.
346pub fn sliding_window(
347    img_width: usize,
348    img_height: usize,
349    win_width: usize,
350    win_height: usize,
351    stride: usize,
352    scale_factor: f64,
353    num_scales: usize,
354) -> Result<Vec<WindowSpec>> {
355    if win_width == 0 || win_height == 0 {
356        return Err(VisionError::InvalidInput(
357            "sliding_window: window dimensions must be > 0".to_string(),
358        ));
359    }
360    if stride == 0 {
361        return Err(VisionError::InvalidInput(
362            "sliding_window: stride must be > 0".to_string(),
363        ));
364    }
365    if scale_factor <= 0.0 {
366        return Err(VisionError::InvalidInput(
367            "sliding_window: scale_factor must be positive".to_string(),
368        ));
369    }
370
371    let mut windows = Vec::new();
372
373    for scale_idx in 0..num_scales {
374        let scale = scale_factor.powi(scale_idx as i32);
375        let w = ((win_width as f64) * scale).round() as usize;
376        let h = ((win_height as f64) * scale).round() as usize;
377
378        if w == 0 || h == 0 || w > img_width || h > img_height {
379            // Skip degenerate or oversized windows
380            continue;
381        }
382
383        let step = ((stride as f64) * scale).round().max(1.0) as usize;
384
385        let mut y = 0usize;
386        while y + h <= img_height {
387            let mut x = 0usize;
388            while x + w <= img_width {
389                windows.push(WindowSpec {
390                    x,
391                    y,
392                    width: w,
393                    height: h,
394                    scale,
395                });
396                x += step;
397            }
398            y += step;
399        }
400    }
401
402    Ok(windows)
403}
404
405// ---------------------------------------------------------------------------
406// Anchor boxes
407// ---------------------------------------------------------------------------
408
409/// Configuration for grid-based anchor box generation.
410#[derive(Clone, Debug)]
411pub struct AnchorConfig {
412    /// Base anchor sizes (in pixels at scale 1.0)
413    pub base_sizes: Vec<f64>,
414    /// Aspect ratios width/height (e.g. [0.5, 1.0, 2.0])
415    pub aspect_ratios: Vec<f64>,
416    /// Additional scale multipliers applied to each base size
417    pub scales: Vec<f64>,
418    /// Image width
419    pub img_width: usize,
420    /// Image height
421    pub img_height: usize,
422    /// Feature map width (grid columns)
423    pub feat_width: usize,
424    /// Feature map height (grid rows)
425    pub feat_height: usize,
426}
427
428impl Default for AnchorConfig {
429    fn default() -> Self {
430        Self {
431            base_sizes: vec![32.0, 64.0, 128.0, 256.0, 512.0],
432            aspect_ratios: vec![0.5, 1.0, 2.0],
433            scales: vec![1.0, 2.0f64.sqrt()],
434            img_width: 512,
435            img_height: 512,
436            feat_width: 16,
437            feat_height: 16,
438        }
439    }
440}
441
442/// Generate a grid of anchor boxes from an [`AnchorConfig`].
443///
444/// For each cell in the `feat_width × feat_height` grid, one anchor is created
445/// per `(base_size, aspect_ratio, scale)` combination, centred at the projected
446/// pixel coordinates of that cell.
447///
448/// # Errors
449/// Returns [`VisionError::InvalidInput`] if any dimension is zero.
450pub fn anchor_boxes(config: &AnchorConfig) -> Result<Vec<BoundingBox>> {
451    if config.feat_width == 0 || config.feat_height == 0 {
452        return Err(VisionError::InvalidInput(
453            "anchor_boxes: feature map dimensions must be > 0".to_string(),
454        ));
455    }
456    if config.img_width == 0 || config.img_height == 0 {
457        return Err(VisionError::InvalidInput(
458            "anchor_boxes: image dimensions must be > 0".to_string(),
459        ));
460    }
461    if config.base_sizes.is_empty() || config.aspect_ratios.is_empty() || config.scales.is_empty() {
462        return Err(VisionError::InvalidInput(
463            "anchor_boxes: base_sizes, aspect_ratios, and scales must be non-empty".to_string(),
464        ));
465    }
466
467    let stride_x = config.img_width as f64 / config.feat_width as f64;
468    let stride_y = config.img_height as f64 / config.feat_height as f64;
469
470    let mut anchors = Vec::new();
471
472    for row in 0..config.feat_height {
473        let cy = (row as f64 + 0.5) * stride_y;
474        for col in 0..config.feat_width {
475            let cx = (col as f64 + 0.5) * stride_x;
476
477            for &base_size in &config.base_sizes {
478                for &ratio in &config.aspect_ratios {
479                    for &scale in &config.scales {
480                        // area = (base_size * scale)^2
481                        let area = (base_size * scale).powi(2);
482                        // ratio = width / height  →  width = sqrt(area * ratio)
483                        let w = (area * ratio).sqrt();
484                        let h = area / w;
485
486                        anchors.push(BoundingBox::new(
487                            cx - w * 0.5,
488                            cy - h * 0.5,
489                            cx + w * 0.5,
490                            cy + h * 0.5,
491                            1.0,
492                            0,
493                        ));
494                    }
495                }
496            }
497        }
498    }
499
500    Ok(anchors)
501}
502
503// ---------------------------------------------------------------------------
504// Tests
505// ---------------------------------------------------------------------------
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_bounding_box_geometry() {
513        let b = BoundingBox::new(10.0, 20.0, 50.0, 80.0, 0.9, 1);
514        assert!((b.width() - 40.0).abs() < 1e-10);
515        assert!((b.height() - 60.0).abs() < 1e-10);
516        assert!((b.area() - 2400.0).abs() < 1e-10);
517        let (cx, cy) = b.center();
518        assert!((cx - 30.0).abs() < 1e-10);
519        assert!((cy - 50.0).abs() < 1e-10);
520    }
521
522    #[test]
523    fn test_compute_iou_identical() {
524        let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0, 1.0, 0);
525        assert!((compute_iou(&a, &a) - 1.0).abs() < 1e-10);
526    }
527
528    #[test]
529    fn test_compute_iou_disjoint() {
530        let a = BoundingBox::new(0.0, 0.0, 5.0, 5.0, 1.0, 0);
531        let b = BoundingBox::new(10.0, 10.0, 15.0, 15.0, 1.0, 0);
532        assert!((compute_iou(&a, &b)).abs() < 1e-10);
533    }
534
535    #[test]
536    fn test_compute_iou_partial() {
537        let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0, 1.0, 0);
538        let b = BoundingBox::new(5.0, 5.0, 15.0, 15.0, 1.0, 0);
539        let iou = compute_iou(&a, &b);
540        // intersection = 5×5 = 25, union = 100+100-25 = 175
541        assert!((iou - 25.0 / 175.0).abs() < 1e-10);
542    }
543
544    #[test]
545    fn test_nms_removes_overlapping() {
546        let boxes = vec![
547            BoundingBox::new(0.0, 0.0, 10.0, 10.0, 0.9, 0),
548            BoundingBox::new(1.0, 1.0, 11.0, 11.0, 0.7, 0), // heavily overlaps first
549            BoundingBox::new(50.0, 50.0, 60.0, 60.0, 0.8, 1), // different class, disjoint
550        ];
551        let kept = nms(&boxes, 0.5);
552        assert_eq!(kept.len(), 2);
553        assert!((kept[0].score - 0.9).abs() < 1e-10);
554    }
555
556    #[test]
557    fn test_nms_different_classes_kept() {
558        // Same position, different class — NMS should keep both
559        let boxes = vec![
560            BoundingBox::new(0.0, 0.0, 10.0, 10.0, 0.9, 0),
561            BoundingBox::new(0.0, 0.0, 10.0, 10.0, 0.8, 1),
562        ];
563        let kept = nms(&boxes, 0.5);
564        assert_eq!(kept.len(), 2);
565    }
566
567    #[test]
568    fn test_soft_nms_linear() {
569        let boxes = vec![
570            BoundingBox::new(0.0, 0.0, 10.0, 10.0, 0.9, 0),
571            BoundingBox::new(1.0, 1.0, 11.0, 11.0, 0.8, 0),
572            BoundingBox::new(50.0, 50.0, 60.0, 60.0, 0.7, 0),
573        ];
574        let kept = soft_nms(&boxes, 0.3, 0.3, SoftNmsMethod::Linear);
575        // Disjoint box should survive; heavily overlapping box may be suppressed
576        assert!(!kept.is_empty());
577        // All remaining scores should be >= threshold
578        for b in &kept {
579            assert!(b.score >= 0.3);
580        }
581    }
582
583    #[test]
584    fn test_soft_nms_gaussian() {
585        let boxes = vec![
586            BoundingBox::new(0.0, 0.0, 10.0, 10.0, 0.9, 0),
587            BoundingBox::new(0.5, 0.5, 10.5, 10.5, 0.8, 0),
588        ];
589        let kept = soft_nms(&boxes, 0.3, 0.1, SoftNmsMethod::Gaussian { sigma: 0.5 });
590        assert!(!kept.is_empty());
591    }
592
593    #[test]
594    fn test_sliding_window_basic() {
595        let windows =
596            sliding_window(100, 100, 20, 20, 10, 1.0, 1).expect("sliding_window should succeed");
597        // Without scaling: (100-20)/10 + 1 = 9 positions per axis → 9×9 = 81
598        assert_eq!(windows.len(), 81);
599        for w in &windows {
600            assert!(w.x + w.width <= 100);
601            assert!(w.y + w.height <= 100);
602        }
603    }
604
605    #[test]
606    fn test_sliding_window_error_zero_dims() {
607        assert!(sliding_window(100, 100, 0, 20, 10, 1.0, 1).is_err());
608    }
609
610    #[test]
611    fn test_anchor_boxes_count() {
612        let config = AnchorConfig {
613            base_sizes: vec![32.0],
614            aspect_ratios: vec![1.0],
615            scales: vec![1.0],
616            img_width: 256,
617            img_height: 256,
618            feat_width: 4,
619            feat_height: 4,
620        };
621        let anchors = anchor_boxes(&config).expect("anchor_boxes should succeed");
622        // 4×4 grid × 1 base × 1 ratio × 1 scale = 16
623        assert_eq!(anchors.len(), 16);
624    }
625
626    #[test]
627    fn test_anchor_boxes_multi() {
628        let config = AnchorConfig {
629            base_sizes: vec![32.0, 64.0],
630            aspect_ratios: vec![0.5, 1.0, 2.0],
631            scales: vec![1.0, 2.0f64.sqrt()],
632            img_width: 512,
633            img_height: 512,
634            feat_width: 8,
635            feat_height: 8,
636        };
637        let anchors = anchor_boxes(&config).expect("anchor_boxes should succeed");
638        // 8×8 × 2 × 3 × 2 = 768
639        assert_eq!(anchors.len(), 768);
640    }
641
642    #[test]
643    fn test_from_center() {
644        let b = BoundingBox::from_center(10.0, 10.0, 4.0, 6.0, 0.9, 0);
645        assert!((b.x1 - 8.0).abs() < 1e-10);
646        assert!((b.y1 - 7.0).abs() < 1e-10);
647        assert!((b.x2 - 12.0).abs() < 1e-10);
648        assert!((b.y2 - 13.0).abs() < 1e-10);
649    }
650}