feat: improve ota packaging and people-flow runtime

This commit is contained in:
2026-05-19 15:44:00 +08:00
parent 6783be8db0
commit f3f40b5167
8 changed files with 452 additions and 29 deletions

View File

@@ -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

View File

@@ -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,

View 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