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