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}