kerojohan commited on
Commit
81a2d8e
·
1 Parent(s): 737952c

Sync logic with bat_tracker v1.1.7

Browse files
Files changed (4) hide show
  1. app.py +1 -1
  2. bat_tracker/pipeline.py +164 -91
  3. bat_tracker/tracker.py +30 -21
  4. requirements.txt +1 -0
app.py CHANGED
@@ -13,7 +13,7 @@ import yaml
13
  from bat_tracker.pipeline import run_pipeline
14
 
15
 
16
- APP_VERSION = "v1.1.5"
17
  APP_TITLE = f"Bat Tracker {APP_VERSION}"
18
  APP_DESCRIPTION = (
19
  "Sube un video IR monocromo para ejecutar el pipeline, revisar la region valida "
 
13
  from bat_tracker.pipeline import run_pipeline
14
 
15
 
16
+ APP_VERSION = "v1.1.7"
17
  APP_TITLE = f"Bat Tracker {APP_VERSION}"
18
  APP_DESCRIPTION = (
19
  "Sube un video IR monocromo para ejecutar el pipeline, revisar la region valida "
bat_tracker/pipeline.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import csv
 
4
  import json
5
  import sys
6
  from collections import Counter, defaultdict, deque
@@ -146,6 +147,24 @@ def _classify_direction(start_inside: bool, end_inside: bool) -> str:
146
  return "outside"
147
 
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  def _infer_outside_direction_from_motion(
150
  start: TrackPoint,
151
  end: TrackPoint,
@@ -189,9 +208,7 @@ def _write_events_csv(
189
  if valid_mask is not None:
190
  s_in = _point_in_mask(start, valid_mask)
191
  e_in = _point_in_mask(end, valid_mask)
192
- direction = _classify_direction(s_in, e_in)
193
- if direction == "outside":
194
- direction = _infer_outside_direction_from_motion(start, end, valid_mask.shape[:2])
195
  else:
196
  s_in = None
197
  e_in = None
@@ -477,15 +494,13 @@ def _filter_track_points(
477
  if require_start_or_end_in_valid_region and gate_mask is not None:
478
  s_in = _point_in_mask(start, gate_mask)
479
  e_in = _point_in_mask(end, gate_mask)
480
- direction = _classify_direction(s_in, e_in)
481
  if not (s_in or e_in):
482
  reject_reasons.append("valid_region_gate")
483
  elif valid_mask is not None:
484
  s_in = _point_in_mask(start, valid_mask)
485
  e_in = _point_in_mask(end, valid_mask)
486
- direction = _classify_direction(s_in, e_in)
487
- if direction == "outside":
488
- direction = _infer_outside_direction_from_motion(start, end, valid_mask.shape[:2])
489
 
490
  accepted = not reject_reasons
491
  if not accepted:
@@ -565,6 +580,7 @@ def _auto_merge_track_points(points: List[TrackPoint], tracking_cfg: Dict) -> tu
565
  max_overlap_mean_dist = float(tracking_cfg.get("merge_overlap_max_mean_distance", 60.0))
566
  min_overlap_cos = float(tracking_cfg.get("merge_overlap_min_direction_cosine", 0.8))
567
  local_overlap_min_cos = max(0.65, min_overlap_cos - 0.15)
 
568
 
569
  parent: Dict[int, int] = {track_id: track_id for track_id in by_track}
570
 
@@ -586,93 +602,150 @@ def _auto_merge_track_points(points: List[TrackPoint], tracking_cfg: Dict) -> tu
586
 
587
  merges_applied: List[Dict] = []
588
  track_ids = sorted(by_track.keys())
589
- for idx, track_a_id in enumerate(track_ids):
590
- a_pts = by_track[track_a_id]
591
- a_start = a_pts[0]
592
- a_end = a_pts[-1]
593
- a_start_vec, a_end_vec = _track_edge_vectors(a_pts)
594
- a_frames = {p.frame: p for p in a_pts}
595
-
596
- for track_b_id in track_ids[idx + 1 :]:
597
- b_pts = by_track[track_b_id]
598
- b_start = b_pts[0]
599
- b_end = b_pts[-1]
600
- b_start_vec, b_end_vec = _track_edge_vectors(b_pts)
601
-
602
- reason = None
603
- reason_data: Dict[str, float | int] = {}
604
-
605
- if a_end.frame < b_start.frame:
606
- gap = b_start.frame - a_end.frame
607
- dist = hypot(b_start.x - a_end.x, b_start.y - a_end.y)
608
- if gap <= max_gap and dist <= max_endpoint_dist:
609
- reason = "handoff"
610
- reason_data = {"gap_frames": gap, "endpoint_distance": dist}
611
- elif b_end.frame < a_start.frame:
612
- gap = a_start.frame - b_end.frame
613
- dist = hypot(a_start.x - b_end.x, a_start.y - b_end.y)
614
- if gap <= max_gap and dist <= max_endpoint_dist:
615
- reason = "handoff"
616
- reason_data = {"gap_frames": gap, "endpoint_distance": dist}
617
- else:
618
- b_frames = {p.frame: p for p in b_pts}
619
- common_frames = sorted(set(a_frames.keys()).intersection(b_frames.keys()))
620
- if len(common_frames) >= 2:
621
- distances = []
622
- for frame in common_frames:
623
- pa = a_frames[frame]
624
- pb = b_frames[frame]
625
- distances.append(hypot(pa.x - pb.x, pa.y - pb.y))
626
-
627
- mean_distance = sum(distances) / len(distances)
628
- start_cos = _vector_cosine(a_start_vec, b_start_vec)
629
- end_cos = _vector_cosine(a_end_vec, b_end_vec)
630
- connector_cos = _vector_cosine(a_end_vec, b_start_vec)
631
- global_cosines = [c for c in (start_cos, end_cos) if c is not None]
632
- mean_cos = (sum(global_cosines) / len(global_cosines)) if global_cosines else None
633
-
634
- overlap_reason = None
635
- if len(common_frames) >= min_overlap_common:
636
- if mean_distance <= max_overlap_mean_dist and (
637
- mean_cos is None or mean_cos >= min_overlap_cos or connector_cos is not None and connector_cos >= min_overlap_cos
638
- ):
639
- overlap_reason = "overlap"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  elif (
641
  mean_distance <= max_overlap_mean_dist
642
- and connector_cos is not None
643
- and connector_cos >= local_overlap_min_cos
644
  ):
645
- overlap_reason = "overlap_local"
646
-
647
- if overlap_reason is not None:
648
- direction_score = connector_cos
649
- if direction_score is None:
650
- direction_score = mean_cos if mean_cos is not None else 1.0
651
- reason = overlap_reason
652
- reason_data = {
653
- "common_frames": len(common_frames),
654
- "mean_distance": mean_distance,
655
- "mean_direction_cosine": direction_score,
656
- }
657
-
658
- if reason is None:
659
- continue
660
-
661
- ra = find(track_a_id)
662
- rb = find(track_b_id)
663
- if ra == rb:
664
- continue
665
- union(track_a_id, track_b_id)
666
- merged_to = min(find(track_a_id), find(track_b_id))
667
- merges_applied.append(
668
- {
669
- "track_a": track_a_id,
670
- "track_b": track_b_id,
671
- "merged_to": merged_to,
672
- "reason": reason,
673
- **reason_data,
674
- }
675
- )
 
 
 
 
 
 
 
676
 
677
  remap: Dict[int, int] = {track_id: find(track_id) for track_id in track_ids}
678
  if all(src == dst for src, dst in remap.items()):
 
1
  from __future__ import annotations
2
 
3
  import csv
4
+ import heapq
5
  import json
6
  import sys
7
  from collections import Counter, defaultdict, deque
 
147
  return "outside"
148
 
149
 
150
+ def _classify_direction_full(
151
+ s_in: bool,
152
+ e_in: bool,
153
+ tps: List[TrackPoint],
154
+ valid_mask: np.ndarray | None,
155
+ frame_shape: tuple[int, int] | None = None,
156
+ ) -> str:
157
+ direction = _classify_direction(s_in, e_in)
158
+ if direction == "inside" and valid_mask is not None and len(tps) > 2:
159
+ for tp in tps[1:-1]:
160
+ if not _point_in_mask(tp, valid_mask):
161
+ direction = "exit"
162
+ break
163
+ if direction == "outside" and frame_shape is not None:
164
+ direction = _infer_outside_direction_from_motion(tps[0], tps[-1], frame_shape)
165
+ return direction
166
+
167
+
168
  def _infer_outside_direction_from_motion(
169
  start: TrackPoint,
170
  end: TrackPoint,
 
208
  if valid_mask is not None:
209
  s_in = _point_in_mask(start, valid_mask)
210
  e_in = _point_in_mask(end, valid_mask)
211
+ direction = _classify_direction_full(s_in, e_in, tps, valid_mask, valid_mask.shape[:2])
 
 
212
  else:
213
  s_in = None
214
  e_in = None
 
494
  if require_start_or_end_in_valid_region and gate_mask is not None:
495
  s_in = _point_in_mask(start, gate_mask)
496
  e_in = _point_in_mask(end, gate_mask)
497
+ direction = _classify_direction_full(s_in, e_in, track_points, valid_mask)
498
  if not (s_in or e_in):
499
  reject_reasons.append("valid_region_gate")
500
  elif valid_mask is not None:
501
  s_in = _point_in_mask(start, valid_mask)
502
  e_in = _point_in_mask(end, valid_mask)
503
+ direction = _classify_direction_full(s_in, e_in, track_points, valid_mask, valid_mask.shape[:2])
 
 
504
 
505
  accepted = not reject_reasons
506
  if not accepted:
 
580
  max_overlap_mean_dist = float(tracking_cfg.get("merge_overlap_max_mean_distance", 60.0))
581
  min_overlap_cos = float(tracking_cfg.get("merge_overlap_min_direction_cosine", 0.8))
582
  local_overlap_min_cos = max(0.65, min_overlap_cos - 0.15)
583
+ proximity_override_dist = float(tracking_cfg.get("merge_overlap_proximity_override_distance", 0.0))
584
 
585
  parent: Dict[int, int] = {track_id: track_id for track_id in by_track}
586
 
 
602
 
603
  merges_applied: List[Dict] = []
604
  track_ids = sorted(by_track.keys())
605
+
606
+ track_data: Dict[int, Dict] = {}
607
+ for tid in track_ids:
608
+ pts = by_track[tid]
609
+ start_vec, end_vec = _track_edge_vectors(pts)
610
+ track_data[tid] = {
611
+ "start": pts[0],
612
+ "end": pts[-1],
613
+ "start_vec": start_vec,
614
+ "end_vec": end_vec,
615
+ "frames": {p.frame: p for p in pts},
616
+ }
617
+
618
+ n_tracks = len(track_ids)
619
+ start_frames = [track_data[tid]["start"].frame for tid in track_ids]
620
+ end_frames = [track_data[tid]["end"].frame for tid in track_ids]
621
+ start_x = [track_data[tid]["start"].x for tid in track_ids]
622
+ start_y = [track_data[tid]["start"].y for tid in track_ids]
623
+ end_x = [track_data[tid]["end"].x for tid in track_ids]
624
+ end_y = [track_data[tid]["end"].y for tid in track_ids]
625
+ max_endpoint_dist_sq = max_endpoint_dist * max_endpoint_dist
626
+
627
+ sorted_positions = sorted(range(n_tracks), key=lambda idx: start_frames[idx])
628
+ heap: List[tuple[int, int]] = []
629
+ candidate_pairs: List[tuple[int, int]] = []
630
+
631
+ for b_pos in sorted_positions:
632
+ b_start_frame = start_frames[b_pos]
633
+ b_id = track_ids[b_pos]
634
+
635
+ while heap and heap[0][0] < b_start_frame - max_gap:
636
+ heapq.heappop(heap)
637
+
638
+ for a_end_frame, a_pos in heap:
639
+ a_id = track_ids[a_pos]
640
+ if a_end_frame < b_start_frame:
641
+ dx = end_x[a_pos] - start_x[b_pos]
642
+ dy = end_y[a_pos] - start_y[b_pos]
643
+ if dx * dx + dy * dy > max_endpoint_dist_sq:
644
+ continue
645
+ candidate_pairs.append((min(a_id, b_id), max(a_id, b_id)))
646
+
647
+ heapq.heappush(heap, (end_frames[b_pos], b_pos))
648
+
649
+ for track_a_id, track_b_id in candidate_pairs:
650
+ td_a = track_data[track_a_id]
651
+ td_b = track_data[track_b_id]
652
+ a_start = td_a["start"]
653
+ a_end = td_a["end"]
654
+ a_start_vec = td_a["start_vec"]
655
+ a_end_vec = td_a["end_vec"]
656
+ a_frames = td_a["frames"]
657
+ b_start = td_b["start"]
658
+ b_end = td_b["end"]
659
+ b_start_vec = td_b["start_vec"]
660
+ b_end_vec = td_b["end_vec"]
661
+
662
+ reason = None
663
+ reason_data: Dict[str, float | int] = {}
664
+
665
+ if a_end.frame < b_start.frame:
666
+ gap = b_start.frame - a_end.frame
667
+ dist = hypot(b_start.x - a_end.x, b_start.y - a_end.y)
668
+ if gap <= max_gap and dist <= max_endpoint_dist:
669
+ reason = "handoff"
670
+ reason_data = {"gap_frames": gap, "endpoint_distance": dist}
671
+ elif b_end.frame < a_start.frame:
672
+ gap = a_start.frame - b_end.frame
673
+ dist = hypot(a_start.x - b_end.x, a_start.y - b_end.y)
674
+ if gap <= max_gap and dist <= max_endpoint_dist:
675
+ reason = "handoff"
676
+ reason_data = {"gap_frames": gap, "endpoint_distance": dist}
677
+ else:
678
+ b_frames = td_b["frames"]
679
+ common_frames = sorted(set(a_frames.keys()).intersection(b_frames.keys()))
680
+ if len(common_frames) >= 1:
681
+ distances = []
682
+ for frame in common_frames:
683
+ pa = a_frames[frame]
684
+ pb = b_frames[frame]
685
+ distances.append(hypot(pa.x - pb.x, pa.y - pb.y))
686
+
687
+ mean_distance = sum(distances) / len(distances)
688
+ start_cos = _vector_cosine(a_start_vec, b_start_vec)
689
+ end_cos = _vector_cosine(a_end_vec, b_end_vec)
690
+ connector_cos = _vector_cosine(a_end_vec, b_start_vec)
691
+ global_cosines = [c for c in (start_cos, end_cos) if c is not None]
692
+ mean_cos = (sum(global_cosines) / len(global_cosines)) if global_cosines else None
693
+
694
+ overlap_reason = None
695
+ if (
696
+ proximity_override_dist > 0.0
697
+ and mean_distance <= proximity_override_dist
698
+ and len(common_frames) >= 1
699
+ ):
700
+ overlap_reason = "overlap_proximity"
701
+ elif len(common_frames) >= min_overlap_common:
702
+ if mean_distance <= max_overlap_mean_dist and (
703
+ mean_cos is None or mean_cos >= min_overlap_cos or connector_cos is not None and connector_cos >= min_overlap_cos
704
+ ):
705
+ overlap_reason = "overlap"
706
  elif (
707
  mean_distance <= max_overlap_mean_dist
708
+ and start_cos is not None
709
+ and start_cos >= local_overlap_min_cos
710
  ):
711
+ overlap_reason = "overlap_start_aligned"
712
+ elif (
713
+ len(common_frames) >= 1
714
+ and mean_distance <= max_overlap_mean_dist
715
+ and connector_cos is not None
716
+ and connector_cos >= local_overlap_min_cos
717
+ ):
718
+ overlap_reason = "overlap_local"
719
+
720
+ if overlap_reason is not None:
721
+ direction_score = connector_cos
722
+ if direction_score is None:
723
+ direction_score = mean_cos if mean_cos is not None else 1.0
724
+ reason = overlap_reason
725
+ reason_data = {
726
+ "common_frames": len(common_frames),
727
+ "mean_distance": mean_distance,
728
+ "mean_direction_cosine": direction_score,
729
+ }
730
+
731
+ if reason is None:
732
+ continue
733
+
734
+ ra = find(track_a_id)
735
+ rb = find(track_b_id)
736
+ if ra == rb:
737
+ continue
738
+ union(track_a_id, track_b_id)
739
+ merged_to = min(find(track_a_id), find(track_b_id))
740
+ merges_applied.append(
741
+ {
742
+ "track_a": track_a_id,
743
+ "track_b": track_b_id,
744
+ "merged_to": merged_to,
745
+ "reason": reason,
746
+ **reason_data,
747
+ }
748
+ )
749
 
750
  remap: Dict[int, int] = {track_id: find(track_id) for track_id in track_ids}
751
  if all(src == dst for src, dst in remap.items()):
bat_tracker/tracker.py CHANGED
@@ -3,6 +3,9 @@ from __future__ import annotations
3
  from dataclasses import dataclass
4
  from typing import Dict, List, Tuple
5
 
 
 
 
6
  from .detection import Detection
7
 
8
 
@@ -49,28 +52,34 @@ class GreedyTracker:
49
 
50
  unmatched_track_ids = set(self._active.keys())
51
  unmatched_det_idxs = set(range(len(detections)))
52
-
53
- candidate_pairs: List[Tuple[float, int, int]] = []
54
- max_distance_sq = self.max_distance_sq
55
- for track_id, track in self._active.items():
56
- dt_pred = max(1, frame_idx - track.last_frame) / self.fps
57
- pred_x = track.x + track.vx * dt_pred
58
- pred_y = track.y + track.vy * dt_pred
59
- for det_idx, det in enumerate(detections):
60
- dx = pred_x - det.x
61
- dy = pred_y - det.y
62
- d_sq = dx * dx + dy * dy
63
- if d_sq <= max_distance_sq:
64
- candidate_pairs.append((d_sq, track_id, det_idx))
65
-
66
- candidate_pairs.sort(key=lambda t: t[0])
67
  assignments: List[Tuple[int, int]] = []
68
-
69
- for _, track_id, det_idx in candidate_pairs:
70
- if track_id in unmatched_track_ids and det_idx in unmatched_det_idxs:
71
- assignments.append((track_id, det_idx))
72
- unmatched_track_ids.remove(track_id)
73
- unmatched_det_idxs.remove(det_idx)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  for track_id, det_idx in assignments:
76
  track = self._active[track_id]
 
3
  from dataclasses import dataclass
4
  from typing import Dict, List, Tuple
5
 
6
+ import numpy as np
7
+ from scipy.optimize import linear_sum_assignment
8
+
9
  from .detection import Detection
10
 
11
 
 
52
 
53
  unmatched_track_ids = set(self._active.keys())
54
  unmatched_det_idxs = set(range(len(detections)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  assignments: List[Tuple[int, int]] = []
56
+ if self._active and detections:
57
+ track_ids_list = list(self._active.keys())
58
+ n_tracks = len(track_ids_list)
59
+ n_dets = len(detections)
60
+ max_dist = self.max_distance
61
+ inf_cost = max_dist * 1e6
62
+
63
+ cost = np.full((n_tracks, n_dets), inf_cost, dtype=np.float64)
64
+ for i, track_id in enumerate(track_ids_list):
65
+ track = self._active[track_id]
66
+ dt_pred = max(1, frame_idx - track.last_frame) / self.fps
67
+ pred_x = track.x + track.vx * dt_pred
68
+ pred_y = track.y + track.vy * dt_pred
69
+ for j, det in enumerate(detections):
70
+ dx = pred_x - det.x
71
+ dy = pred_y - det.y
72
+ dist = (dx * dx + dy * dy) ** 0.5
73
+ if dist <= max_dist:
74
+ cost[i, j] = dist
75
+
76
+ row_ind, col_ind = linear_sum_assignment(cost)
77
+ for i, j in zip(row_ind, col_ind):
78
+ if cost[i, j] < inf_cost:
79
+ track_id = track_ids_list[i]
80
+ assignments.append((track_id, j))
81
+ unmatched_track_ids.discard(track_id)
82
+ unmatched_det_idxs.discard(j)
83
 
84
  for track_id, det_idx in assignments:
85
  track = self._active[track_id]
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
  numpy>=1.24
 
2
  opencv-python>=4.8
3
  matplotlib>=3.7
4
  PyYAML>=6.0
 
1
  numpy>=1.24
2
+ scipy>=1.10
3
  opencv-python>=4.8
4
  matplotlib>=3.7
5
  PyYAML>=6.0