1use crate::float::{box_area, intersection_area, ios_value, iou_value};
54use crate::{BoundingBox, DetectBox};
55pub use edgefirst_decoder_abi::TilePlacement;
60use edgefirst_tensor::unletter_norm;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub enum MatchMetric {
66 Iou,
68 #[default]
71 Ios,
72}
73
74impl MatchMetric {
75 #[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#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct MergeConfig {
88 pub metric: MatchMetric,
90 pub threshold: f32,
92 pub class_agnostic: bool,
94 pub max_det: usize,
96 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#[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#[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#[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 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 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 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#[derive(Debug, Clone)]
279pub struct TiledFrameAccumulator {
280 frame_dims: (f32, f32),
281 tiles_total: usize,
282 seen: Vec<bool>,
286 received: usize,
287 dets: Vec<DetectBox>,
288 cfg: MergeConfig,
289}
290
291impl TiledFrameAccumulator {
292 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 pub fn push_tile(&mut self, tile_boxes: Vec<DetectBox>, placement: &TilePlacement) -> bool {
327 let idx = placement.index;
328 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 #[inline]
341 pub fn is_complete(&self) -> bool {
342 self.received >= self.tiles_total
343 }
344
345 #[inline]
347 pub fn remaining(&self) -> usize {
348 self.tiles_total.saturating_sub(self.received)
349 }
350
351 #[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 #[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 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 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 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 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 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 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 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 #[test]
528 fn ios_merges_fragment_iou_does_not() {
529 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); 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 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 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); 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 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); 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 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 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 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 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 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 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 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 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 #[cfg(feature = "tracker")]
883 #[test]
884 fn e2e_ios_one_track_iou_two_tracks() {
885 use edgefirst_tracker::{ByteTrackBuilder, Tracker};
886
887 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}