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