fix: isolate per-camera tracker states in tensor-batched execution and add test_tracking
This commit is contained in:
1 parent
2205330679
commit
f5c6cff75e
4 files changed
+135
-141
No files matched your search
@@ -5,6 +5,10 @@ All notable changes to the `chicken-counting-sukawarna-det` project are document
|
||||
## [Unreleased] - 2026-08-19
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Multi-Camera Tracker State Isolation (`tracking.py` & `batch_runner.py`)**:
|
||||
- Implemented per-camera independent tracker instances keyed by `stream_id` in `DetectionTracker`.
|
||||
- Fixed tracker state bleed and track ID jumping in `tensor_batching` and multi-camera batch modes when camera frames are processed or when active camera sets shrink.
|
||||
- Added unit test suite `tests/test_tracking.py` covering multi-stream tracker isolation.
|
||||
- **Multi-Floor Script Syntax (`run_all_coops.sh`)**:
|
||||
- Resolved fatal `syntax error: unexpected end of file` caused by missing `done` in floor configuration discovery loop.
|
||||
- Expanded config discovery pattern from `K*-L*.yaml` to `*.yaml` to support custom named coops (e.g. `kandang-atas.yaml`).
|
||||
|
||||
@@ -193,7 +193,10 @@ def run_tensor_batched_daily_batch(
|
||||
break
|
||||
|
||||
if frame_index % inference_stride == 0 or any(not last_batch_tracks[active_cams[i][0]] for i in current_active):
|
||||
batch_tracks_list = shared_tracker.infer_batch(current_frames, crop_rects=current_crops)
|
||||
current_stream_ids = [active_cams[idx][0] for idx in current_active]
|
||||
batch_tracks_list = shared_tracker.infer_batch(
|
||||
current_frames, stream_ids=current_stream_ids, crop_rects=current_crops
|
||||
)
|
||||
for i, idx in enumerate(current_active):
|
||||
cam_id = active_cams[idx][0]
|
||||
last_batch_tracks[cam_id] = batch_tracks_list[i]
|
||||
@@ -455,7 +458,10 @@ def run_hybrid_daily_batch(
|
||||
|
||||
# 2. Batched GPU inference (Synchronous on main thread)
|
||||
if frame_index % inference_stride == 0 or any(not last_batch_tracks[active_cams[i][0]] for i in current_active_now):
|
||||
batch_tracks_list = shared_tracker.infer_batch(current_frames, crop_rects=current_crops)
|
||||
current_stream_ids = [active_cams[idx][0] for idx in current_active_now]
|
||||
batch_tracks_list = shared_tracker.infer_batch(
|
||||
current_frames, stream_ids=current_stream_ids, crop_rects=current_crops
|
||||
)
|
||||
for i, idx in enumerate(current_active_now):
|
||||
cam_id = active_cams[idx][0]
|
||||
last_batch_tracks[cam_id] = batch_tracks_list[i]
|
||||
|
||||
+87
-139
@@ -1,12 +1,16 @@
|
||||
"""Run YOLO detection and BoT-SORT tracking on each frame."""
|
||||
"""Run YOLO detection and BoT-SORT tracking on each frame with per-camera tracker isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import yaml
|
||||
from ultralytics import YOLO
|
||||
from ultralytics.trackers.track import TRACKER_MAP
|
||||
from ultralytics.utils import IterableSimpleNamespace
|
||||
|
||||
from chicken_counter.config import CameraConfig
|
||||
from chicken_counter.engine_utils import ensure_compatible_model
|
||||
@@ -32,6 +36,10 @@ class DetectionTracker:
|
||||
self.tracker_config_path = str(Path(config.tracker.tracker_config_path))
|
||||
self.verbose = config.performance.verbose
|
||||
self._infer_count = 0
|
||||
self._stream_trackers: dict[str, Any] = {}
|
||||
self._tracker_cfg_obj: IterableSimpleNamespace | None = None
|
||||
self._init_tracker_cfg()
|
||||
|
||||
print(
|
||||
f"[model] loaded {self.model_kind} from {model_path} "
|
||||
f"(imgsz={config.detection.imgsz}, device={config.detection.device})"
|
||||
@@ -39,7 +47,49 @@ class DetectionTracker:
|
||||
if self.model_kind == "engine":
|
||||
print("[model] TensorRT engine active; runtime half flag is ignored")
|
||||
|
||||
def reset_tracking(self) -> None:
|
||||
def _init_tracker_cfg(self) -> None:
|
||||
try:
|
||||
tracker_file = Path(self.tracker_config_path)
|
||||
if tracker_file.exists():
|
||||
with open(tracker_file, "r", encoding="utf-8") as f:
|
||||
raw_cfg = yaml.safe_load(f) or {}
|
||||
self._tracker_cfg_obj = IterableSimpleNamespace(**raw_cfg)
|
||||
self._tracker_cfg_obj.device = self.config.detection.device
|
||||
except Exception as exc:
|
||||
if self.verbose:
|
||||
print(f"[tracker] error reading tracker config: {exc}")
|
||||
self._tracker_cfg_obj = None
|
||||
|
||||
def get_or_create_tracker(self, stream_id: str = "default") -> Any:
|
||||
if stream_id not in self._stream_trackers:
|
||||
if self._tracker_cfg_obj is not None and self._tracker_cfg_obj.tracker_type in TRACKER_MAP:
|
||||
tracker_cls = TRACKER_MAP[self._tracker_cfg_obj.tracker_type]
|
||||
self._stream_trackers[stream_id] = tracker_cls(args=self._tracker_cfg_obj)
|
||||
else:
|
||||
default_args = IterableSimpleNamespace(
|
||||
tracker_type="botsort",
|
||||
track_high_thresh=0.5,
|
||||
track_low_thresh=0.1,
|
||||
new_track_thresh=0.6,
|
||||
track_buffer=self.config.tracker.track_buffer,
|
||||
match_thresh=0.8,
|
||||
fuse_score=True,
|
||||
gmc_method="none",
|
||||
proximity_thresh=0.5,
|
||||
appearance_thresh=0.25,
|
||||
with_reid=False,
|
||||
model="auto",
|
||||
device=self.config.detection.device,
|
||||
)
|
||||
self._stream_trackers[stream_id] = TRACKER_MAP["botsort"](args=default_args)
|
||||
return self._stream_trackers[stream_id]
|
||||
|
||||
def reset_tracking(self, stream_id: str | None = None) -> None:
|
||||
if stream_id:
|
||||
if stream_id in self._stream_trackers:
|
||||
del self._stream_trackers[stream_id]
|
||||
else:
|
||||
self._stream_trackers.clear()
|
||||
if hasattr(self.model, "predictor"):
|
||||
self.model.predictor = None
|
||||
|
||||
@@ -47,119 +97,25 @@ class DetectionTracker:
|
||||
self,
|
||||
frame: np.ndarray,
|
||||
*,
|
||||
stream_id: str = "default",
|
||||
crop_rect: tuple[int, int, int, int] | None = None,
|
||||
) -> list[TrackObservation]:
|
||||
offset_x = 0
|
||||
offset_y = 0
|
||||
source = frame
|
||||
if crop_rect is not None:
|
||||
x1, y1, x2, y2 = crop_rect
|
||||
source = frame[y1:y2, x1:x2]
|
||||
offset_x, offset_y = x1, y1
|
||||
|
||||
track_kwargs: dict = {
|
||||
"source": source,
|
||||
"persist": self.config.tracker.persist,
|
||||
"tracker": self.tracker_config_path,
|
||||
"conf": self.config.detection.conf,
|
||||
"iou": self.config.detection.iou,
|
||||
"classes": self.config.detection.classes,
|
||||
"imgsz": self.config.detection.imgsz,
|
||||
"verbose": False,
|
||||
"device": self.config.detection.device,
|
||||
}
|
||||
if self.model_kind != "engine" and self.config.performance.half:
|
||||
track_kwargs["half"] = True
|
||||
|
||||
if self.verbose:
|
||||
t_start = time.monotonic()
|
||||
|
||||
results = self.model.track(**track_kwargs)
|
||||
|
||||
if self.verbose:
|
||||
t_track = time.monotonic()
|
||||
self._infer_count += 1
|
||||
|
||||
if not results:
|
||||
if self.verbose:
|
||||
print(f"[tracker #{self._infer_count}] no detections (infer={t_track - t_start:.1f}ms)")
|
||||
return []
|
||||
|
||||
result = results[0]
|
||||
boxes = result.boxes
|
||||
if boxes is None or boxes.id is None:
|
||||
return []
|
||||
|
||||
ids = boxes.id.int().cpu().numpy()
|
||||
classes = boxes.cls.int().cpu().numpy()
|
||||
confidences = boxes.conf.cpu().numpy()
|
||||
xyxy = boxes.xyxy.int().cpu().numpy()
|
||||
|
||||
mask_polygons = None
|
||||
if result.masks is not None and result.masks.xy is not None:
|
||||
mask_polygons = result.masks.xy
|
||||
if len(mask_polygons) != len(boxes):
|
||||
raise RuntimeError(
|
||||
f"Ultralytics box/mask count mismatch: {len(boxes)} boxes, "
|
||||
f"{len(mask_polygons)} masks"
|
||||
)
|
||||
|
||||
tracks: list[TrackObservation] = []
|
||||
for index in range(len(boxes)):
|
||||
track_id = int(ids[index])
|
||||
class_id = int(classes[index])
|
||||
confidence = float(confidences[index])
|
||||
bbox = xyxy[index]
|
||||
x1 = int(bbox[0]) + offset_x
|
||||
y1 = int(bbox[1]) + offset_y
|
||||
x2 = int(bbox[2]) + offset_x
|
||||
y2 = int(bbox[3]) + offset_y
|
||||
centroid = ((x1 + x2) // 2, (y1 + y2) // 2)
|
||||
|
||||
if crop_rect is not None and not self._centroid_in_rect(centroid, crop_rect):
|
||||
continue
|
||||
|
||||
polygon = None
|
||||
if mask_polygons is not None:
|
||||
poly = np.asarray(mask_polygons[index], dtype=np.float64).copy()
|
||||
if poly.ndim == 2 and poly.shape[0] >= 3:
|
||||
poly[:, 0] += offset_x
|
||||
poly[:, 1] += offset_y
|
||||
polygon = poly
|
||||
|
||||
tracks.append(
|
||||
TrackObservation(
|
||||
track_id=track_id,
|
||||
class_id=class_id,
|
||||
confidence=confidence,
|
||||
bbox_xyxy=(x1, y1, x2, y2),
|
||||
centroid=centroid,
|
||||
mask_polygon_xy=polygon,
|
||||
)
|
||||
)
|
||||
|
||||
if self.verbose:
|
||||
unique_ids = sorted(set(t.track_id for t in tracks))
|
||||
confs = [t.confidence for t in tracks] if tracks else [0]
|
||||
print(
|
||||
f"[tracker #{self._infer_count}] "
|
||||
f"det={len(tracks)} unique={len(unique_ids)} "
|
||||
f"conf=[{min(confs):.2f}..{max(confs):.2f}] "
|
||||
f"ids={unique_ids[:10]}{'+' if len(unique_ids) > 10 else ''} "
|
||||
f"infer={t_track - t_start:.1f}ms"
|
||||
)
|
||||
|
||||
return tracks
|
||||
results = self.infer_batch([frame], stream_ids=[stream_id], crop_rects=[crop_rect])
|
||||
return results[0] if results else []
|
||||
|
||||
def infer_batch(
|
||||
self,
|
||||
frames: list[np.ndarray],
|
||||
*,
|
||||
stream_ids: list[str] | None = None,
|
||||
crop_rects: list[tuple[int, int, int, int] | None] | None = None,
|
||||
) -> list[list[TrackObservation]]:
|
||||
if not frames:
|
||||
return []
|
||||
|
||||
if stream_ids is None:
|
||||
stream_ids = [f"cam_{i}" for i in range(len(frames))]
|
||||
|
||||
sources = []
|
||||
offsets = []
|
||||
for i, frame in enumerate(frames):
|
||||
@@ -172,10 +128,7 @@ class DetectionTracker:
|
||||
sources.append(frame)
|
||||
offsets.append((0, 0))
|
||||
|
||||
track_kwargs: dict = {
|
||||
"source": sources,
|
||||
"persist": self.config.tracker.persist,
|
||||
"tracker": self.tracker_config_path,
|
||||
predict_kwargs: dict = {
|
||||
"conf": self.config.detection.conf,
|
||||
"iou": self.config.detection.iou,
|
||||
"classes": self.config.detection.classes,
|
||||
@@ -184,19 +137,19 @@ class DetectionTracker:
|
||||
"device": self.config.detection.device,
|
||||
}
|
||||
if self.model_kind != "engine" and self.config.performance.half:
|
||||
track_kwargs["half"] = True
|
||||
predict_kwargs["half"] = True
|
||||
|
||||
if self.verbose:
|
||||
t_start = time.monotonic()
|
||||
|
||||
try:
|
||||
results = self.model.track(**track_kwargs)
|
||||
results = self.model.predict(source=sources, **predict_kwargs)
|
||||
except Exception:
|
||||
results = []
|
||||
for src in sources:
|
||||
kw = dict(track_kwargs)
|
||||
kw = dict(predict_kwargs)
|
||||
kw["source"] = src
|
||||
res = self.model.track(**kw)
|
||||
res = self.model.predict(**kw)
|
||||
if res:
|
||||
results.append(res[0])
|
||||
|
||||
@@ -206,53 +159,48 @@ class DetectionTracker:
|
||||
|
||||
batch_tracks: list[list[TrackObservation]] = []
|
||||
for idx, result in enumerate(results):
|
||||
stream_id = stream_ids[idx] if idx < len(stream_ids) else f"stream_{idx}"
|
||||
tracker = self.get_or_create_tracker(stream_id)
|
||||
orig_src = sources[idx]
|
||||
offset_x, offset_y = offsets[idx]
|
||||
crop_rect = crop_rects[idx] if crop_rects and idx < len(crop_rects) else None
|
||||
|
||||
boxes = result.boxes
|
||||
if boxes is None or boxes.id is None:
|
||||
if boxes is None or len(boxes) == 0:
|
||||
tracker.update(np.empty((0, 6)), orig_src)
|
||||
batch_tracks.append([])
|
||||
continue
|
||||
|
||||
ids = boxes.id.int().cpu().numpy()
|
||||
classes = boxes.cls.int().cpu().numpy()
|
||||
confidences = boxes.conf.cpu().numpy()
|
||||
xyxy = boxes.xyxy.int().cpu().numpy()
|
||||
det_np = boxes.cpu().numpy()
|
||||
tracks_raw = tracker.update(det_np, orig_src)
|
||||
|
||||
mask_polygons = None
|
||||
if result.masks is not None and result.masks.xy is not None:
|
||||
mask_polygons = result.masks.xy
|
||||
if len(tracks_raw) == 0:
|
||||
batch_tracks.append([])
|
||||
continue
|
||||
|
||||
tracks: list[TrackObservation] = []
|
||||
for index in range(len(boxes)):
|
||||
track_id = int(ids[index])
|
||||
class_id = int(classes[index])
|
||||
confidence = float(confidences[index])
|
||||
bbox = xyxy[index]
|
||||
x1 = int(bbox[0]) + offset_x
|
||||
y1 = int(bbox[1]) + offset_y
|
||||
x2 = int(bbox[2]) + offset_x
|
||||
y2 = int(bbox[3]) + offset_y
|
||||
for row in tracks_raw:
|
||||
# Format: [x1, y1, x2, y2, track_id, conf, cls, idx]
|
||||
x1 = int(row[0]) + offset_x
|
||||
y1 = int(row[1]) + offset_y
|
||||
x2 = int(row[2]) + offset_x
|
||||
y2 = int(row[3]) + offset_y
|
||||
track_id = int(row[4])
|
||||
conf = float(row[5]) if len(row) > 5 else float(self.config.detection.conf)
|
||||
cls_id = int(row[6]) if len(row) > 6 else 0
|
||||
centroid = ((x1 + x2) // 2, (y1 + y2) // 2)
|
||||
|
||||
if crop_rect is not None and not self._centroid_in_rect(centroid, crop_rect):
|
||||
continue
|
||||
|
||||
polygon = None
|
||||
if mask_polygons is not None:
|
||||
poly = np.asarray(mask_polygons[index], dtype=np.float64).copy()
|
||||
if poly.ndim == 2 and poly.shape[0] >= 3:
|
||||
poly[:, 0] += offset_x
|
||||
poly[:, 1] += offset_y
|
||||
polygon = poly
|
||||
|
||||
tracks.append(
|
||||
TrackObservation(
|
||||
track_id=track_id,
|
||||
class_id=class_id,
|
||||
confidence=confidence,
|
||||
class_id=cls_id,
|
||||
confidence=conf,
|
||||
bbox_xyxy=(x1, y1, x2, y2),
|
||||
centroid=centroid,
|
||||
mask_polygon_xy=polygon,
|
||||
mask_polygon_xy=None,
|
||||
)
|
||||
)
|
||||
batch_tracks.append(tracks)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests for DetectionTracker multi-camera stream isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from chicken_counter.config import load_camera_config
|
||||
|
||||
|
||||
class MultiCameraTrackerIsolationTests(unittest.TestCase):
|
||||
def test_independent_stream_trackers(self) -> None:
|
||||
cfg = load_camera_config("configs/cameras/example_camera.yaml")
|
||||
|
||||
# Test get_or_create_tracker instantiates isolated instances for different stream IDs
|
||||
with patch("chicken_counter.tracking.ensure_compatible_model", return_value=cfg.detection.model_path), \
|
||||
patch("chicken_counter.tracking.YOLO"):
|
||||
from chicken_counter.tracking import DetectionTracker
|
||||
|
||||
tracker_manager = DetectionTracker(cfg)
|
||||
tr1 = tracker_manager.get_or_create_tracker("CC1")
|
||||
tr2 = tracker_manager.get_or_create_tracker("CC2")
|
||||
|
||||
self.assertIsNotNone(tr1)
|
||||
self.assertIsNotNone(tr2)
|
||||
self.assertIsNot(tr1, tr2, "Trackers for CC1 and CC2 must be distinct objects")
|
||||
|
||||
# Resetting CC1 should not delete CC2 tracker
|
||||
tracker_manager.reset_tracking("CC1")
|
||||
self.assertNotIn("CC1", tracker_manager._stream_trackers)
|
||||
self.assertIn("CC2", tracker_manager._stream_trackers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user