1use crate::float::{box_area, intersection_area, ios_value, iou_value};
54use crate::{BoundingBox, DetectBox};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum MatchMetric {
60 Iou,
62 #[default]
65 Ios,
66}
67
68impl MatchMetric {
69 #[inline]
71 pub fn value(self, a: &BoundingBox, b: &BoundingBox) -> f32 {
72 match self {
73 MatchMetric::Iou => iou_value(a, b),
74 MatchMetric::Ios => ios_value(a, b),
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct TilePlacement {
85 pub index: usize,
87 pub count: usize,
89 pub origin: (f32, f32),
91 pub crop_size: (f32, f32),
94 pub letterbox: Option<[f32; 4]>,
97 pub frame_dims: (f32, f32),
99}
100
101#[derive(Debug, Clone, Copy, PartialEq)]
103pub struct MergeConfig {
104 pub metric: MatchMetric,
106 pub threshold: f32,
108 pub class_agnostic: bool,
110 pub max_det: usize,
112 pub score_threshold: f32,
115}
116
117impl Default for MergeConfig {
118 fn default() -> Self {
119 Self {
120 metric: MatchMetric::Ios,
121 threshold: 0.5,
122 class_agnostic: false,
123 max_det: 300,
124 score_threshold: 0.0,
125 }
126 }
127}
128
129#[must_use]
140#[inline]
141pub fn unletter_norm(b: BoundingBox, lb: [f32; 4]) -> BoundingBox {
142 let b = b.to_canonical();
143 let [lx0, ly0, lx1, ly1] = lb;
144 let inv_w = if lx1 > lx0 { 1.0 / (lx1 - lx0) } else { 1.0 };
145 let inv_h = if ly1 > ly0 { 1.0 / (ly1 - ly0) } else { 1.0 };
146 BoundingBox {
147 xmin: ((b.xmin - lx0) * inv_w).clamp(0.0, 1.0),
148 ymin: ((b.ymin - ly0) * inv_h).clamp(0.0, 1.0),
149 xmax: ((b.xmax - lx0) * inv_w).clamp(0.0, 1.0),
150 ymax: ((b.ymax - ly0) * inv_h).clamp(0.0, 1.0),
151 }
152}
153
154#[must_use]
174pub fn lift_tile_boxes(mut boxes: Vec<DetectBox>, placement: &TilePlacement) -> Vec<DetectBox> {
175 let (ox, oy) = placement.origin;
176 let (cw, ch) = placement.crop_size;
177 for d in &mut boxes {
178 let n = match placement.letterbox {
179 Some(lb) => unletter_norm(d.bbox, lb),
180 None => d.bbox,
181 };
182 d.bbox = BoundingBox {
183 xmin: ox + n.xmin * cw,
184 ymin: oy + n.ymin * ch,
185 xmax: ox + n.xmax * cw,
186 ymax: oy + n.ymax * ch,
187 };
188 }
189 boxes
190}
191
192#[inline]
196fn metric_value_with_areas(
197 metric: MatchMetric,
198 a: &BoundingBox,
199 area_a: f32,
200 b: &BoundingBox,
201 area_b: f32,
202) -> f32 {
203 let inter = intersection_area(a, b);
204 let denom = match metric {
205 MatchMetric::Iou => area_a + area_b - inter,
206 MatchMetric::Ios => area_a.min(area_b),
207 };
208 inter / denom.max(1e-9)
209}
210
211#[must_use]
246pub fn merge_tiled_detections(dets: Vec<DetectBox>, cfg: &MergeConfig) -> Vec<DetectBox> {
247 if dets.is_empty() {
248 return dets;
249 }
250
251 let mut order: Vec<usize> = (0..dets.len()).collect();
253 order.sort_by(|&i, &j| dets[j].score.total_cmp(&dets[i].score).then(i.cmp(&j)));
254
255 let boxes: Vec<BoundingBox> = dets.iter().map(|d| d.bbox.to_canonical()).collect();
259 let areas: Vec<f32> = boxes.iter().map(box_area).collect();
260
261 let n = dets.len();
262 let mut used = vec![false; n];
263 let mut out: Vec<DetectBox> = Vec::with_capacity(n);
264
265 for oi in 0..n {
266 let i = order[oi];
267 if used[i] {
268 continue;
269 }
270 used[i] = true;
271 let base_box = boxes[i];
272 let base_area = areas[i];
273 let base_label = dets[i].label;
274 let mut acc = base_box;
275 let mut max_score = dets[i].score;
276
277 for &j in &order[(oi + 1)..] {
278 if used[j] {
279 continue;
280 }
281 if !cfg.class_agnostic && dets[j].label != base_label {
282 continue;
283 }
284 if metric_value_with_areas(cfg.metric, &base_box, base_area, &boxes[j], areas[j])
287 >= cfg.threshold
288 {
289 used[j] = true;
290 let c = boxes[j];
291 acc.xmin = acc.xmin.min(c.xmin);
292 acc.ymin = acc.ymin.min(c.ymin);
293 acc.xmax = acc.xmax.max(c.xmax);
294 acc.ymax = acc.ymax.max(c.ymax);
295 max_score = max_score.max(dets[j].score);
296 }
297 }
298
299 out.push(DetectBox {
300 bbox: acc,
301 score: max_score,
302 label: base_label,
303 });
304 }
305
306 if cfg.score_threshold > 0.0 {
307 out.retain(|d| d.score >= cfg.score_threshold);
308 }
309 out.truncate(cfg.max_det);
310 out
311}
312
313#[derive(Debug, Clone)]
319pub struct TiledFrameAccumulator {
320 frame_dims: (f32, f32),
321 tiles_total: usize,
322 seen: Vec<bool>,
326 received: usize,
327 dets: Vec<DetectBox>,
328 cfg: MergeConfig,
329}
330
331impl TiledFrameAccumulator {
332 pub fn new(
344 frame_dims: (f32, f32),
345 tiles_total: usize,
346 cfg: MergeConfig,
347 est_per_tile: usize,
348 ) -> Self {
349 Self {
350 frame_dims,
351 tiles_total,
352 seen: vec![false; tiles_total],
353 received: 0,
354 dets: Vec::with_capacity(tiles_total.saturating_mul(est_per_tile)),
355 cfg,
356 }
357 }
358
359 pub fn push_tile(&mut self, tile_boxes: Vec<DetectBox>, placement: &TilePlacement) -> bool {
367 let idx = placement.index;
368 if placement.count != self.tiles_total || idx >= self.tiles_total || self.seen[idx] {
371 return false;
372 }
373 self.seen[idx] = true;
374 self.dets.extend(lift_tile_boxes(tile_boxes, placement));
375 self.received += 1;
376 true
377 }
378
379 #[inline]
381 pub fn is_complete(&self) -> bool {
382 self.received >= self.tiles_total
383 }
384
385 #[inline]
387 pub fn remaining(&self) -> usize {
388 self.tiles_total.saturating_sub(self.received)
389 }
390
391 #[must_use]
393 pub fn finalize(self) -> Vec<DetectBox> {
394 merge_tiled_detections(self.dets, &self.cfg)
395 }
396
397 #[must_use]
403 pub fn finalize_normalized(self) -> Vec<DetectBox> {
404 let (fw, fh) = self.frame_dims;
405 if !(fw.is_finite() && fh.is_finite() && fw > 0.0 && fh > 0.0) {
406 return Vec::new();
407 }
408 let inv_w = 1.0 / fw;
409 let inv_h = 1.0 / fh;
410 let mut merged = merge_tiled_detections(self.dets, &self.cfg);
411 for d in &mut merged {
412 d.bbox.xmin *= inv_w;
413 d.bbox.xmax *= inv_w;
414 d.bbox.ymin *= inv_h;
415 d.bbox.ymax *= inv_h;
416 }
417 merged
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 fn det(b: [f32; 4], score: f32, label: usize) -> DetectBox {
426 DetectBox {
427 bbox: BoundingBox::new(b[0], b[1], b[2], b[3]),
428 score,
429 label,
430 }
431 }
432
433 fn placement(origin: (f32, f32), crop: (f32, f32)) -> TilePlacement {
436 TilePlacement {
437 index: 0,
438 count: 1,
439 origin,
440 crop_size: crop,
441 letterbox: None,
442 frame_dims: (3840.0, 2160.0),
443 }
444 }
445
446 #[test]
447 fn lift_no_letterbox_matches_origin_plus_scale() {
448 let p = placement((100.0, 200.0), (640.0, 640.0));
449 let lifted = lift_tile_boxes(
450 vec![
451 det([0.0, 0.0, 1.0, 1.0], 0.9, 0),
452 det([0.25, 0.5, 0.75, 1.0], 0.8, 0),
453 ],
454 &p,
455 );
456 assert_eq!(lifted[0].bbox, BoundingBox::new(100.0, 200.0, 740.0, 840.0));
457 assert_eq!(lifted[1].bbox, BoundingBox::new(260.0, 520.0, 580.0, 840.0));
458 }
459
460 #[test]
461 fn lift_with_letterbox_inverts_then_scales() {
462 let mut p = placement((0.0, 0.0), (640.0, 640.0));
465 p.letterbox = Some([0.1, 0.1, 0.9, 0.9]);
466 let lifted = lift_tile_boxes(vec![det([0.1, 0.1, 0.9, 0.9], 0.9, 0)], &p);
467 let b = lifted[0].bbox;
468 assert!((b.xmin - 0.0).abs() < 1e-3);
469 assert!((b.ymin - 0.0).abs() < 1e-3);
470 assert!((b.xmax - 640.0).abs() < 1e-3);
471 assert!((b.ymax - 640.0).abs() < 1e-3);
472 }
473
474 #[test]
475 fn lift_empty_is_empty() {
476 let p = placement((0.0, 0.0), (640.0, 640.0));
477 assert!(lift_tile_boxes(vec![], &p).is_empty());
478 }
479
480 #[test]
481 fn lift_roundtrip_with_letterbox() {
482 let p = TilePlacement {
485 index: 0,
486 count: 1,
487 origin: (100.0, 200.0),
488 crop_size: (640.0, 640.0),
489 letterbox: Some([0.1, 0.1, 0.9, 0.9]),
490 frame_dims: (1920.0, 1080.0),
491 };
492 let crop_n = [
495 (228.0 - 100.0) / 640.0,
496 (328.0 - 200.0) / 640.0,
497 (420.0 - 100.0) / 640.0,
498 (520.0 - 200.0) / 640.0,
499 ];
500 let [lx0, ly0, lx1, ly1] = [0.1, 0.1, 0.9, 0.9];
501 let model_n = det(
502 [
503 lx0 + crop_n[0] * (lx1 - lx0),
504 ly0 + crop_n[1] * (ly1 - ly0),
505 lx0 + crop_n[2] * (lx1 - lx0),
506 ly0 + crop_n[3] * (ly1 - ly0),
507 ],
508 0.9,
509 0,
510 );
511 let lifted = lift_tile_boxes(vec![model_n], &p);
512 let b = lifted[0].bbox;
513 assert!((b.xmin - 228.0).abs() < 1e-2, "xmin {}", b.xmin);
514 assert!((b.ymin - 328.0).abs() < 1e-2, "ymin {}", b.ymin);
515 assert!((b.xmax - 420.0).abs() < 1e-2, "xmax {}", b.xmax);
516 assert!((b.ymax - 520.0).abs() < 1e-2, "ymax {}", b.ymax);
517 }
518
519 #[test]
520 fn lift_letterbox_clamp_fires_and_no_div_by_zero() {
521 let p = TilePlacement {
524 index: 0,
525 count: 1,
526 origin: (0.0, 0.0),
527 crop_size: (640.0, 640.0),
528 letterbox: Some([0.1, 0.1, 0.9, 0.9]),
529 frame_dims: (640.0, 640.0),
530 };
531 let lifted = lift_tile_boxes(vec![det([0.0, 0.0, 0.05, 0.05], 0.9, 0)], &p);
533 assert_eq!(lifted[0].bbox.xmin, 0.0);
534 assert_eq!(lifted[0].bbox.ymin, 0.0);
535
536 let pd = TilePlacement {
538 letterbox: Some([0.5, 0.1, 0.5, 0.9]),
539 ..p
540 };
541 let out = lift_tile_boxes(vec![det([0.2, 0.2, 0.8, 0.8], 0.9, 0)], &pd);
542 assert!(out[0].bbox.xmin.is_finite() && out[0].bbox.xmax.is_finite());
543 }
544
545 #[test]
548 fn ios_merges_fragment_iou_does_not() {
549 let a = det([100.0, 100.0, 400.0, 300.0], 0.9, 0);
552 let b = det([350.0, 100.0, 400.0, 300.0], 0.7, 0);
553
554 let ios = merge_tiled_detections(
555 vec![a, b],
556 &MergeConfig {
557 metric: MatchMetric::Ios,
558 threshold: 0.5,
559 ..Default::default()
560 },
561 );
562 assert_eq!(ios.len(), 1);
563 assert_eq!(ios[0].bbox, BoundingBox::new(100.0, 100.0, 400.0, 300.0));
564 assert_eq!(ios[0].score, 0.9);
565
566 let iou = merge_tiled_detections(
567 vec![a, b],
568 &MergeConfig {
569 metric: MatchMetric::Iou,
570 threshold: 0.5,
571 ..Default::default()
572 },
573 );
574 assert_eq!(iou.len(), 2);
575 }
576
577 #[test]
578 fn merge_respects_class_unless_agnostic() {
579 let a = det([100.0, 100.0, 400.0, 300.0], 0.9, 0);
580 let b = det([350.0, 100.0, 400.0, 300.0], 0.7, 1); let aware = merge_tiled_detections(vec![a, b], &MergeConfig::default());
583 assert_eq!(aware.len(), 2);
584
585 let agnostic = merge_tiled_detections(
586 vec![a, b],
587 &MergeConfig {
588 class_agnostic: true,
589 ..Default::default()
590 },
591 );
592 assert_eq!(agnostic.len(), 1);
593 assert_eq!(agnostic[0].label, 0);
595 assert_eq!(
596 agnostic[0].bbox,
597 BoundingBox::new(100.0, 100.0, 400.0, 300.0)
598 );
599 }
600
601 #[test]
602 fn merge_enclosing_union_for_partial_overlap() {
603 let a = det([0.0, 0.0, 100.0, 100.0], 0.9, 0);
605 let b = det([50.0, 0.0, 150.0, 100.0], 0.8, 0); let merged = merge_tiled_detections(
607 vec![a, b],
608 &MergeConfig {
609 metric: MatchMetric::Ios,
610 threshold: 0.5,
611 ..Default::default()
612 },
613 );
614 assert_eq!(merged.len(), 1);
615 assert_eq!(merged[0].bbox, BoundingBox::new(0.0, 0.0, 150.0, 100.0));
616 assert_eq!(merged[0].score, 0.9);
617 }
618
619 #[test]
620 fn merge_disjoint_boxes_pass_through() {
621 let a = det([0.0, 0.0, 10.0, 10.0], 0.9, 0);
622 let b = det([100.0, 100.0, 110.0, 110.0], 0.8, 0);
623 let merged = merge_tiled_detections(vec![a, b], &MergeConfig::default());
624 assert_eq!(merged.len(), 2);
625 }
626
627 #[test]
628 fn merge_empty_is_empty() {
629 assert!(merge_tiled_detections(vec![], &MergeConfig::default()).is_empty());
630 }
631
632 #[test]
633 fn merge_threshold_boundary_is_inclusive() {
634 let a = det([0.0, 0.0, 100.0, 100.0], 0.9, 0);
636 let b = det([50.0, 0.0, 150.0, 100.0], 0.8, 0); let merged = merge_tiled_detections(
638 vec![a, b],
639 &MergeConfig {
640 metric: MatchMetric::Ios,
641 threshold: 0.5,
642 ..Default::default()
643 },
644 );
645 assert_eq!(merged.len(), 1);
646 }
647
648 #[test]
649 fn merge_score_threshold_drops_low_groups() {
650 let a = det([0.0, 0.0, 10.0, 10.0], 0.3, 0);
651 let b = det([100.0, 100.0, 110.0, 110.0], 0.8, 0);
652 let merged = merge_tiled_detections(
653 vec![a, b],
654 &MergeConfig {
655 score_threshold: 0.5,
656 ..Default::default()
657 },
658 );
659 assert_eq!(merged.len(), 1);
660 assert_eq!(merged[0].score, 0.8);
661 }
662
663 #[test]
664 fn merge_max_det_caps_highest_scoring() {
665 let dets: Vec<DetectBox> = (0..10)
666 .map(|i| {
667 det(
668 [i as f32 * 50.0, 0.0, i as f32 * 50.0 + 10.0, 10.0],
669 1.0 - i as f32 * 0.05,
670 0,
671 )
672 })
673 .collect();
674 let merged = merge_tiled_detections(
675 dets,
676 &MergeConfig {
677 max_det: 3,
678 ..Default::default()
679 },
680 );
681 assert_eq!(merged.len(), 3);
682 assert!(merged[0].score >= merged[1].score);
683 assert!(merged[1].score >= merged[2].score);
684 }
685
686 #[test]
687 fn merge_max_det_exact_boundary() {
688 let make = || -> Vec<DetectBox> {
690 (0..5)
691 .map(|i| {
692 det(
693 [i as f32 * 50.0, 0.0, i as f32 * 50.0 + 10.0, 10.0],
694 1.0 - i as f32 * 0.05,
695 0,
696 )
697 })
698 .collect()
699 };
700 assert_eq!(
701 merge_tiled_detections(
702 make(),
703 &MergeConfig {
704 max_det: 5,
705 ..Default::default()
706 }
707 )
708 .len(),
709 5
710 );
711 assert_eq!(
712 merge_tiled_detections(
713 make(),
714 &MergeConfig {
715 max_det: 4,
716 ..Default::default()
717 }
718 )
719 .len(),
720 4
721 );
722 }
723
724 #[test]
725 fn merge_score_threshold_boundary_is_inclusive() {
726 let a = det([0.0, 0.0, 10.0, 10.0], 0.5, 0);
728 let merged = merge_tiled_detections(
729 vec![a],
730 &MergeConfig {
731 score_threshold: 0.5,
732 ..Default::default()
733 },
734 );
735 assert_eq!(merged.len(), 1);
736 }
737
738 fn empty_placement(index: usize, count: usize) -> TilePlacement {
741 TilePlacement {
742 index,
743 count,
744 origin: (0.0, 0.0),
745 crop_size: (640.0, 640.0),
746 letterbox: None,
747 frame_dims: (640.0, 640.0),
748 }
749 }
750
751 #[test]
752 fn accumulator_fan_in_fence() {
753 let mut acc = TiledFrameAccumulator::new((640.0, 640.0), 3, MergeConfig::default(), 8);
754 assert!(!acc.is_complete());
755 assert_eq!(acc.remaining(), 3);
756 assert!(acc.push_tile(vec![], &empty_placement(0, 3)));
757 assert!(acc.push_tile(vec![], &empty_placement(1, 3)));
758 assert!(!acc.is_complete());
759 assert_eq!(acc.remaining(), 1);
760 assert!(acc.push_tile(vec![], &empty_placement(2, 3)));
761 assert!(acc.is_complete());
762 assert_eq!(acc.remaining(), 0);
763 assert!(acc.finalize().is_empty());
764 }
765
766 #[test]
767 fn accumulator_dedups_and_ignores_overpush() {
768 let mut acc = TiledFrameAccumulator::new((640.0, 640.0), 2, MergeConfig::default(), 8);
769 assert!(acc.push_tile(vec![], &empty_placement(0, 2)));
770 assert!(!acc.push_tile(vec![], &empty_placement(0, 2)));
772 assert_eq!(acc.remaining(), 1);
773 assert!(!acc.is_complete());
774 assert!(acc.push_tile(vec![], &empty_placement(1, 2)));
775 assert!(acc.is_complete());
776 assert!(!acc.push_tile(vec![], &empty_placement(2, 2)));
778 assert_eq!(acc.remaining(), 0);
779 }
780
781 #[test]
782 fn accumulator_rejects_foreign_plan_count() {
783 let mut acc = TiledFrameAccumulator::new((640.0, 640.0), 3, MergeConfig::default(), 8);
787 assert!(!acc.push_tile(vec![], &empty_placement(0, 4)));
788 assert_eq!(acc.remaining(), 3);
789 assert!(!acc.is_complete());
790 assert!(acc.push_tile(vec![], &empty_placement(0, 3)));
792 assert_eq!(acc.remaining(), 2);
793 }
794
795 #[test]
796 fn accumulator_out_of_order_equals_in_order() {
797 let cfg = MergeConfig::default();
798 let frame = (1280.0, 640.0);
799 let p0 = TilePlacement {
801 index: 0,
802 count: 2,
803 origin: (0.0, 0.0),
804 crop_size: (640.0, 640.0),
805 letterbox: None,
806 frame_dims: frame,
807 };
808 let p1 = TilePlacement {
809 index: 1,
810 count: 2,
811 origin: (640.0, 0.0),
812 crop_size: (640.0, 640.0),
813 letterbox: None,
814 frame_dims: frame,
815 };
816 let t0 = vec![det([0.9, 0.4, 1.0, 0.6], 0.8, 0)];
817 let t1 = vec![det([0.0, 0.4, 0.1, 0.6], 0.9, 0)];
818
819 let mut a = TiledFrameAccumulator::new(frame, 2, cfg, 8);
820 a.push_tile(t0.clone(), &p0);
821 a.push_tile(t1.clone(), &p1);
822 let in_order = a.finalize();
823
824 let mut b = TiledFrameAccumulator::new(frame, 2, cfg, 8);
825 b.push_tile(t1, &p1);
826 b.push_tile(t0, &p0);
827 let out_order = b.finalize();
828
829 assert_eq!(in_order.len(), out_order.len());
830 for (x, y) in in_order.iter().zip(out_order.iter()) {
831 assert_eq!(x.bbox, y.bbox);
832 assert_eq!(x.score, y.score);
833 }
834 }
835
836 #[test]
837 fn finalize_normalized_equals_finalize_over_frame_dims() {
838 let cfg = MergeConfig::default();
839 let frame = (1280.0, 640.0);
840 let p = TilePlacement {
841 index: 0,
842 count: 1,
843 origin: (100.0, 50.0),
844 crop_size: (640.0, 640.0),
845 letterbox: None,
846 frame_dims: frame,
847 };
848 let boxes = vec![det([0.1, 0.1, 0.4, 0.4], 0.9, 0)];
849
850 let mut a = TiledFrameAccumulator::new(frame, 1, cfg, 8);
851 a.push_tile(boxes.clone(), &p);
852 let px = a.finalize();
853
854 let mut b = TiledFrameAccumulator::new(frame, 1, cfg, 8);
855 b.push_tile(boxes, &p);
856 let norm = b.finalize_normalized();
857
858 assert_eq!(px.len(), norm.len());
859 let (fw, fh) = frame;
860 for (p, n) in px.iter().zip(norm.iter()) {
861 assert!((n.bbox.xmin - p.bbox.xmin / fw).abs() < 1e-4);
862 assert!((n.bbox.ymin - p.bbox.ymin / fh).abs() < 1e-4);
863 assert!((n.bbox.xmax - p.bbox.xmax / fw).abs() < 1e-4);
864 assert!((n.bbox.ymax - p.bbox.ymax / fh).abs() < 1e-4);
865 }
866 }
867
868 #[test]
869 fn finalize_normalized_rejects_invalid_frame_dims() {
870 let cfg = MergeConfig::default();
871 let boxes = vec![det([10.0, 10.0, 40.0, 40.0], 0.9, 0)];
872 for frame in [
873 (0.0, 640.0),
874 (1280.0, 0.0),
875 (f32::NAN, 640.0),
876 (1280.0, f32::INFINITY),
877 ] {
878 let p = TilePlacement {
879 index: 0,
880 count: 1,
881 origin: (0.0, 0.0),
882 crop_size: (640.0, 640.0),
883 letterbox: None,
884 frame_dims: frame,
885 };
886 let mut acc = TiledFrameAccumulator::new(frame, 1, cfg, 8);
887 acc.push_tile(boxes.clone(), &p);
888 assert!(
889 acc.finalize_normalized().is_empty(),
890 "expected empty for frame_dims={frame:?}"
891 );
892 }
893 }
894
895 #[cfg(feature = "tracker")]
903 #[test]
904 fn e2e_ios_one_track_iou_two_tracks() {
905 use edgefirst_tracker::{ByteTrackBuilder, Tracker};
906
907 let p0 = TilePlacement {
911 index: 0,
912 count: 2,
913 origin: (0.0, 0.0),
914 crop_size: (640.0, 640.0),
915 letterbox: None,
916 frame_dims: (640.0, 640.0),
917 };
918 let p1 = TilePlacement { index: 1, ..p0 };
919 let full = det(
920 [100.0 / 640.0, 100.0 / 640.0, 400.0 / 640.0, 300.0 / 640.0],
921 0.9,
922 0,
923 );
924 let frag = det(
925 [350.0 / 640.0, 100.0 / 640.0, 400.0 / 640.0, 300.0 / 640.0],
926 0.7,
927 0,
928 );
929
930 let run = |metric: MatchMetric| -> (usize, usize) {
931 let cfg = MergeConfig {
932 metric,
933 threshold: 0.5,
934 ..Default::default()
935 };
936 let mut acc = TiledFrameAccumulator::new((640.0, 640.0), 2, cfg, 4);
937 acc.push_tile(vec![full], &p0);
938 acc.push_tile(vec![frag], &p1);
939 let merged = acc.finalize_normalized();
940 let mut tracker = ByteTrackBuilder::new().build::<DetectBox>();
941 let _ = tracker.update(&merged, 1_000);
942 (merged.len(), tracker.get_active_tracks().len())
943 };
944
945 let (ios_merged, ios_tracks) = run(MatchMetric::Ios);
946 assert_eq!(ios_merged, 1, "IOS should merge the fragment");
947 assert_eq!(ios_tracks, 1, "merged detection yields one track");
948
949 let (iou_merged, iou_tracks) = run(MatchMetric::Iou);
950 assert_eq!(iou_merged, 2, "IOU should leave the fragment separate");
951 assert_eq!(iou_tracks, 2, "two detections yield two tracks");
952 }
953}