feat: improve ota packaging and people-flow runtime
This commit is contained in:
@@ -31,7 +31,7 @@ class CountingConfig:
|
||||
|
||||
@dataclass
|
||||
class AttributeConfig:
|
||||
enabled: bool = True
|
||||
enabled: bool = False
|
||||
sample_every_n_frames: int = 12
|
||||
max_samples_per_track: int = 5
|
||||
min_person_box_width: int = 80
|
||||
|
||||
@@ -27,6 +27,7 @@ from .webhook import dispatch_json_event
|
||||
from .worker_status import write_worker_status
|
||||
|
||||
SUPPORTED_EXTENSIONS = {".mp4", ".mov", ".mkv", ".avi"}
|
||||
LIVE_SUMMARY_INTERVAL_SECONDS = 15.0
|
||||
|
||||
|
||||
def discover_videos(root: Path, pattern: str = "*.mp4") -> list[Path]:
|
||||
@@ -40,10 +41,40 @@ def discover_videos(root: Path, pattern: str = "*.mp4") -> list[Path]:
|
||||
return sorted(videos)
|
||||
|
||||
|
||||
def resolve_inference_device(requested_device: str | None) -> str:
|
||||
device = (requested_device or "").strip()
|
||||
if not device:
|
||||
return "cpu"
|
||||
|
||||
normalized = device.lower()
|
||||
if normalized == "cpu":
|
||||
return "cpu"
|
||||
|
||||
if normalized.startswith("cuda") or normalized.replace(",", "").isdigit():
|
||||
try:
|
||||
import torch
|
||||
except ImportError:
|
||||
print(
|
||||
f"Requested inference device {device!r} but torch is unavailable; falling back to cpu.",
|
||||
flush=True,
|
||||
)
|
||||
return "cpu"
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
print(
|
||||
f"Requested inference device {device!r} but torch.cuda.is_available() is False; falling back to cpu.",
|
||||
flush=True,
|
||||
)
|
||||
return "cpu"
|
||||
|
||||
return device
|
||||
|
||||
|
||||
class PeopleFlowPipeline:
|
||||
def __init__(self, config: AppConfig, output_root: Path) -> None:
|
||||
self.config = config
|
||||
self.output_root = ensure_dir(output_root)
|
||||
self.inference_device = resolve_inference_device(self.config.yolo.device)
|
||||
self.model = self._load_model()
|
||||
|
||||
def _load_model(self) -> Any:
|
||||
@@ -174,6 +205,7 @@ class PeopleFlowPipeline:
|
||||
last_processed_at = 0.0
|
||||
last_processed_wall_time: datetime | None = None
|
||||
next_heartbeat_at = time.monotonic() + 60.0
|
||||
next_live_summary_at = time.monotonic()
|
||||
frame_index = 0
|
||||
capture = None
|
||||
pixel_line = None
|
||||
@@ -254,6 +286,7 @@ class PeopleFlowPipeline:
|
||||
window_index += 1
|
||||
window_start = window_end
|
||||
window_end = window_start + timedelta(seconds=window_seconds)
|
||||
next_live_summary_at = time.monotonic()
|
||||
if counter is not None:
|
||||
counter.reset()
|
||||
if queue_tracker is not None:
|
||||
@@ -329,6 +362,18 @@ class PeopleFlowPipeline:
|
||||
next_heartbeat_at = current_time + 60.0
|
||||
last_processed_wall_time = now
|
||||
frame_index += 1
|
||||
if current_time >= next_live_summary_at:
|
||||
self._write_live_rtsp_summary(
|
||||
latest_path=rtsp_paths["latest_json"],
|
||||
source=source,
|
||||
window_index=window_index,
|
||||
window_start=window_start,
|
||||
observed_at=now,
|
||||
counter=counter,
|
||||
attributes=attributes,
|
||||
queue_tracker=queue_tracker,
|
||||
)
|
||||
next_live_summary_at = current_time + LIVE_SUMMARY_INTERVAL_SECONDS
|
||||
update_status("processed_frame", force=True)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -350,7 +395,7 @@ class PeopleFlowPipeline:
|
||||
conf=self.config.yolo.conf,
|
||||
iou=self.config.yolo.iou,
|
||||
imgsz=self.config.yolo.imgsz,
|
||||
device=self.config.yolo.device,
|
||||
device=self.inference_device,
|
||||
verbose=False,
|
||||
classes=[0],
|
||||
)
|
||||
@@ -483,6 +528,8 @@ class PeopleFlowPipeline:
|
||||
counter: LineCrossCounter | None,
|
||||
attributes: AttributeAggregator,
|
||||
queue_tracker: QueueWindowTracker | None,
|
||||
*,
|
||||
commit_queue_level: bool = True,
|
||||
) -> dict:
|
||||
age_counts, gender_counts, unknown_attributes, track_summaries = (
|
||||
self._collect_track_summaries(
|
||||
@@ -492,7 +539,11 @@ class PeopleFlowPipeline:
|
||||
)
|
||||
total_people = 0 if counter is None else counter.total_people
|
||||
queue_metrics = (
|
||||
queue_tracker.build_queue_metrics(window_start, window_end)
|
||||
queue_tracker.build_queue_metrics(
|
||||
window_start,
|
||||
window_end,
|
||||
commit_queue_level=commit_queue_level,
|
||||
)
|
||||
if queue_tracker is not None and self.config.queue.enabled
|
||||
else {
|
||||
"queue_time_threshold_seconds": self.config.queue.queue_time_threshold_seconds,
|
||||
@@ -529,6 +580,31 @@ class PeopleFlowPipeline:
|
||||
"queue_metrics": queue_metrics,
|
||||
}
|
||||
|
||||
def _write_live_rtsp_summary(
|
||||
self,
|
||||
latest_path: Path,
|
||||
source: str,
|
||||
window_index: int,
|
||||
window_start: datetime,
|
||||
observed_at: datetime,
|
||||
counter: LineCrossCounter | None,
|
||||
attributes: AttributeAggregator,
|
||||
queue_tracker: QueueWindowTracker | None,
|
||||
) -> None:
|
||||
payload = self._build_rtsp_summary(
|
||||
source=source,
|
||||
window_index=window_index,
|
||||
window_start=window_start,
|
||||
window_end=observed_at,
|
||||
counter=counter,
|
||||
attributes=attributes,
|
||||
queue_tracker=queue_tracker,
|
||||
commit_queue_level=False,
|
||||
)
|
||||
payload["event"] = "rtsp_live_summary"
|
||||
payload["window_status"] = "in_progress"
|
||||
write_json(latest_path, payload)
|
||||
|
||||
def _finalize_summary(
|
||||
self,
|
||||
video_path: Path,
|
||||
|
||||
104
managed/people_flow_project/tests/test_pipeline.py
Normal file
104
managed/people_flow_project/tests/test_pipeline.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.people_flow.models import AttributeConfig
|
||||
from src.people_flow.pipeline import PeopleFlowPipeline, resolve_inference_device
|
||||
|
||||
|
||||
def test_resolve_inference_device_keeps_cpu():
|
||||
assert resolve_inference_device("cpu") == "cpu"
|
||||
|
||||
|
||||
def test_resolve_inference_device_falls_back_when_cuda_unavailable(monkeypatch):
|
||||
fake_torch = SimpleNamespace(
|
||||
cuda=SimpleNamespace(is_available=lambda: False),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
||||
|
||||
assert resolve_inference_device("cuda:0") == "cpu"
|
||||
|
||||
|
||||
def test_resolve_inference_device_keeps_cuda_when_available(monkeypatch):
|
||||
fake_torch = SimpleNamespace(
|
||||
cuda=SimpleNamespace(is_available=lambda: True),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
||||
|
||||
assert resolve_inference_device("cuda:0") == "cuda:0"
|
||||
|
||||
|
||||
def test_attribute_config_defaults_to_disabled():
|
||||
assert AttributeConfig().enabled is False
|
||||
|
||||
|
||||
def test_write_live_rtsp_summary_updates_latest_json(tmp_path: Path):
|
||||
pipeline = PeopleFlowPipeline.__new__(PeopleFlowPipeline)
|
||||
observed_at = datetime.fromisoformat("2026-05-18T10:45:18+08:00")
|
||||
window_start = datetime.fromisoformat("2026-05-18T10:30:00+08:00")
|
||||
|
||||
def fake_build_rtsp_summary(**kwargs):
|
||||
return {
|
||||
"event": "half_hour_report",
|
||||
"window_start": kwargs["window_start"].isoformat(),
|
||||
"window_end": kwargs["window_end"].isoformat(),
|
||||
"total_people": 4,
|
||||
"queue_metrics": {"queue_level": "normal"},
|
||||
}
|
||||
|
||||
pipeline._build_rtsp_summary = fake_build_rtsp_summary # type: ignore[method-assign]
|
||||
latest_path = tmp_path / "latest.json"
|
||||
|
||||
pipeline._write_live_rtsp_summary(
|
||||
latest_path=latest_path,
|
||||
source="rtsp://example",
|
||||
window_index=0,
|
||||
window_start=window_start,
|
||||
observed_at=observed_at,
|
||||
counter=None,
|
||||
attributes=SimpleNamespace(),
|
||||
queue_tracker=None,
|
||||
)
|
||||
|
||||
payload = json.loads(latest_path.read_text(encoding="utf-8"))
|
||||
assert payload["event"] == "rtsp_live_summary"
|
||||
assert payload["window_status"] == "in_progress"
|
||||
assert payload["window_start"] == window_start.isoformat()
|
||||
assert payload["window_end"] == observed_at.isoformat()
|
||||
|
||||
|
||||
def test_write_live_rtsp_summary_does_not_commit_queue_level(tmp_path: Path):
|
||||
pipeline = PeopleFlowPipeline.__new__(PeopleFlowPipeline)
|
||||
observed_at = datetime.fromisoformat("2026-05-18T10:45:18+08:00")
|
||||
window_start = datetime.fromisoformat("2026-05-18T10:30:00+08:00")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_build_rtsp_summary(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"event": "half_hour_report",
|
||||
"window_start": kwargs["window_start"].isoformat(),
|
||||
"window_end": kwargs["window_end"].isoformat(),
|
||||
"total_people": 4,
|
||||
"queue_metrics": {"queue_level": "normal"},
|
||||
}
|
||||
|
||||
pipeline._build_rtsp_summary = fake_build_rtsp_summary # type: ignore[method-assign]
|
||||
latest_path = tmp_path / "latest.json"
|
||||
|
||||
pipeline._write_live_rtsp_summary(
|
||||
latest_path=latest_path,
|
||||
source="rtsp://example",
|
||||
window_index=0,
|
||||
window_start=window_start,
|
||||
observed_at=observed_at,
|
||||
counter=None,
|
||||
attributes=SimpleNamespace(),
|
||||
queue_tracker=SimpleNamespace(),
|
||||
)
|
||||
|
||||
assert captured["commit_queue_level"] is False
|
||||
Reference in New Issue
Block a user