Skip to main content

edgefirst_decoder/
tiling.rs

1// SPDX-FileCopyrightText: Copyright 2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! SAHI-style tiled-inference postprocessing: lift per-tile detections to
5//! full-frame coordinates and merge them across tiles.
6//!
7//! A high-resolution frame is covered by a uniform overlapping grid of tiles
8//! (geometry lives in the `edgefirst-image` crate). Each tile is run through
9//! the small tile-input model and decoded independently to normalized `[0,1]`
10//! detections over the model input. This module lifts those to full-frame
11//! pixels ([`lift_tile_boxes`]) and merges duplicates at tile seams
12//! ([`merge_tiled_detections`]) using **GREEDYNMM** with the **IOS**
13//! (intersection-over-smaller) match metric. [`TiledFrameAccumulator`] is a
14//! streaming collector so a pipelined runtime can push each tile's detections
15//! as inference completes and finalize once the frame's last tile arrives.
16//!
17//! The merge reproduces ModelPack's reference runtime
18//! (`metrics/tiled.py::merge_tiled_detections`) numerically. IOS matters
19//! because an object split across a tile overlap appears as two partial boxes
20//! whose IoU is low but whose IoS is high, so IoS merges them where IoU leaves
21//! duplicates.
22//!
23//! # Per-tile decode guidance (affects mAP)
24//!
25//! Run the per-tile [`crate::Decoder`] with a **low score threshold** (e.g.
26//! 0.05) and **class-aware** NMS, and a modest per-tile `max_det`. The merge's
27//! own [`MergeConfig::score_threshold`] defaults to `0.0` precisely because
28//! per-tile decode is the real flood control — a high per-tile threshold
29//! discards true-positive fragments before the merge can join them, collapsing
30//! the recall the IOS design buys. Final score gating belongs in
31//! [`MergeConfig::score_threshold`].
32//!
33//! # Known limitations
34//!
35//! - **Objects larger than one tile** cannot be reconstructed: every tile sees
36//!   only a fragment, and with no whole-object box to anchor the union the
37//!   fragments may not mutually pass the IOS threshold. Choose a tile size that
38//!   exceeds the largest expected object, or add the optional full-frame
39//!   downscaled pass (push it as one extra tile into the accumulator).
40//!
41//! # Reference implementations
42//!
43//! - **Grid spacing (EvenDist):** the canonical authority is HAL's own
44//!   `edgefirst_image::tile_grid` (ported from the adis-uav-model `sahi()`
45//!   function). `overlap_ratio` is a *minimum*; realized overlap is never
46//!   rounded below it. ModelPack's validator is slated to adopt this same grid.
47//! - **Merge:** ModelPack `metrics/tiled.py::merge_tiled_detections` — mirrored
48//!   here numerically. The only deliberate difference is tie-breaking on exactly
49//!   equal scores (ascending original index here vs NumPy's unstable `argsort`),
50//!   which makes the streaming accumulator order-independent; results are
51//!   identical on non-degenerate inputs.
52
53use crate::float::{box_area, intersection_area, ios_value, iou_value};
54use crate::{BoundingBox, DetectBox};
55
56/// Overlap metric used by the tiled-detection merge to decide whether two boxes
57/// belong to the same object.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum MatchMetric {
60    /// Intersection-over-Union (standard NMS metric).
61    Iou,
62    /// Intersection-over-Smaller (default): `inter / min(area_a, area_b)`. A
63    /// seam-split object has low IoU but high IoS, so IoS merges the fragment.
64    #[default]
65    Ios,
66}
67
68impl MatchMetric {
69    /// Metric value in `[0, 1]` for two boxes.
70    #[inline]
71    pub fn value(self, a: &BoundingBox, b: &BoundingBox) -> f32 {
72        match self {
73            MatchMetric::Iou => iou_value(a, b),
74            MatchMetric::Ios => ios_value(a, b),
75        }
76    }
77}
78
79/// How one tile was cut from the full frame and fed to the model. Produced by
80/// the input side (the `edgefirst-image` tiling API), consumed by
81/// [`lift_tile_boxes`]. All fields are native full-frame **pixels** except
82/// `letterbox`.
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct TilePlacement {
85    /// Tile index within the frame grid, `0..count`.
86    pub index: usize,
87    /// Total tiles for this frame (the streaming fan-in fence).
88    pub count: usize,
89    /// Native crop origin `(ox, oy)` in full-frame pixels.
90    pub origin: (f32, f32),
91    /// Native crop size `(cw, ch)` in full-frame pixels. Equals the tile size
92    /// for the full-size tiles the EvenDist grid produces.
93    pub crop_size: (f32, f32),
94    /// Normalized letterbox content bounds `[lx0, ly0, lx1, ly1]` on the model
95    /// input, or `None` when the crop was stretched to fill it (the hot path).
96    pub letterbox: Option<[f32; 4]>,
97    /// Full-frame dimensions `(frame_w, frame_h)` in pixels.
98    pub frame_dims: (f32, f32),
99}
100
101/// Configuration for the tiled-detection merge.
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub struct MergeConfig {
104    /// Overlap metric (default [`MatchMetric::Ios`]).
105    pub metric: MatchMetric,
106    /// Merge two boxes when `metric.value(a, b) >= threshold` (default 0.5).
107    pub threshold: f32,
108    /// Merge across classes when true (default false).
109    pub class_agnostic: bool,
110    /// Cap on returned detections after the merge (default 300).
111    pub max_det: usize,
112    /// Drop merged groups whose max score is below this (default 0.0 = keep
113    /// all; per-tile decode is the real flood control).
114    pub score_threshold: f32,
115}
116
117impl Default for MergeConfig {
118    fn default() -> Self {
119        Self {
120            metric: MatchMetric::Ios,
121            threshold: 0.5,
122            class_agnostic: false,
123            max_det: 300,
124            score_threshold: 0.0,
125        }
126    }
127}
128
129/// Invert a letterbox: map a [`BoundingBox`] normalized over the model input
130/// back to normalized-over-the-crop, given the content bounds
131/// `[lx0, ly0, lx1, ly1]`. The box is canonicalised first, a degenerate
132/// (zero-span) letterbox axis maps with unit scale (no divide-by-zero), and the
133/// result is clamped to `[0, 1]`.
134///
135/// This is the single home for the inverse-letterbox transform;
136/// `edgefirst_image::unletter_bbox` is a thin wrapper around it (the `image`
137/// crate depends on `decoder`, so the shared math lives here, in the lower
138/// crate).
139#[must_use]
140#[inline]
141pub fn unletter_norm(b: BoundingBox, lb: [f32; 4]) -> BoundingBox {
142    let b = b.to_canonical();
143    let [lx0, ly0, lx1, ly1] = lb;
144    let inv_w = if lx1 > lx0 { 1.0 / (lx1 - lx0) } else { 1.0 };
145    let inv_h = if ly1 > ly0 { 1.0 / (ly1 - ly0) } else { 1.0 };
146    BoundingBox {
147        xmin: ((b.xmin - lx0) * inv_w).clamp(0.0, 1.0),
148        ymin: ((b.ymin - ly0) * inv_h).clamp(0.0, 1.0),
149        xmax: ((b.xmax - lx0) * inv_w).clamp(0.0, 1.0),
150        ymax: ((b.ymax - ly0) * inv_h).clamp(0.0, 1.0),
151    }
152}
153
154/// Lift tile-local **normalized** `[0,1]` xyxy detections (over the model
155/// input) to full-frame **pixel** xyxy. Mirrors
156/// `metrics/tiled.py::lift_tile_boxes`: optionally invert the letterbox, then
157/// `full = origin + norm * crop_size`. Consumes and rewrites `boxes` in place.
158///
159/// # Examples
160/// ```
161/// use edgefirst_decoder::tiling::{lift_tile_boxes, TilePlacement};
162/// use edgefirst_decoder::{BoundingBox, DetectBox};
163///
164/// let placement = TilePlacement {
165///     index: 0, count: 1,
166///     origin: (100.0, 200.0), crop_size: (640.0, 640.0),
167///     letterbox: None, frame_dims: (3840.0, 2160.0),
168/// };
169/// let tile_local = DetectBox { bbox: BoundingBox::new(0.0, 0.0, 1.0, 1.0), score: 0.9, label: 0 };
170/// let lifted = lift_tile_boxes(vec![tile_local], &placement);
171/// assert_eq!(lifted[0].bbox, BoundingBox::new(100.0, 200.0, 740.0, 840.0));
172/// ```
173#[must_use]
174pub fn lift_tile_boxes(mut boxes: Vec<DetectBox>, placement: &TilePlacement) -> Vec<DetectBox> {
175    let (ox, oy) = placement.origin;
176    let (cw, ch) = placement.crop_size;
177    for d in &mut boxes {
178        let n = match placement.letterbox {
179            Some(lb) => unletter_norm(d.bbox, lb),
180            None => d.bbox,
181        };
182        d.bbox = BoundingBox {
183            xmin: ox + n.xmin * cw,
184            ymin: oy + n.ymin * ch,
185            xmax: ox + n.xmax * cw,
186            ymax: oy + n.ymax * ch,
187        };
188    }
189    boxes
190}
191
192/// Match metric using precomputed areas (avoids recomputing both operands'
193/// areas on every pair in the O(N^2) merge). Equivalent to
194/// [`MatchMetric::value`].
195#[inline]
196fn metric_value_with_areas(
197    metric: MatchMetric,
198    a: &BoundingBox,
199    area_a: f32,
200    b: &BoundingBox,
201    area_b: f32,
202) -> f32 {
203    let inter = intersection_area(a, b);
204    let denom = match metric {
205        MatchMetric::Iou => area_a + area_b - inter,
206        MatchMetric::Ios => area_a.min(area_b),
207    };
208    inter / denom.max(1e-9)
209}
210
211/// Greedy Non-Max **Merge** of lifted full-frame detections. Mirrors
212/// `metrics/tiled.py::merge_tiled_detections`:
213///
214/// 1. Sort descending by score (ties broken by ascending original index so the
215///    result is deterministic — this differs from NumPy's unstable `argsort`
216///    only on exact ties).
217/// 2. For each unused `base` in order, find later unused boxes (same class
218///    unless `class_agnostic`) whose `metric.value(base, cand) >= threshold` —
219///    matched against the **original** base box. Replace the group with its
220///    **enclosing union** carrying the group's **max** score and the base's
221///    label.
222/// 3. Drop groups below `score_threshold` and truncate to `max_det`.
223///
224/// Operates in pixel space (the metric's `1e-9` epsilon is calibrated to pixel
225/// areas).
226///
227/// # Examples
228/// ```
229/// use edgefirst_decoder::tiling::{merge_tiled_detections, MatchMetric, MergeConfig};
230/// use edgefirst_decoder::{BoundingBox, DetectBox};
231///
232/// // A fragment (B) fully inside the full detection (A): IoS=1.0, IoU≈0.17.
233/// let a = DetectBox { bbox: BoundingBox::new(100.0, 100.0, 400.0, 300.0), score: 0.9, label: 0 };
234/// let b = DetectBox { bbox: BoundingBox::new(350.0, 100.0, 400.0, 300.0), score: 0.7, label: 0 };
235///
236/// // IOS merges the fragment into one box carrying the group's max score…
237/// let ios = merge_tiled_detections(vec![a, b], &MergeConfig::default());
238/// assert_eq!(ios.len(), 1);
239/// assert_eq!(ios[0].score, 0.9);
240///
241/// // …while IOU leaves the two separate.
242/// let cfg = MergeConfig { metric: MatchMetric::Iou, ..MergeConfig::default() };
243/// assert_eq!(merge_tiled_detections(vec![a, b], &cfg).len(), 2);
244/// ```
245#[must_use]
246pub fn merge_tiled_detections(dets: Vec<DetectBox>, cfg: &MergeConfig) -> Vec<DetectBox> {
247    if dets.is_empty() {
248        return dets;
249    }
250
251    // Descending score, ties by ascending original index (deterministic).
252    let mut order: Vec<usize> = (0..dets.len()).collect();
253    order.sort_by(|&i, &j| dets[j].score.total_cmp(&dets[i].score).then(i.cmp(&j)));
254
255    // Canonicalize once so degenerate (inverted) boxes have well-defined areas,
256    // and precompute each box's area once (the O(N^2) loop below would otherwise
257    // recompute both operands' areas on every pair).
258    let boxes: Vec<BoundingBox> = dets.iter().map(|d| d.bbox.to_canonical()).collect();
259    let areas: Vec<f32> = boxes.iter().map(box_area).collect();
260
261    let n = dets.len();
262    let mut used = vec![false; n];
263    let mut out: Vec<DetectBox> = Vec::with_capacity(n);
264
265    for oi in 0..n {
266        let i = order[oi];
267        if used[i] {
268            continue;
269        }
270        used[i] = true;
271        let base_box = boxes[i];
272        let base_area = areas[i];
273        let base_label = dets[i].label;
274        let mut acc = base_box;
275        let mut max_score = dets[i].score;
276
277        for &j in &order[(oi + 1)..] {
278            if used[j] {
279                continue;
280            }
281            if !cfg.class_agnostic && dets[j].label != base_label {
282                continue;
283            }
284            // Metric measured against the ORIGINAL base box (parity with the
285            // reference), using cached areas.
286            if metric_value_with_areas(cfg.metric, &base_box, base_area, &boxes[j], areas[j])
287                >= cfg.threshold
288            {
289                used[j] = true;
290                let c = boxes[j];
291                acc.xmin = acc.xmin.min(c.xmin);
292                acc.ymin = acc.ymin.min(c.ymin);
293                acc.xmax = acc.xmax.max(c.xmax);
294                acc.ymax = acc.ymax.max(c.ymax);
295                max_score = max_score.max(dets[j].score);
296            }
297        }
298
299        out.push(DetectBox {
300            bbox: acc,
301            score: max_score,
302            label: base_label,
303        });
304    }
305
306    if cfg.score_threshold > 0.0 {
307        out.retain(|d| d.score >= cfg.score_threshold);
308    }
309    out.truncate(cfg.max_det);
310    out
311}
312
313/// Streaming collector for one frame's tiled detections. A pipelined runtime
314/// pushes each tile's per-tile-decoded boxes as inference completes (any
315/// order), then finalizes once every tile has arrived — the "collect after the
316/// final tile" fan-in. Not internally synchronized; keep one accumulator per
317/// in-flight frame.
318#[derive(Debug, Clone)]
319pub struct TiledFrameAccumulator {
320    frame_dims: (f32, f32),
321    tiles_total: usize,
322    /// Per-tile-index arrival flags — makes `push_tile` idempotent and the
323    /// completion fence robust to duplicate / out-of-range / retried pushes
324    /// (an at-least-once async pipeline can deliver the same tile twice).
325    seen: Vec<bool>,
326    received: usize,
327    dets: Vec<DetectBox>,
328    cfg: MergeConfig,
329}
330
331impl TiledFrameAccumulator {
332    /// Create an accumulator for a frame with `tiles_total` tiles. `frame_dims`
333    /// is `(frame_w, frame_h)` in pixels, used by [`Self::finalize_normalized`].
334    /// `est_per_tile` pre-reserves the detection buffer.
335    ///
336    /// # Examples
337    /// ```
338    /// use edgefirst_decoder::tiling::{MergeConfig, TiledFrameAccumulator};
339    /// let acc = TiledFrameAccumulator::new((1920.0, 1080.0), 12, MergeConfig::default(), 16);
340    /// assert_eq!(acc.remaining(), 12);
341    /// assert!(!acc.is_complete());
342    /// ```
343    pub fn new(
344        frame_dims: (f32, f32),
345        tiles_total: usize,
346        cfg: MergeConfig,
347        est_per_tile: usize,
348    ) -> Self {
349        Self {
350            frame_dims,
351            tiles_total,
352            seen: vec![false; tiles_total],
353            received: 0,
354            dets: Vec::with_capacity(tiles_total.saturating_mul(est_per_tile)),
355            cfg,
356        }
357    }
358
359    /// Lift one tile's per-tile-decoded boxes to full-frame pixels and append
360    /// them. Idempotent per `placement.index`: a duplicate, out-of-range, or
361    /// foreign placement (one whose `count` disagrees with this accumulator's
362    /// `tiles_total`, i.e. from a different plan/frame) is ignored and its boxes
363    /// dropped, so out-of-order **and** at-least-once delivery both converge to
364    /// the same result. Returns `true` if the tile was newly accepted, `false`
365    /// otherwise.
366    pub fn push_tile(&mut self, tile_boxes: Vec<DetectBox>, placement: &TilePlacement) -> bool {
367        let idx = placement.index;
368        // Runtime guard (not debug-only): reject placements from a different
369        // plan so a mixed-frame mistake can't corrupt fan-in completion.
370        if placement.count != self.tiles_total || idx >= self.tiles_total || self.seen[idx] {
371            return false;
372        }
373        self.seen[idx] = true;
374        self.dets.extend(lift_tile_boxes(tile_boxes, placement));
375        self.received += 1;
376        true
377    }
378
379    /// True once every tile of the frame has been pushed (by distinct index).
380    #[inline]
381    pub fn is_complete(&self) -> bool {
382        self.received >= self.tiles_total
383    }
384
385    /// Tiles still outstanding.
386    #[inline]
387    pub fn remaining(&self) -> usize {
388        self.tiles_total.saturating_sub(self.received)
389    }
390
391    /// Merge all accumulated detections into full-frame **pixel** boxes.
392    #[must_use]
393    pub fn finalize(self) -> Vec<DetectBox> {
394        merge_tiled_detections(self.dets, &self.cfg)
395    }
396
397    /// Merge then renormalize to `[0,1]` by `frame_dims` (for the tracker,
398    /// matching the non-tiled normalized-detection contract).
399    ///
400    /// Returns an empty list when `frame_dims` are non-finite or non-positive
401    /// rather than emitting Inf/NaN coordinates.
402    #[must_use]
403    pub fn finalize_normalized(self) -> Vec<DetectBox> {
404        let (fw, fh) = self.frame_dims;
405        if !(fw.is_finite() && fh.is_finite() && fw > 0.0 && fh > 0.0) {
406            return Vec::new();
407        }
408        let inv_w = 1.0 / fw;
409        let inv_h = 1.0 / fh;
410        let mut merged = merge_tiled_detections(self.dets, &self.cfg);
411        for d in &mut merged {
412            d.bbox.xmin *= inv_w;
413            d.bbox.xmax *= inv_w;
414            d.bbox.ymin *= inv_h;
415            d.bbox.ymax *= inv_h;
416        }
417        merged
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    fn det(b: [f32; 4], score: f32, label: usize) -> DetectBox {
426        DetectBox {
427            bbox: BoundingBox::new(b[0], b[1], b[2], b[3]),
428            score,
429            label,
430        }
431    }
432
433    // --- lift -------------------------------------------------------------
434
435    fn placement(origin: (f32, f32), crop: (f32, f32)) -> TilePlacement {
436        TilePlacement {
437            index: 0,
438            count: 1,
439            origin,
440            crop_size: crop,
441            letterbox: None,
442            frame_dims: (3840.0, 2160.0),
443        }
444    }
445
446    #[test]
447    fn lift_no_letterbox_matches_origin_plus_scale() {
448        let p = placement((100.0, 200.0), (640.0, 640.0));
449        let lifted = lift_tile_boxes(
450            vec![
451                det([0.0, 0.0, 1.0, 1.0], 0.9, 0),
452                det([0.25, 0.5, 0.75, 1.0], 0.8, 0),
453            ],
454            &p,
455        );
456        assert_eq!(lifted[0].bbox, BoundingBox::new(100.0, 200.0, 740.0, 840.0));
457        assert_eq!(lifted[1].bbox, BoundingBox::new(260.0, 520.0, 580.0, 840.0));
458    }
459
460    #[test]
461    fn lift_with_letterbox_inverts_then_scales() {
462        // A box filling the letterbox content region should, after un-padding,
463        // fill the crop and lift identically to the no-letterbox full-crop box.
464        let mut p = placement((0.0, 0.0), (640.0, 640.0));
465        p.letterbox = Some([0.1, 0.1, 0.9, 0.9]);
466        let lifted = lift_tile_boxes(vec![det([0.1, 0.1, 0.9, 0.9], 0.9, 0)], &p);
467        let b = lifted[0].bbox;
468        assert!((b.xmin - 0.0).abs() < 1e-3);
469        assert!((b.ymin - 0.0).abs() < 1e-3);
470        assert!((b.xmax - 640.0).abs() < 1e-3);
471        assert!((b.ymax - 640.0).abs() < 1e-3);
472    }
473
474    #[test]
475    fn lift_empty_is_empty() {
476        let p = placement((0.0, 0.0), (640.0, 640.0));
477        assert!(lift_tile_boxes(vec![], &p).is_empty());
478    }
479
480    #[test]
481    fn lift_roundtrip_with_letterbox() {
482        // Project a known full-frame box into tile-normalized (letterboxed)
483        // coords, then lift it back and confirm it returns to the original.
484        let p = TilePlacement {
485            index: 0,
486            count: 1,
487            origin: (100.0, 200.0),
488            crop_size: (640.0, 640.0),
489            letterbox: Some([0.1, 0.1, 0.9, 0.9]),
490            frame_dims: (1920.0, 1080.0),
491        };
492        // Full-frame target [228,328,420,520] -> crop-normalized (subtract origin,
493        // /crop) -> letterbox-normalized (scale by lb extent + offset).
494        let crop_n = [
495            (228.0 - 100.0) / 640.0,
496            (328.0 - 200.0) / 640.0,
497            (420.0 - 100.0) / 640.0,
498            (520.0 - 200.0) / 640.0,
499        ];
500        let [lx0, ly0, lx1, ly1] = [0.1, 0.1, 0.9, 0.9];
501        let model_n = det(
502            [
503                lx0 + crop_n[0] * (lx1 - lx0),
504                ly0 + crop_n[1] * (ly1 - ly0),
505                lx0 + crop_n[2] * (lx1 - lx0),
506                ly0 + crop_n[3] * (ly1 - ly0),
507            ],
508            0.9,
509            0,
510        );
511        let lifted = lift_tile_boxes(vec![model_n], &p);
512        let b = lifted[0].bbox;
513        assert!((b.xmin - 228.0).abs() < 1e-2, "xmin {}", b.xmin);
514        assert!((b.ymin - 328.0).abs() < 1e-2, "ymin {}", b.ymin);
515        assert!((b.xmax - 420.0).abs() < 1e-2, "xmax {}", b.xmax);
516        assert!((b.ymax - 520.0).abs() < 1e-2, "ymax {}", b.ymax);
517    }
518
519    #[test]
520    fn lift_letterbox_clamp_fires_and_no_div_by_zero() {
521        // A box outside the letterbox content region clamps to the edge; a
522        // degenerate (zero-span) letterbox axis must not divide by zero.
523        let p = TilePlacement {
524            index: 0,
525            count: 1,
526            origin: (0.0, 0.0),
527            crop_size: (640.0, 640.0),
528            letterbox: Some([0.1, 0.1, 0.9, 0.9]),
529            frame_dims: (640.0, 640.0),
530        };
531        // Box at the very top-left, outside [0.1,0.1] content origin.
532        let lifted = lift_tile_boxes(vec![det([0.0, 0.0, 0.05, 0.05], 0.9, 0)], &p);
533        assert_eq!(lifted[0].bbox.xmin, 0.0);
534        assert_eq!(lifted[0].bbox.ymin, 0.0);
535
536        // Degenerate letterbox (lx0 == lx1): unit scale, finite result.
537        let pd = TilePlacement {
538            letterbox: Some([0.5, 0.1, 0.5, 0.9]),
539            ..p
540        };
541        let out = lift_tile_boxes(vec![det([0.2, 0.2, 0.8, 0.8], 0.9, 0)], &pd);
542        assert!(out[0].bbox.xmin.is_finite() && out[0].bbox.xmax.is_finite());
543    }
544
545    // --- merge: the canonical IOS-vs-IOU case ----------------------------
546
547    #[test]
548    fn ios_merges_fragment_iou_does_not() {
549        // From modelpack tests/test_tiled_merge.py: B fully inside A
550        // (IoS=1.0, IoU=0.167).
551        let a = det([100.0, 100.0, 400.0, 300.0], 0.9, 0);
552        let b = det([350.0, 100.0, 400.0, 300.0], 0.7, 0);
553
554        let ios = merge_tiled_detections(
555            vec![a, b],
556            &MergeConfig {
557                metric: MatchMetric::Ios,
558                threshold: 0.5,
559                ..Default::default()
560            },
561        );
562        assert_eq!(ios.len(), 1);
563        assert_eq!(ios[0].bbox, BoundingBox::new(100.0, 100.0, 400.0, 300.0));
564        assert_eq!(ios[0].score, 0.9);
565
566        let iou = merge_tiled_detections(
567            vec![a, b],
568            &MergeConfig {
569                metric: MatchMetric::Iou,
570                threshold: 0.5,
571                ..Default::default()
572            },
573        );
574        assert_eq!(iou.len(), 2);
575    }
576
577    #[test]
578    fn merge_respects_class_unless_agnostic() {
579        let a = det([100.0, 100.0, 400.0, 300.0], 0.9, 0);
580        let b = det([350.0, 100.0, 400.0, 300.0], 0.7, 1); // different class
581
582        let aware = merge_tiled_detections(vec![a, b], &MergeConfig::default());
583        assert_eq!(aware.len(), 2);
584
585        let agnostic = merge_tiled_detections(
586            vec![a, b],
587            &MergeConfig {
588                class_agnostic: true,
589                ..Default::default()
590            },
591        );
592        assert_eq!(agnostic.len(), 1);
593        // Merged box keeps the base (highest-score) label.
594        assert_eq!(agnostic[0].label, 0);
595        assert_eq!(
596            agnostic[0].bbox,
597            BoundingBox::new(100.0, 100.0, 400.0, 300.0)
598        );
599    }
600
601    #[test]
602    fn merge_enclosing_union_for_partial_overlap() {
603        // Two boxes overlapping >=0.5 IoS merge to their enclosing union.
604        let a = det([0.0, 0.0, 100.0, 100.0], 0.9, 0);
605        let b = det([50.0, 0.0, 150.0, 100.0], 0.8, 0); // IoS = 0.5
606        let merged = merge_tiled_detections(
607            vec![a, b],
608            &MergeConfig {
609                metric: MatchMetric::Ios,
610                threshold: 0.5,
611                ..Default::default()
612            },
613        );
614        assert_eq!(merged.len(), 1);
615        assert_eq!(merged[0].bbox, BoundingBox::new(0.0, 0.0, 150.0, 100.0));
616        assert_eq!(merged[0].score, 0.9);
617    }
618
619    #[test]
620    fn merge_disjoint_boxes_pass_through() {
621        let a = det([0.0, 0.0, 10.0, 10.0], 0.9, 0);
622        let b = det([100.0, 100.0, 110.0, 110.0], 0.8, 0);
623        let merged = merge_tiled_detections(vec![a, b], &MergeConfig::default());
624        assert_eq!(merged.len(), 2);
625    }
626
627    #[test]
628    fn merge_empty_is_empty() {
629        assert!(merge_tiled_detections(vec![], &MergeConfig::default()).is_empty());
630    }
631
632    #[test]
633    fn merge_threshold_boundary_is_inclusive() {
634        // IoS exactly == threshold must merge (>=, not >).
635        let a = det([0.0, 0.0, 100.0, 100.0], 0.9, 0);
636        let b = det([50.0, 0.0, 150.0, 100.0], 0.8, 0); // IoS = 0.5 exactly
637        let merged = merge_tiled_detections(
638            vec![a, b],
639            &MergeConfig {
640                metric: MatchMetric::Ios,
641                threshold: 0.5,
642                ..Default::default()
643            },
644        );
645        assert_eq!(merged.len(), 1);
646    }
647
648    #[test]
649    fn merge_score_threshold_drops_low_groups() {
650        let a = det([0.0, 0.0, 10.0, 10.0], 0.3, 0);
651        let b = det([100.0, 100.0, 110.0, 110.0], 0.8, 0);
652        let merged = merge_tiled_detections(
653            vec![a, b],
654            &MergeConfig {
655                score_threshold: 0.5,
656                ..Default::default()
657            },
658        );
659        assert_eq!(merged.len(), 1);
660        assert_eq!(merged[0].score, 0.8);
661    }
662
663    #[test]
664    fn merge_max_det_caps_highest_scoring() {
665        let dets: Vec<DetectBox> = (0..10)
666            .map(|i| {
667                det(
668                    [i as f32 * 50.0, 0.0, i as f32 * 50.0 + 10.0, 10.0],
669                    1.0 - i as f32 * 0.05,
670                    0,
671                )
672            })
673            .collect();
674        let merged = merge_tiled_detections(
675            dets,
676            &MergeConfig {
677                max_det: 3,
678                ..Default::default()
679            },
680        );
681        assert_eq!(merged.len(), 3);
682        assert!(merged[0].score >= merged[1].score);
683        assert!(merged[1].score >= merged[2].score);
684    }
685
686    #[test]
687    fn merge_max_det_exact_boundary() {
688        // N disjoint boxes; max_det == N keeps all, max_det == N-1 drops one.
689        let make = || -> Vec<DetectBox> {
690            (0..5)
691                .map(|i| {
692                    det(
693                        [i as f32 * 50.0, 0.0, i as f32 * 50.0 + 10.0, 10.0],
694                        1.0 - i as f32 * 0.05,
695                        0,
696                    )
697                })
698                .collect()
699        };
700        assert_eq!(
701            merge_tiled_detections(
702                make(),
703                &MergeConfig {
704                    max_det: 5,
705                    ..Default::default()
706                }
707            )
708            .len(),
709            5
710        );
711        assert_eq!(
712            merge_tiled_detections(
713                make(),
714                &MergeConfig {
715                    max_det: 4,
716                    ..Default::default()
717                }
718            )
719            .len(),
720            4
721        );
722    }
723
724    #[test]
725    fn merge_score_threshold_boundary_is_inclusive() {
726        // A group whose max score == score_threshold is kept (>=).
727        let a = det([0.0, 0.0, 10.0, 10.0], 0.5, 0);
728        let merged = merge_tiled_detections(
729            vec![a],
730            &MergeConfig {
731                score_threshold: 0.5,
732                ..Default::default()
733            },
734        );
735        assert_eq!(merged.len(), 1);
736    }
737
738    // --- accumulator ------------------------------------------------------
739
740    fn empty_placement(index: usize, count: usize) -> TilePlacement {
741        TilePlacement {
742            index,
743            count,
744            origin: (0.0, 0.0),
745            crop_size: (640.0, 640.0),
746            letterbox: None,
747            frame_dims: (640.0, 640.0),
748        }
749    }
750
751    #[test]
752    fn accumulator_fan_in_fence() {
753        let mut acc = TiledFrameAccumulator::new((640.0, 640.0), 3, MergeConfig::default(), 8);
754        assert!(!acc.is_complete());
755        assert_eq!(acc.remaining(), 3);
756        assert!(acc.push_tile(vec![], &empty_placement(0, 3)));
757        assert!(acc.push_tile(vec![], &empty_placement(1, 3)));
758        assert!(!acc.is_complete());
759        assert_eq!(acc.remaining(), 1);
760        assert!(acc.push_tile(vec![], &empty_placement(2, 3)));
761        assert!(acc.is_complete());
762        assert_eq!(acc.remaining(), 0);
763        assert!(acc.finalize().is_empty());
764    }
765
766    #[test]
767    fn accumulator_dedups_and_ignores_overpush() {
768        let mut acc = TiledFrameAccumulator::new((640.0, 640.0), 2, MergeConfig::default(), 8);
769        assert!(acc.push_tile(vec![], &empty_placement(0, 2)));
770        // Duplicate index 0 is ignored (idempotent under at-least-once delivery).
771        assert!(!acc.push_tile(vec![], &empty_placement(0, 2)));
772        assert_eq!(acc.remaining(), 1);
773        assert!(!acc.is_complete());
774        assert!(acc.push_tile(vec![], &empty_placement(1, 2)));
775        assert!(acc.is_complete());
776        // Out-of-range index is ignored, never over-counts.
777        assert!(!acc.push_tile(vec![], &empty_placement(2, 2)));
778        assert_eq!(acc.remaining(), 0);
779    }
780
781    #[test]
782    fn accumulator_rejects_foreign_plan_count() {
783        // A placement from a different plan (count != tiles_total) is rejected
784        // at runtime — not just under debug_assert — so a mixed-frame mistake
785        // can't corrupt fan-in completion.
786        let mut acc = TiledFrameAccumulator::new((640.0, 640.0), 3, MergeConfig::default(), 8);
787        assert!(!acc.push_tile(vec![], &empty_placement(0, 4)));
788        assert_eq!(acc.remaining(), 3);
789        assert!(!acc.is_complete());
790        // The same index from the correct plan is still accepted afterward.
791        assert!(acc.push_tile(vec![], &empty_placement(0, 3)));
792        assert_eq!(acc.remaining(), 2);
793    }
794
795    #[test]
796    fn accumulator_out_of_order_equals_in_order() {
797        let cfg = MergeConfig::default();
798        let frame = (1280.0, 640.0);
799        // Two tiles side by side, a box near the seam in each.
800        let p0 = TilePlacement {
801            index: 0,
802            count: 2,
803            origin: (0.0, 0.0),
804            crop_size: (640.0, 640.0),
805            letterbox: None,
806            frame_dims: frame,
807        };
808        let p1 = TilePlacement {
809            index: 1,
810            count: 2,
811            origin: (640.0, 0.0),
812            crop_size: (640.0, 640.0),
813            letterbox: None,
814            frame_dims: frame,
815        };
816        let t0 = vec![det([0.9, 0.4, 1.0, 0.6], 0.8, 0)];
817        let t1 = vec![det([0.0, 0.4, 0.1, 0.6], 0.9, 0)];
818
819        let mut a = TiledFrameAccumulator::new(frame, 2, cfg, 8);
820        a.push_tile(t0.clone(), &p0);
821        a.push_tile(t1.clone(), &p1);
822        let in_order = a.finalize();
823
824        let mut b = TiledFrameAccumulator::new(frame, 2, cfg, 8);
825        b.push_tile(t1, &p1);
826        b.push_tile(t0, &p0);
827        let out_order = b.finalize();
828
829        assert_eq!(in_order.len(), out_order.len());
830        for (x, y) in in_order.iter().zip(out_order.iter()) {
831            assert_eq!(x.bbox, y.bbox);
832            assert_eq!(x.score, y.score);
833        }
834    }
835
836    #[test]
837    fn finalize_normalized_equals_finalize_over_frame_dims() {
838        let cfg = MergeConfig::default();
839        let frame = (1280.0, 640.0);
840        let p = TilePlacement {
841            index: 0,
842            count: 1,
843            origin: (100.0, 50.0),
844            crop_size: (640.0, 640.0),
845            letterbox: None,
846            frame_dims: frame,
847        };
848        let boxes = vec![det([0.1, 0.1, 0.4, 0.4], 0.9, 0)];
849
850        let mut a = TiledFrameAccumulator::new(frame, 1, cfg, 8);
851        a.push_tile(boxes.clone(), &p);
852        let px = a.finalize();
853
854        let mut b = TiledFrameAccumulator::new(frame, 1, cfg, 8);
855        b.push_tile(boxes, &p);
856        let norm = b.finalize_normalized();
857
858        assert_eq!(px.len(), norm.len());
859        let (fw, fh) = frame;
860        for (p, n) in px.iter().zip(norm.iter()) {
861            assert!((n.bbox.xmin - p.bbox.xmin / fw).abs() < 1e-4);
862            assert!((n.bbox.ymin - p.bbox.ymin / fh).abs() < 1e-4);
863            assert!((n.bbox.xmax - p.bbox.xmax / fw).abs() < 1e-4);
864            assert!((n.bbox.ymax - p.bbox.ymax / fh).abs() < 1e-4);
865        }
866    }
867
868    #[test]
869    fn finalize_normalized_rejects_invalid_frame_dims() {
870        let cfg = MergeConfig::default();
871        let boxes = vec![det([10.0, 10.0, 40.0, 40.0], 0.9, 0)];
872        for frame in [
873            (0.0, 640.0),
874            (1280.0, 0.0),
875            (f32::NAN, 640.0),
876            (1280.0, f32::INFINITY),
877        ] {
878            let p = TilePlacement {
879                index: 0,
880                count: 1,
881                origin: (0.0, 0.0),
882                crop_size: (640.0, 640.0),
883                letterbox: None,
884                frame_dims: frame,
885            };
886            let mut acc = TiledFrameAccumulator::new(frame, 1, cfg, 8);
887            acc.push_tile(boxes.clone(), &p);
888            assert!(
889                acc.finalize_normalized().is_empty(),
890                "expected empty for frame_dims={frame:?}"
891            );
892        }
893    }
894
895    // --- end-to-end: accumulator -> merge -> tracker ----------------------
896
897    /// One tile sees the whole object, an overlapping tile sees a contained
898    /// fragment of it. IOS merges them into a single full-frame detection that
899    /// the tracker resolves to ONE track; IOU leaves two, yielding TWO tracks
900    /// (the negative control proving IOS does its job, not that it collapses
901    /// everything).
902    #[cfg(feature = "tracker")]
903    #[test]
904    fn e2e_ios_one_track_iou_two_tracks() {
905        use edgefirst_tracker::{ByteTrackBuilder, Tracker};
906
907        // Two distinct tiles (indices 0 and 1) of a 2-tile frame, each lifted via
908        // a whole-frame placement so tile 0 yields the full object and tile 1 a
909        // contained fragment — simulating overlapping tiles seeing the same object.
910        let p0 = TilePlacement {
911            index: 0,
912            count: 2,
913            origin: (0.0, 0.0),
914            crop_size: (640.0, 640.0),
915            letterbox: None,
916            frame_dims: (640.0, 640.0),
917        };
918        let p1 = TilePlacement { index: 1, ..p0 };
919        let full = det(
920            [100.0 / 640.0, 100.0 / 640.0, 400.0 / 640.0, 300.0 / 640.0],
921            0.9,
922            0,
923        );
924        let frag = det(
925            [350.0 / 640.0, 100.0 / 640.0, 400.0 / 640.0, 300.0 / 640.0],
926            0.7,
927            0,
928        );
929
930        let run = |metric: MatchMetric| -> (usize, usize) {
931            let cfg = MergeConfig {
932                metric,
933                threshold: 0.5,
934                ..Default::default()
935            };
936            let mut acc = TiledFrameAccumulator::new((640.0, 640.0), 2, cfg, 4);
937            acc.push_tile(vec![full], &p0);
938            acc.push_tile(vec![frag], &p1);
939            let merged = acc.finalize_normalized();
940            let mut tracker = ByteTrackBuilder::new().build::<DetectBox>();
941            let _ = tracker.update(&merged, 1_000);
942            (merged.len(), tracker.get_active_tracks().len())
943        };
944
945        let (ios_merged, ios_tracks) = run(MatchMetric::Ios);
946        assert_eq!(ios_merged, 1, "IOS should merge the fragment");
947        assert_eq!(ios_tracks, 1, "merged detection yields one track");
948
949        let (iou_merged, iou_tracks) = run(MatchMetric::Iou);
950        assert_eq!(iou_merged, 2, "IOU should leave the fragment separate");
951        assert_eq!(iou_tracks, 2, "two detections yield two tracks");
952    }
953}