Skip to main content

sightloom_core/
nms.rs

1//! Deterministic non-maximum suppression over caller-owned storage.
2#![allow(clippy::cast_precision_loss, clippy::needless_range_loop)]
3
4use core::cmp::Ordering;
5
6use crate::{CoreError, Detection, ios, iou};
7
8/// The rectangle-overlap metric used to suppress detections.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum OverlapMetric {
11    /// Intersection over union.
12    IoU,
13    /// Intersection over the smaller rectangle's area.
14    IoS,
15}
16
17/// The class-comparison policy used during suppression.
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub enum NmsMode {
20    /// Compare detections only when their optional class identifiers match.
21    ClassAware,
22    /// Compare every detection regardless of class identifier.
23    ClassAgnostic,
24}
25
26/// Immutable settings for a non-maximum suppression pass.
27#[derive(Clone, Copy, Debug, PartialEq)]
28pub struct NmsConfig {
29    /// Overlap threshold in the inclusive range `0.0..=1.0`; suppression
30    /// occurs only when overlap is strictly greater than this value.
31    pub threshold: f32,
32    /// Whether class identifiers restrict suppression.
33    pub mode: NmsMode,
34    /// The overlap metric used for suppression.
35    pub metric: OverlapMetric,
36}
37
38/// Suppresses overlapping detections without allocating.
39///
40/// Detections are prioritized by descending score, then ascending original
41/// index. The kept detections are compacted into the front of `detections` in
42/// their original input order; the remaining tail is unspecified.
43///
44/// # Errors
45///
46/// Returns [`CoreError::InvalidThreshold`] when `config.threshold` is not
47/// finite or is outside `0.0..=1.0`. Returns
48/// [`CoreError::InsufficientScratch`] when either scratch slice is shorter
49/// than `detections`. Neither error mutates a caller-owned slice.
50pub fn nms_in_place(
51    detections: &mut [Detection],
52    order_scratch: &mut [usize],
53    suppressed_scratch: &mut [bool],
54    config: NmsConfig,
55) -> Result<usize, CoreError> {
56    if !config.threshold.is_finite() || !(0.0..=1.0).contains(&config.threshold) {
57        return Err(CoreError::InvalidThreshold);
58    }
59
60    let len = detections.len();
61    if order_scratch.len() < len || suppressed_scratch.len() < len {
62        return Err(CoreError::InsufficientScratch);
63    }
64
65    let order = &mut order_scratch[..len];
66    let suppressed = &mut suppressed_scratch[..len];
67    for (index, slot) in order.iter_mut().enumerate() {
68        *slot = index;
69    }
70    for value in suppressed.iter_mut() {
71        *value = false;
72    }
73
74    order.sort_unstable_by(|left, right| {
75        detections[*right]
76            .score()
77            .partial_cmp(&detections[*left].score())
78            .unwrap_or(Ordering::Equal)
79            .then_with(|| left.cmp(right))
80    });
81
82    for candidate_position in 0..len {
83        let candidate_index = order[candidate_position];
84        if suppressed[candidate_index] {
85            continue;
86        }
87
88        for contender_index in order.iter().skip(candidate_position + 1).copied() {
89            if suppressed[contender_index]
90                || (config.mode == NmsMode::ClassAware
91                    && detections[candidate_index].class_id()
92                        != detections[contender_index].class_id())
93            {
94                continue;
95            }
96
97            let overlap = match config.metric {
98                OverlapMetric::IoU => iou(
99                    detections[candidate_index].bbox(),
100                    detections[contender_index].bbox(),
101                ),
102                OverlapMetric::IoS => ios(
103                    detections[candidate_index].bbox(),
104                    detections[contender_index].bbox(),
105                ),
106            };
107            if overlap > config.threshold {
108                suppressed[contender_index] = true;
109            }
110        }
111    }
112
113    let mut kept = 0;
114    for index in 0..len {
115        if !suppressed[index] {
116            let detection = detections[index];
117            detections[kept] = detection;
118            kept += 1;
119        }
120    }
121
122    Ok(kept)
123}
124
125/// Soft-NMS decay method.
126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub enum SoftNmsMethod {
128    /// Linear score decay: `score * (1 - overlap)` when overlap &gt; threshold.
129    Linear,
130    /// Gaussian decay: `score * exp(-(overlap^2) / sigma)`.
131    Gaussian,
132}
133
134/// Soft-NMS configuration.
135#[derive(Clone, Copy, Debug, PartialEq)]
136pub struct SoftNmsConfig {
137    /// Overlap threshold for linear method (ignored by Gaussian except as 0 gate).
138    pub threshold: f32,
139    /// Gaussian sigma (`> 0`); used when method is Gaussian.
140    pub sigma: f32,
141    /// Minimum score after decay; detections below are dropped.
142    pub score_threshold: f32,
143    /// Decay method.
144    pub method: SoftNmsMethod,
145    /// Class comparison policy.
146    pub mode: NmsMode,
147    /// Overlap metric.
148    pub metric: OverlapMetric,
149}
150
151impl Default for SoftNmsConfig {
152    fn default() -> Self {
153        Self {
154            threshold: 0.5,
155            sigma: 0.5,
156            score_threshold: 0.001,
157            method: SoftNmsMethod::Gaussian,
158            mode: NmsMode::ClassAgnostic,
159            metric: OverlapMetric::IoU,
160        }
161    }
162}
163
164/// Soft-NMS: decays scores of overlapping boxes instead of hard suppression.
165///
166/// Compacts survivors (score ≥ `score_threshold`) to the front of `detections`
167/// in descending final-score order.
168///
169/// # Errors
170///
171/// Returns threshold / scratch errors similar to [`nms_in_place`].
172pub fn soft_nms_in_place(
173    detections: &mut [Detection],
174    order_scratch: &mut [usize],
175    scores_scratch: &mut [f32],
176    config: SoftNmsConfig,
177) -> Result<usize, CoreError> {
178    if !config.threshold.is_finite()
179        || !(0.0..=1.0).contains(&config.threshold)
180        || !config.sigma.is_finite()
181        || config.sigma <= 0.0
182        || !config.score_threshold.is_finite()
183    {
184        return Err(CoreError::InvalidThreshold);
185    }
186    let len = detections.len();
187    if order_scratch.len() < len || scores_scratch.len() < len {
188        return Err(CoreError::InsufficientScratch);
189    }
190    let order = &mut order_scratch[..len];
191    let scores = &mut scores_scratch[..len];
192    for (i, d) in detections.iter().enumerate() {
193        order[i] = i;
194        scores[i] = d.score();
195    }
196
197    // Active indices live in order[0..remaining).
198    let mut remaining = len;
199    while remaining > 0 {
200        let mut best_pos = 0_usize;
201        for p in 1..remaining {
202            let cmp = scores[order[p]]
203                .partial_cmp(&scores[order[best_pos]])
204                .unwrap_or(Ordering::Equal);
205            if cmp == Ordering::Greater || (cmp == Ordering::Equal && order[p] < order[best_pos]) {
206                best_pos = p;
207            }
208        }
209        // Move best to the end of the active region and shrink.
210        order.swap(best_pos, remaining - 1);
211        let cand = order[remaining - 1];
212        remaining -= 1;
213        if scores[cand] < config.score_threshold {
214            scores[cand] = 0.0;
215            continue;
216        }
217        for p in 0..remaining {
218            let other = order[p];
219            if config.mode == NmsMode::ClassAware
220                && detections[cand].class_id() != detections[other].class_id()
221            {
222                continue;
223            }
224            let overlap = match config.metric {
225                OverlapMetric::IoU => iou(detections[cand].bbox(), detections[other].bbox()),
226                OverlapMetric::IoS => ios(detections[cand].bbox(), detections[other].bbox()),
227            };
228            let weight = match config.method {
229                SoftNmsMethod::Linear => {
230                    if overlap > config.threshold {
231                        1.0 - overlap
232                    } else {
233                        1.0
234                    }
235                }
236                SoftNmsMethod::Gaussian => exp_neg_approx((overlap * overlap) / config.sigma),
237            };
238            scores[other] *= weight;
239        }
240    }
241
242    for (i, slot) in order.iter_mut().enumerate() {
243        *slot = i;
244    }
245    order.sort_unstable_by(|a, b| {
246        scores[*b]
247            .partial_cmp(&scores[*a])
248            .unwrap_or(Ordering::Equal)
249            .then_with(|| a.cmp(b))
250    });
251    let mut out = 0_usize;
252    for &i in order.iter() {
253        if scores[i] < config.score_threshold {
254            break;
255        }
256        let d = detections[i];
257        detections[out] = Detection::new(d.bbox(), scores[i], d.class_id(), d.track_id())
258            .map_err(|_| CoreError::NonFinite)?;
259        out += 1;
260    }
261    Ok(out)
262}
263
264fn union_find_root(parent: &mut [usize], mut x: usize) -> usize {
265    while parent[x] != x {
266        parent[x] = parent[parent[x]];
267        x = parent[x];
268    }
269    x
270}
271
272/// Merges overlapping detections by averaging boxes and taking max score.
273///
274/// Compacts merged clusters to the front of `detections`.
275///
276/// # Errors
277///
278/// Same threshold / scratch validation as hard NMS.
279pub fn merge_nms_in_place(
280    detections: &mut [Detection],
281    order_scratch: &mut [usize],
282    parent_scratch: &mut [usize],
283    config: NmsConfig,
284) -> Result<usize, CoreError> {
285    if !config.threshold.is_finite() || !(0.0..=1.0).contains(&config.threshold) {
286        return Err(CoreError::InvalidThreshold);
287    }
288    let len = detections.len();
289    if order_scratch.len() < len || parent_scratch.len() < len {
290        return Err(CoreError::InsufficientScratch);
291    }
292    let parent = &mut parent_scratch[..len];
293    for (i, p) in parent.iter_mut().enumerate() {
294        *p = i;
295    }
296    for i in 0..len {
297        for j in (i + 1)..len {
298            if config.mode == NmsMode::ClassAware
299                && detections[i].class_id() != detections[j].class_id()
300            {
301                continue;
302            }
303            let overlap = match config.metric {
304                OverlapMetric::IoU => iou(detections[i].bbox(), detections[j].bbox()),
305                OverlapMetric::IoS => ios(detections[i].bbox(), detections[j].bbox()),
306            };
307            if overlap > config.threshold {
308                let a = union_find_root(parent, i);
309                let b = union_find_root(parent, j);
310                if a != b {
311                    // Prefer higher score as root.
312                    if detections[a].score() >= detections[b].score() {
313                        parent[b] = a;
314                    } else {
315                        parent[a] = b;
316                    }
317                }
318            }
319        }
320    }
321    // Aggregate per root
322    let used = order_scratch;
323    for u in used.iter_mut().take(len) {
324        *u = 0;
325    }
326    let mut out = 0_usize;
327    for i in 0..len {
328        let r = union_find_root(parent, i);
329        if used[r] != 0 {
330            continue;
331        }
332        used[r] = 1;
333        let mut sum_l = 0.0_f32;
334        let mut sum_t = 0.0_f32;
335        let mut sum_r = 0.0_f32;
336        let mut sum_b = 0.0_f32;
337        let mut max_score = 0.0_f32;
338        let mut n = 0_f32;
339        let mut class_id = detections[r].class_id();
340        let mut track_id = detections[r].track_id();
341        for j in 0..len {
342            if union_find_root(parent, j) != r {
343                continue;
344            }
345            let b = detections[j].bbox();
346            sum_l += b.left();
347            sum_t += b.top();
348            sum_r += b.right();
349            sum_b += b.bottom();
350            max_score = max_score.max(detections[j].score());
351            n += 1.0;
352            if detections[j].class_id().is_some() {
353                class_id = detections[j].class_id();
354            }
355            if detections[j].track_id().is_some() {
356                track_id = detections[j].track_id();
357            }
358        }
359        if n <= 0.0 {
360            continue;
361        }
362        let rect = crate::Rect::new(sum_l / n, sum_t / n, sum_r / n, sum_b / n)
363            .map_err(|_| CoreError::NonFinite)?;
364        detections[out] = Detection::new(rect, max_score, class_id, track_id)
365            .map_err(|_| CoreError::NonFinite)?;
366        out += 1;
367    }
368    Ok(out)
369}
370
371/// `exp(-x)` approximation for Soft-NMS Gaussian (portable, no libm).
372fn exp_neg_approx(x: f32) -> f32 {
373    if !x.is_finite() || x < 0.0 {
374        return 1.0;
375    }
376    if x > 20.0 {
377        return 0.0;
378    }
379    // Padé-ish / Taylor for e^{-x}
380    let mut term = 1.0_f32;
381    let mut sum = 1.0_f32;
382    for k in 1..12 {
383        term *= -x / k as f32;
384        sum += term;
385    }
386    sum.clamp(0.0, 1.0)
387}