161 lines
5.1 KiB
Python
161 lines
5.1 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import math
|
|
import sys
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
|
|
def _worker_module() -> ModuleType:
|
|
worker_root = Path(__file__).parents[1] / "experiments" / "perception" / "worker"
|
|
path = worker_root / "run_e5_instance_tracking.py"
|
|
sys.path.insert(0, str(worker_root))
|
|
try:
|
|
spec = importlib.util.spec_from_file_location("e5_instance_tracking_worker", path)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
finally:
|
|
sys.path.pop(0)
|
|
|
|
|
|
def _profile_path() -> Path:
|
|
return (
|
|
Path(__file__).parents[1]
|
|
/ "experiments"
|
|
/ "perception"
|
|
/ "worker"
|
|
/ "e5_yolox_bytetrack_profile.json"
|
|
)
|
|
|
|
|
|
def _detection(*, score: float, x: float = 100, class_id: int = 0) -> dict[str, object]:
|
|
return {
|
|
"class_id": class_id,
|
|
"label": "person" if class_id == 0 else "car",
|
|
"score": score,
|
|
"bbox_xyxy": [x, 100.0, x + 60.0, 220.0],
|
|
"valid_fov_fraction": 1.0,
|
|
}
|
|
|
|
|
|
def test_profile_is_pinned_and_semantically_valid() -> None:
|
|
worker = _worker_module()
|
|
|
|
profile, digest = worker._read_profile(_profile_path())
|
|
|
|
assert len(digest) == 64
|
|
assert profile["model"]["id"] == "yolox_s"
|
|
assert profile["model"]["model_sha256"] == (
|
|
"c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063"
|
|
)
|
|
assert profile["tracking"]["algorithm"] == "bytetrack-style-two-stage-iou/v1"
|
|
assert profile["detection"]["target_class_ids"] == [0, 1, 2, 3, 5, 7]
|
|
|
|
|
|
def test_profile_rejects_duplicate_target_classes(tmp_path: Path) -> None:
|
|
worker = _worker_module()
|
|
profile = json.loads(_profile_path().read_text(encoding="utf-8"))
|
|
profile["detection"]["target_class_ids"] = [0, 0]
|
|
path = tmp_path / "profile.json"
|
|
path.write_text(json.dumps(profile), encoding="utf-8")
|
|
|
|
with pytest.raises(RuntimeError, match="target class"):
|
|
worker._read_profile(path)
|
|
|
|
|
|
def test_valid_fraction_uses_box_area_and_center_gate() -> None:
|
|
worker = _worker_module()
|
|
mask = np.zeros((10, 10), dtype=bool)
|
|
mask[2:8, 2:8] = True
|
|
integral = np.pad(mask.astype(np.int64), ((1, 0), (1, 0))).cumsum(0).cumsum(1)
|
|
|
|
fraction, center_inside, area = worker._valid_fraction(
|
|
np.asarray([1.0, 1.0, 5.0, 5.0]), integral
|
|
)
|
|
|
|
assert area == 16
|
|
assert fraction == pytest.approx(9 / 16)
|
|
assert center_inside is True
|
|
|
|
|
|
def test_yolox_decode_and_fov_filter_admit_one_person() -> None:
|
|
worker = _worker_module()
|
|
profile = json.loads(_profile_path().read_text(encoding="utf-8"))
|
|
output = np.zeros((1, 8400, 85), dtype=np.float32)
|
|
output[0, 0, :4] = [40.0, 30.0, math.log(10.0), math.log(10.0)]
|
|
output[0, 0, 4] = 0.9
|
|
output[0, 0, 5] = 0.9
|
|
|
|
detections, rejected = worker._detections(
|
|
output,
|
|
profile,
|
|
np.ones((600, 800), dtype=bool),
|
|
)
|
|
|
|
assert rejected == {}
|
|
assert len(detections) == 1
|
|
assert detections[0]["label"] == "person"
|
|
assert detections[0]["score"] == pytest.approx(0.81)
|
|
assert detections[0]["bbox_xyxy"] == pytest.approx([350.0, 250.0, 450.0, 350.0])
|
|
|
|
|
|
def test_nms_suppresses_lower_score_box_contained_inside_object() -> None:
|
|
worker = _worker_module()
|
|
boxes = np.asarray(
|
|
[
|
|
[0.0, 0.0, 100.0, 100.0],
|
|
[10.0, 10.0, 60.0, 60.0],
|
|
]
|
|
)
|
|
scores = np.asarray([0.9, 0.5])
|
|
|
|
assert worker._nms(boxes, scores, 0.45, 0.8) == [0]
|
|
|
|
|
|
def test_two_stage_tracker_keeps_id_through_low_score_detection() -> None:
|
|
worker = _worker_module()
|
|
profile = json.loads(_profile_path().read_text(encoding="utf-8"))
|
|
tracker = worker.TwoStageTracker(profile["tracking"])
|
|
|
|
assert tracker.update([_detection(score=0.8)], 0) == []
|
|
second = tracker.update([_detection(score=0.7, x=103)], 1)
|
|
third = tracker.update([_detection(score=0.15, x=106)], 2)
|
|
|
|
assert [track.track_id for track in second] == [1]
|
|
assert [track.track_id for track in third] == [1]
|
|
assert third[0].hits == 3
|
|
assert tracker.created == 1
|
|
|
|
|
|
def test_tracker_does_not_cross_match_classes() -> None:
|
|
worker = _worker_module()
|
|
profile = json.loads(_profile_path().read_text(encoding="utf-8"))
|
|
tracker = worker.TwoStageTracker(profile["tracking"])
|
|
|
|
tracker.update([_detection(score=0.8)], 0)
|
|
tracks = tracker.update([_detection(score=0.8, class_id=2)], 1)
|
|
|
|
assert tracks == []
|
|
assert tracker.created == 2
|
|
assert {track.class_id for track in tracker.tracks} == {0, 2}
|
|
|
|
|
|
def test_clip_timeline_may_start_at_nonzero_source_index(tmp_path: Path) -> None:
|
|
worker = _worker_module()
|
|
path = tmp_path / "timeline.jsonl"
|
|
rows = [
|
|
{"frame_index": 0, "source_frame_index": 1000, "session_seconds": 135.1},
|
|
{"frame_index": 1, "source_frame_index": 1001, "session_seconds": 135.2},
|
|
]
|
|
path.write_text("\n".join(json.dumps(row) for row in rows) + "\n", encoding="utf-8")
|
|
|
|
assert worker._read_timeline(path, 2) == rows
|