Refactor store dwell alert management API and dwell engine
- Updated argument parsing in manage_api.py to include new threshold parameters. - Enhanced _config_payload to include thresholds and webhook configurations. - Modified _build_summary to track queue metrics and adjust alert reporting. - Refactored DwellEngine to utilize queue thresholds for alerting and reporting. - Added queue metrics calculations and status change tracking in dwell_engine.py. - Updated notifier.py to support posting JSON events to webhooks. - Adjusted example configuration to reflect new threshold parameters. - Enhanced Docker entrypoint script for better process management. - Updated tests to cover new queue metrics and thresholds. - Improved ManagedServiceDetail and ManagedServices Vue components to display queue metrics.
This commit is contained in:
@@ -8,8 +8,9 @@ import yaml
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Thresholds:
|
||||
min_people: int = 5
|
||||
min_dwell_seconds: int = 600
|
||||
queue_time_threshold_seconds: int = 300
|
||||
crowded_count_threshold: int = 5
|
||||
normal_count_threshold: int = 2
|
||||
pause_timeout_seconds: int = 300
|
||||
alert_cooldown_seconds: int = 600
|
||||
|
||||
@@ -30,6 +31,7 @@ class StaffConfig:
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WebhookConfig:
|
||||
url: str = ""
|
||||
alert_url: str = ""
|
||||
report_url: str = ""
|
||||
timeout_seconds: float = 5.0
|
||||
@@ -52,7 +54,16 @@ class AppConfig:
|
||||
|
||||
|
||||
def _load_section(raw: dict, key: str, cls):
|
||||
return cls(**raw.get(key, {}))
|
||||
payload = dict(raw.get(key, {}))
|
||||
if cls is Thresholds:
|
||||
if (
|
||||
"queue_time_threshold_seconds" not in payload
|
||||
and "min_dwell_seconds" in payload
|
||||
):
|
||||
payload["queue_time_threshold_seconds"] = payload["min_dwell_seconds"]
|
||||
if "crowded_count_threshold" not in payload and "min_people" in payload:
|
||||
payload["crowded_count_threshold"] = payload["min_people"]
|
||||
return cls(**payload)
|
||||
|
||||
|
||||
def resolve_config_path(config_path: str | Path | None = None) -> Path:
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.config import (
|
||||
from app.modules.detector_tracker import YOLOTrackerAdapter
|
||||
from app.modules.dwell_engine import DwellEngine
|
||||
from app.modules.identity_resolver import IdentityResolver
|
||||
from app.modules.notifier import append_json_event
|
||||
from app.modules.notifier import dispatch_json_event
|
||||
from app.modules.staff_filter import StaffMatcher, load_staff_gallery
|
||||
from app.modules.stream_reader import RTSPFrameReader
|
||||
|
||||
@@ -46,12 +46,20 @@ def build_app(config_path: str | Path | None = None) -> dict:
|
||||
),
|
||||
"dwell_engine": DwellEngine(
|
||||
camera_id=config.camera_id,
|
||||
min_people=config.thresholds.min_people,
|
||||
min_dwell_seconds=config.thresholds.min_dwell_seconds,
|
||||
queue_time_threshold_seconds=config.thresholds.queue_time_threshold_seconds,
|
||||
crowded_count_threshold=config.thresholds.crowded_count_threshold,
|
||||
normal_count_threshold=config.thresholds.normal_count_threshold,
|
||||
pause_timeout_seconds=config.thresholds.pause_timeout_seconds,
|
||||
alert_cooldown_seconds=config.thresholds.alert_cooldown_seconds,
|
||||
),
|
||||
"notifier": append_json_event,
|
||||
"notifier": lambda path, event: dispatch_json_event(
|
||||
path,
|
||||
event,
|
||||
webhook_url=config.webhook.url
|
||||
or config.webhook.report_url
|
||||
or config.webhook.alert_url,
|
||||
timeout_seconds=config.webhook.timeout_seconds,
|
||||
),
|
||||
"event_sink_path": event_sink_path,
|
||||
}
|
||||
|
||||
@@ -102,14 +110,18 @@ def run_forever(app: dict, max_frames: int | None = None) -> int:
|
||||
def parse_args() -> ArgumentParser:
|
||||
parser = ArgumentParser(description="Store dwell alert service bootstrap")
|
||||
parser.add_argument("--config", help="Path to YAML config file")
|
||||
parser.add_argument("--once", action="store_true", help="Read and process one frame")
|
||||
parser.add_argument(
|
||||
"--once", action="store_true", help="Read and process one frame"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manage-api",
|
||||
action="store_true",
|
||||
help="Start the management API instead of the RTSP worker loop",
|
||||
)
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host for the management API")
|
||||
parser.add_argument("--port", type=int, default=18081, help="Port for the management API")
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=18081, help="Port for the management API"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-frames",
|
||||
type=int,
|
||||
|
||||
@@ -17,7 +17,6 @@ from app.config import (
|
||||
save_config_document,
|
||||
)
|
||||
|
||||
|
||||
PROJECT_TYPE = "store_dwell_alert"
|
||||
DEFAULT_MANAGE_PORT = 18081
|
||||
MAX_PREVIEW_LINES = 2000
|
||||
@@ -136,7 +135,12 @@ def parse_args() -> ArgumentParser:
|
||||
parser = ArgumentParser(description="Store dwell alert management API")
|
||||
parser.add_argument("--config", help="Path to YAML config file")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host for the management API")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_MANAGE_PORT, help="Port for the management API")
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=DEFAULT_MANAGE_PORT,
|
||||
help="Port for the management API",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -160,9 +164,18 @@ def _config_payload(ctx: ManageContext) -> dict:
|
||||
"sample_fps": config.stream.sample_fps,
|
||||
"reconnect_backoff_seconds": config.stream.reconnect_backoff_seconds,
|
||||
},
|
||||
"thresholds": {
|
||||
"queue_time_threshold_seconds": config.thresholds.queue_time_threshold_seconds,
|
||||
"crowded_count_threshold": config.thresholds.crowded_count_threshold,
|
||||
"normal_count_threshold": config.thresholds.normal_count_threshold,
|
||||
},
|
||||
"event_sink": {
|
||||
"path": str(event_sink_path),
|
||||
},
|
||||
"webhook": {
|
||||
"url": config.webhook.url,
|
||||
"timeout_seconds": config.webhook.timeout_seconds,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -179,11 +192,13 @@ def _build_summary(ctx: ManageContext) -> dict:
|
||||
},
|
||||
}
|
||||
|
||||
alert_count = 0
|
||||
last_alert_time = ""
|
||||
last_report_time = ""
|
||||
active_count = 0
|
||||
longest_dwell_seconds = 0
|
||||
queue_level = ""
|
||||
over_threshold_count = 0
|
||||
under_threshold_count = 0
|
||||
status_change = ""
|
||||
window_stats: list[dict] = []
|
||||
|
||||
with events_path.open("r", encoding="utf-8") as handle:
|
||||
@@ -196,10 +211,7 @@ def _build_summary(ctx: ManageContext) -> dict:
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if payload.get("event") == "long_stay_alert":
|
||||
alert_count += 1
|
||||
last_alert_time = _string_value(payload.get("ts"))
|
||||
elif payload.get("event") == "half_hour_report":
|
||||
if payload.get("event") == "half_hour_report":
|
||||
last_report_time = _string_value(payload.get("window_end"))
|
||||
active_count = _int_value(payload.get("active_customer_count"))
|
||||
stat = _build_window_stat(payload)
|
||||
@@ -208,31 +220,36 @@ def _build_summary(ctx: ManageContext) -> dict:
|
||||
longest_dwell_seconds,
|
||||
stat["max_wait_seconds"],
|
||||
)
|
||||
queue_level = stat["queue_level"]
|
||||
over_threshold_count = stat["over_threshold_count"]
|
||||
under_threshold_count = stat["under_threshold_count"]
|
||||
status_change = stat["status_change"]
|
||||
|
||||
window_stats.sort(
|
||||
key=lambda item: _parse_timestamp(item["window_end"]),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
headline = "No alerts or reports yet"
|
||||
headline = "No reports yet"
|
||||
if last_report_time:
|
||||
headline = (
|
||||
"Latest report shows "
|
||||
f"{active_count} active customers, longest dwell {longest_dwell_seconds}s"
|
||||
f"{queue_level or 'unknown'} queue, "
|
||||
f"{over_threshold_count} over 5 min and {under_threshold_count} under 5 min"
|
||||
)
|
||||
elif alert_count > 0:
|
||||
headline = f"Observed {alert_count} alert(s), latest alert at {last_alert_time}"
|
||||
|
||||
return {
|
||||
"result_type": PROJECT_TYPE,
|
||||
"headline": headline,
|
||||
"last_result_time": _latest_timestamp(last_alert_time, last_report_time),
|
||||
"last_result_time": _latest_timestamp(last_report_time),
|
||||
"metrics": {
|
||||
"alert_count": alert_count,
|
||||
"last_alert_time": last_alert_time,
|
||||
"last_half_hour_report_time": last_report_time,
|
||||
"active_customer_count": active_count,
|
||||
"longest_dwell_seconds": longest_dwell_seconds,
|
||||
"queue_level": queue_level,
|
||||
"over_threshold_count": over_threshold_count,
|
||||
"under_threshold_count": under_threshold_count,
|
||||
"status_change": status_change,
|
||||
"events_path": str(events_path),
|
||||
"recent_window_stats": window_stats[:24],
|
||||
"all_window_stats": window_stats,
|
||||
@@ -260,7 +277,9 @@ def _list_result_files(ctx: ManageContext) -> list[dict]:
|
||||
"label": label,
|
||||
"kind": path.suffix.lstrip(".").lower(),
|
||||
"size": info.st_size,
|
||||
"modified_at": datetime.fromtimestamp(info.st_mtime).astimezone().isoformat(),
|
||||
"modified_at": datetime.fromtimestamp(info.st_mtime)
|
||||
.astimezone()
|
||||
.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -279,12 +298,21 @@ def _build_window_stat(payload: dict) -> dict:
|
||||
payload.get("closed_customers"),
|
||||
"final_dwell_seconds",
|
||||
)
|
||||
queue_metrics = (
|
||||
payload.get("queue_metrics")
|
||||
if isinstance(payload.get("queue_metrics"), dict)
|
||||
else {}
|
||||
)
|
||||
return {
|
||||
"window_start": _string_value(payload.get("window_start")),
|
||||
"window_end": _string_value(payload.get("window_end")),
|
||||
"active_customer_count": _int_value(payload.get("active_customer_count")),
|
||||
"active_wait_seconds": active_wait_seconds,
|
||||
"closed_wait_seconds": closed_wait_seconds,
|
||||
"queue_level": _string_value(queue_metrics.get("queue_level")),
|
||||
"over_threshold_count": _int_value(queue_metrics.get("over_threshold_count")),
|
||||
"under_threshold_count": _int_value(queue_metrics.get("under_threshold_count")),
|
||||
"status_change": _string_value(queue_metrics.get("status_change")),
|
||||
"max_wait_seconds": max(
|
||||
max(active_wait_seconds, default=0),
|
||||
max(closed_wait_seconds, default=0),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from app.modules.reporter import floor_half_hour, previous_half_hour_window
|
||||
@@ -18,6 +18,7 @@ class DwellSession:
|
||||
last_seen_at: datetime | None = None
|
||||
pause_started_at: datetime | None = None
|
||||
closed_at: datetime | None = None
|
||||
completed_periods: list[tuple[datetime, datetime]] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.active_started_at is None:
|
||||
@@ -46,6 +47,7 @@ class DwellSession:
|
||||
def pause(self, when: datetime) -> None:
|
||||
if self.state != "active" or self.active_started_at is None:
|
||||
return
|
||||
self.completed_periods.append((self.active_started_at, when))
|
||||
self.accumulated_dwell_seconds += max(
|
||||
0,
|
||||
int((when - self.active_started_at).total_seconds()),
|
||||
@@ -73,19 +75,41 @@ class DwellSession:
|
||||
"dwell_seconds": self.dwell_seconds(when),
|
||||
}
|
||||
|
||||
def window_dwell_seconds(
|
||||
self,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
when: datetime | None = None,
|
||||
) -> int:
|
||||
total = 0
|
||||
for period_start, period_end in self.completed_periods:
|
||||
total += _overlap_seconds(
|
||||
period_start, period_end, window_start, window_end
|
||||
)
|
||||
|
||||
if self.state == "active" and self.active_started_at is not None:
|
||||
current_end = when or self.last_seen_at or window_end
|
||||
total += _overlap_seconds(
|
||||
self.active_started_at, current_end, window_start, window_end
|
||||
)
|
||||
|
||||
return total
|
||||
|
||||
|
||||
class DwellEngine:
|
||||
def __init__(
|
||||
self,
|
||||
camera_id: str,
|
||||
min_people: int,
|
||||
min_dwell_seconds: int,
|
||||
queue_time_threshold_seconds: int,
|
||||
crowded_count_threshold: int,
|
||||
normal_count_threshold: int,
|
||||
pause_timeout_seconds: int,
|
||||
alert_cooldown_seconds: int,
|
||||
) -> None:
|
||||
self.camera_id = camera_id
|
||||
self.min_people = min_people
|
||||
self.min_dwell_seconds = min_dwell_seconds
|
||||
self.queue_time_threshold_seconds = queue_time_threshold_seconds
|
||||
self.crowded_count_threshold = crowded_count_threshold
|
||||
self.normal_count_threshold = normal_count_threshold
|
||||
self.pause_timeout_seconds = pause_timeout_seconds
|
||||
self.alert_cooldown_seconds = alert_cooldown_seconds
|
||||
self.sessions: dict[str, DwellSession] = {}
|
||||
@@ -94,13 +118,16 @@ class DwellEngine:
|
||||
self.alert_rearmed = True
|
||||
self.last_alert_at: datetime | None = None
|
||||
self.last_report_boundary: datetime | None = None
|
||||
self.last_queue_level: str | None = None
|
||||
|
||||
def _next_session_id(self, person_id: str) -> str:
|
||||
next_index = self.session_counts.get(person_id, 0) + 1
|
||||
self.session_counts[person_id] = next_index
|
||||
return f"{person_id}-s{next_index}"
|
||||
|
||||
def _create_session(self, person_id: str, role: str, when: datetime) -> DwellSession:
|
||||
def _create_session(
|
||||
self, person_id: str, role: str, when: datetime
|
||||
) -> DwellSession:
|
||||
session = DwellSession(
|
||||
person_id=person_id,
|
||||
session_id=self._next_session_id(person_id),
|
||||
@@ -110,7 +137,9 @@ class DwellEngine:
|
||||
self.sessions[person_id] = session
|
||||
return session
|
||||
|
||||
def process_observations(self, observations: list[dict], when: datetime) -> list[dict]:
|
||||
def process_observations(
|
||||
self, observations: list[dict], when: datetime
|
||||
) -> list[dict]:
|
||||
events: list[dict] = []
|
||||
seen_people: set[str] = set()
|
||||
|
||||
@@ -151,12 +180,12 @@ class DwellEngine:
|
||||
for session in self.sessions.values()
|
||||
if session.role == "customer"
|
||||
and session.state == "active"
|
||||
and session.dwell_seconds(when) >= self.min_dwell_seconds
|
||||
and session.dwell_seconds(when) >= self.queue_time_threshold_seconds
|
||||
]
|
||||
|
||||
def _build_alert_event(self, when: datetime) -> dict | None:
|
||||
long_stay_sessions = self._active_customer_sessions(when)
|
||||
if len(long_stay_sessions) < self.min_people:
|
||||
if len(long_stay_sessions) < self.crowded_count_threshold:
|
||||
self.alert_rearmed = True
|
||||
return None
|
||||
if not self.alert_rearmed:
|
||||
@@ -168,8 +197,8 @@ class DwellEngine:
|
||||
"camera_id": self.camera_id,
|
||||
"ts": when.isoformat(),
|
||||
"threshold": {
|
||||
"min_people": self.min_people,
|
||||
"min_dwell_seconds": self.min_dwell_seconds,
|
||||
"min_people": self.crowded_count_threshold,
|
||||
"min_dwell_seconds": self.queue_time_threshold_seconds,
|
||||
},
|
||||
"active_long_stay_count": len(long_stay_sessions),
|
||||
"people": [
|
||||
@@ -192,8 +221,15 @@ class DwellEngine:
|
||||
return None
|
||||
|
||||
window_start, window_end = previous_half_hour_window(when)
|
||||
queue_totals = self._queue_totals(window_start, window_end, when)
|
||||
queue_metrics = self._build_queue_metrics(queue_totals)
|
||||
active_customers = [
|
||||
session.as_event_dict(when)
|
||||
{
|
||||
**session.as_event_dict(when),
|
||||
"window_queue_seconds": session.window_dwell_seconds(
|
||||
window_start, window_end, when
|
||||
),
|
||||
}
|
||||
for session in self.sessions.values()
|
||||
if session.role == "customer" and session.state == "active"
|
||||
]
|
||||
@@ -202,25 +238,147 @@ class DwellEngine:
|
||||
"person_id": session.person_id,
|
||||
"session_id": session.session_id,
|
||||
"final_dwell_seconds": session.dwell_seconds(window_end),
|
||||
"window_queue_seconds": session.window_dwell_seconds(
|
||||
window_start, window_end, window_end
|
||||
),
|
||||
}
|
||||
for session in self.closed_sessions
|
||||
if session.role == "customer"
|
||||
and session.closed_at is not None
|
||||
and window_start < session.closed_at <= window_end
|
||||
]
|
||||
staff_seen_count = sum(1 for session in self.sessions.values() if session.role == "staff")
|
||||
staff_seen_count = sum(
|
||||
1 for session in self.sessions.values() if session.role == "staff"
|
||||
)
|
||||
self.last_report_boundary = boundary
|
||||
return {
|
||||
"event": "half_hour_report",
|
||||
"project_type": "store_dwell_alert",
|
||||
"camera_id": self.camera_id,
|
||||
"source_id": self.camera_id,
|
||||
"window_start": window_start.isoformat(),
|
||||
"window_end": window_end.isoformat(),
|
||||
"active_customer_count": len(active_customers),
|
||||
"active_customers": active_customers,
|
||||
"closed_customers": closed_customers,
|
||||
"staff_seen_count": staff_seen_count,
|
||||
"queue_metrics": queue_metrics,
|
||||
}
|
||||
|
||||
def _queue_totals(
|
||||
self,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
when: datetime,
|
||||
) -> dict[str, int]:
|
||||
totals: dict[str, int] = {}
|
||||
for session in self.closed_sessions:
|
||||
if session.role != "customer":
|
||||
continue
|
||||
window_seconds = session.window_dwell_seconds(
|
||||
window_start, window_end, window_end
|
||||
)
|
||||
if window_seconds > 0:
|
||||
totals[session.person_id] = (
|
||||
totals.get(session.person_id, 0) + window_seconds
|
||||
)
|
||||
|
||||
for session in self.sessions.values():
|
||||
if session.role != "customer":
|
||||
continue
|
||||
window_seconds = session.window_dwell_seconds(
|
||||
window_start, window_end, when
|
||||
)
|
||||
if window_seconds > 0:
|
||||
totals[session.person_id] = (
|
||||
totals.get(session.person_id, 0) + window_seconds
|
||||
)
|
||||
|
||||
return totals
|
||||
|
||||
def _build_queue_metrics(self, queue_totals: dict[str, int]) -> dict:
|
||||
over_threshold_count = sum(
|
||||
1
|
||||
for seconds in queue_totals.values()
|
||||
if seconds >= self.queue_time_threshold_seconds
|
||||
)
|
||||
under_threshold_count = sum(
|
||||
1
|
||||
for seconds in queue_totals.values()
|
||||
if 0 < seconds < self.queue_time_threshold_seconds
|
||||
)
|
||||
queue_level = _queue_level(
|
||||
over_threshold_count,
|
||||
crowded_count_threshold=self.crowded_count_threshold,
|
||||
normal_count_threshold=self.normal_count_threshold,
|
||||
)
|
||||
previous_queue_level = self.last_queue_level
|
||||
status_change = _queue_status_change(previous_queue_level, queue_level)
|
||||
self.last_queue_level = queue_level
|
||||
return {
|
||||
"queue_time_threshold_seconds": self.queue_time_threshold_seconds,
|
||||
"over_threshold_count": over_threshold_count,
|
||||
"under_threshold_count": under_threshold_count,
|
||||
"queue_level": queue_level,
|
||||
"previous_queue_level": previous_queue_level,
|
||||
"status_change": status_change,
|
||||
"people": [
|
||||
{
|
||||
"person_id": person_id,
|
||||
"queue_seconds": queue_seconds,
|
||||
"bucket": (
|
||||
"over_threshold"
|
||||
if queue_seconds >= self.queue_time_threshold_seconds
|
||||
else "under_threshold"
|
||||
),
|
||||
}
|
||||
for person_id, queue_seconds in sorted(
|
||||
queue_totals.items(),
|
||||
key=lambda item: item[1],
|
||||
reverse=True,
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _overlap_seconds(
|
||||
period_start: datetime,
|
||||
period_end: datetime,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
) -> int:
|
||||
overlap_start = max(period_start, window_start)
|
||||
overlap_end = min(period_end, window_end)
|
||||
if overlap_end <= overlap_start:
|
||||
return 0
|
||||
return int((overlap_end - overlap_start).total_seconds())
|
||||
|
||||
|
||||
def _queue_level(
|
||||
over_threshold_count: int,
|
||||
crowded_count_threshold: int,
|
||||
normal_count_threshold: int,
|
||||
) -> str:
|
||||
if over_threshold_count > crowded_count_threshold:
|
||||
return "crowded"
|
||||
if over_threshold_count >= normal_count_threshold:
|
||||
return "normal"
|
||||
return "few"
|
||||
|
||||
|
||||
def _queue_status_change(previous_level: str | None, current_level: str) -> str:
|
||||
if previous_level is None:
|
||||
return "initial"
|
||||
if previous_level == current_level:
|
||||
return "unchanged"
|
||||
if current_level == "crowded" and previous_level in {"normal", "few"}:
|
||||
return "queue_increased"
|
||||
if current_level == "few" and previous_level in {"normal", "crowded"}:
|
||||
return "queue_decreased"
|
||||
if current_level == "normal" and previous_level in {"crowded", "few"}:
|
||||
return "queue_normalized"
|
||||
return "changed"
|
||||
|
||||
|
||||
def long_stay_count(sessions: list[dict], min_dwell_seconds: int) -> int:
|
||||
return sum(
|
||||
|
||||
@@ -5,7 +5,9 @@ from pathlib import Path
|
||||
from urllib import request
|
||||
|
||||
|
||||
def build_json_request(url: str, payload: dict, timeout_seconds: float = 5.0) -> request.Request:
|
||||
def build_json_request(
|
||||
url: str, payload: dict, timeout_seconds: float = 5.0
|
||||
) -> request.Request:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = request.Request(url=url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
@@ -18,3 +20,22 @@ def append_json_event(path: str | Path, payload: dict) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def post_json_event(url: str, payload: dict, timeout_seconds: float = 5.0) -> None:
|
||||
if not url.strip():
|
||||
return
|
||||
req = build_json_request(url, payload, timeout_seconds=timeout_seconds)
|
||||
with request.urlopen(req, timeout=timeout_seconds):
|
||||
return
|
||||
|
||||
|
||||
def dispatch_json_event(
|
||||
path: str | Path,
|
||||
payload: dict,
|
||||
webhook_url: str = "",
|
||||
timeout_seconds: float = 5.0,
|
||||
) -> None:
|
||||
append_json_event(path, payload)
|
||||
if webhook_url.strip():
|
||||
post_json_event(webhook_url, payload, timeout_seconds=timeout_seconds)
|
||||
|
||||
@@ -2,8 +2,9 @@ camera_id: store_cam_01
|
||||
timezone: Asia/Shanghai
|
||||
|
||||
thresholds:
|
||||
min_people: 5
|
||||
min_dwell_seconds: 600
|
||||
queue_time_threshold_seconds: 300
|
||||
crowded_count_threshold: 5
|
||||
normal_count_threshold: 2
|
||||
pause_timeout_seconds: 300
|
||||
alert_cooldown_seconds: 600
|
||||
|
||||
@@ -21,6 +22,7 @@ event_sink:
|
||||
path: logs/events.jsonl
|
||||
|
||||
webhook:
|
||||
url: ""
|
||||
alert_url: ""
|
||||
report_url: ""
|
||||
timeout_seconds: 5.0
|
||||
|
||||
@@ -40,4 +40,67 @@ config_path.write_text(
|
||||
)
|
||||
PY
|
||||
|
||||
exec python -m app.manage_api --config "${CONFIG_PATH}" --host "${API_HOST}" --port "${API_PORT}"
|
||||
exec python - "$CONFIG_PATH" "$API_HOST" "$API_PORT" <<'PY'
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
config_path, api_host, api_port = sys.argv[1:4]
|
||||
commands = [
|
||||
[sys.executable, "-m", "app.main", "--config", config_path],
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"app.manage_api",
|
||||
"--config",
|
||||
config_path,
|
||||
"--host",
|
||||
api_host,
|
||||
"--port",
|
||||
api_port,
|
||||
],
|
||||
]
|
||||
processes = [subprocess.Popen(command) for command in commands]
|
||||
|
||||
|
||||
def terminate_all(signum, _frame):
|
||||
for process in processes:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
deadline = time.time() + 10
|
||||
for process in processes:
|
||||
if process.poll() is not None:
|
||||
continue
|
||||
timeout = max(0, deadline - time.time())
|
||||
try:
|
||||
process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
raise SystemExit(128 + signum)
|
||||
|
||||
|
||||
for handled_signal in (signal.SIGINT, signal.SIGTERM):
|
||||
signal.signal(handled_signal, terminate_all)
|
||||
|
||||
while True:
|
||||
for index, process in enumerate(processes):
|
||||
return_code = process.poll()
|
||||
if return_code is None:
|
||||
continue
|
||||
for other_index, other_process in enumerate(processes):
|
||||
if other_index == index or other_process.poll() is not None:
|
||||
continue
|
||||
other_process.terminate()
|
||||
deadline = time.time() + 10
|
||||
for other_index, other_process in enumerate(processes):
|
||||
if other_index == index or other_process.poll() is not None:
|
||||
continue
|
||||
timeout = max(0, deadline - time.time())
|
||||
try:
|
||||
other_process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
other_process.kill()
|
||||
raise SystemExit(return_code)
|
||||
time.sleep(0.5)
|
||||
PY
|
||||
|
||||
@@ -8,16 +8,18 @@ def test_load_config_reads_thresholds(tmp_path: Path):
|
||||
cfg.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"thresholds:\n"
|
||||
" min_people: 5\n"
|
||||
" min_dwell_seconds: 600\n",
|
||||
" queue_time_threshold_seconds: 300\n"
|
||||
" crowded_count_threshold: 5\n"
|
||||
" normal_count_threshold: 2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data = load_config(cfg)
|
||||
|
||||
assert data.camera_id == "store_cam_01"
|
||||
assert data.thresholds.min_people == 5
|
||||
assert data.thresholds.min_dwell_seconds == 600
|
||||
assert data.thresholds.queue_time_threshold_seconds == 300
|
||||
assert data.thresholds.crowded_count_threshold == 5
|
||||
assert data.thresholds.normal_count_threshold == 2
|
||||
|
||||
|
||||
def test_load_config_uses_defaults_for_optional_sections(tmp_path: Path):
|
||||
@@ -29,4 +31,5 @@ def test_load_config_uses_defaults_for_optional_sections(tmp_path: Path):
|
||||
assert data.stream.sample_fps == 2.0
|
||||
assert data.staff.min_hits == 3
|
||||
assert data.event_sink.path == "logs/events.jsonl"
|
||||
assert data.webhook.url == ""
|
||||
assert data.webhook.timeout_seconds == 5.0
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.modules.dwell_engine import DwellEngine, DwellSession, long_stay_count
|
||||
|
||||
from app.modules.dwell_engine import DwellEngine, DwellSession
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
@@ -12,34 +11,81 @@ def test_session_pauses_without_adding_absence_time():
|
||||
session = DwellSession(person_id="cust_1", session_id="cust_1-s1", entered_at=start)
|
||||
session.mark_seen(start.replace(minute=2))
|
||||
session.pause(start.replace(minute=2, second=10))
|
||||
session.close_if_expired(start.replace(minute=7, second=11), pause_timeout_seconds=300)
|
||||
session.close_if_expired(
|
||||
start.replace(minute=7, second=11), pause_timeout_seconds=300
|
||||
)
|
||||
assert session.state == "closed"
|
||||
assert session.dwell_seconds() == 130
|
||||
|
||||
|
||||
def test_engine_emits_alert_when_five_long_stays_are_active():
|
||||
def test_engine_emits_half_hour_report_with_queue_classification():
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
min_people=5,
|
||||
min_dwell_seconds=600,
|
||||
queue_time_threshold_seconds=300,
|
||||
crowded_count_threshold=5,
|
||||
normal_count_threshold=2,
|
||||
pause_timeout_seconds=300,
|
||||
alert_cooldown_seconds=600,
|
||||
)
|
||||
now = datetime(2026, 4, 15, 11, 20, tzinfo=TZ)
|
||||
observations = [{"person_id": f"cust_{idx}", "role": "customer"} for idx in range(5)]
|
||||
start = datetime(2026, 4, 15, 11, 0, tzinfo=TZ)
|
||||
crowded_group = [
|
||||
{"person_id": f"cust_{idx}", "role": "customer"} for idx in range(6)
|
||||
]
|
||||
short_wait_group = [
|
||||
{"person_id": f"short_{idx}", "role": "customer"} for idx in range(2)
|
||||
]
|
||||
|
||||
engine.process_observations(observations, now.replace(minute=9, second=0))
|
||||
events = engine.process_observations(observations, now)
|
||||
engine.process_observations(crowded_group, start.replace(minute=0, second=0))
|
||||
engine.process_observations(crowded_group, start.replace(minute=6, second=0))
|
||||
engine.process_observations(
|
||||
crowded_group + short_wait_group, start.replace(minute=27, second=0)
|
||||
)
|
||||
events = engine.process_observations(
|
||||
short_wait_group, start.replace(minute=30, second=0)
|
||||
)
|
||||
|
||||
assert [event["event"] for event in events] == ["long_stay_alert"]
|
||||
assert events[0]["active_long_stay_count"] == 5
|
||||
report = next(event for event in events if event["event"] == "half_hour_report")
|
||||
assert report["queue_metrics"]["over_threshold_count"] == 6
|
||||
assert report["queue_metrics"]["under_threshold_count"] == 2
|
||||
assert report["queue_metrics"]["queue_level"] == "crowded"
|
||||
assert report["queue_metrics"]["status_change"] == "initial"
|
||||
|
||||
|
||||
def test_engine_tracks_queue_status_change_between_windows():
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
queue_time_threshold_seconds=300,
|
||||
crowded_count_threshold=5,
|
||||
normal_count_threshold=2,
|
||||
pause_timeout_seconds=300,
|
||||
alert_cooldown_seconds=600,
|
||||
)
|
||||
start = datetime(2026, 4, 15, 11, 0, tzinfo=TZ)
|
||||
engine.process_observations(
|
||||
[{"person_id": f"crowded_{idx}", "role": "customer"} for idx in range(6)],
|
||||
start,
|
||||
)
|
||||
engine.process_observations([], start.replace(minute=30))
|
||||
engine.process_observations(
|
||||
[{"person_id": f"normal_{idx}", "role": "customer"} for idx in range(3)],
|
||||
start.replace(minute=31),
|
||||
)
|
||||
report_events = engine.process_observations([], start.replace(hour=12, minute=0))
|
||||
|
||||
report = next(
|
||||
event for event in report_events if event["event"] == "half_hour_report"
|
||||
)
|
||||
assert report["queue_metrics"]["queue_level"] == "normal"
|
||||
assert report["queue_metrics"]["status_change"] == "queue_normalized"
|
||||
assert report["queue_metrics"]["previous_queue_level"] == "crowded"
|
||||
|
||||
|
||||
def test_engine_emits_half_hour_report_with_closed_customers():
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
min_people=5,
|
||||
min_dwell_seconds=600,
|
||||
queue_time_threshold_seconds=300,
|
||||
crowded_count_threshold=5,
|
||||
normal_count_threshold=2,
|
||||
pause_timeout_seconds=300,
|
||||
alert_cooldown_seconds=600,
|
||||
)
|
||||
@@ -53,11 +99,5 @@ def test_engine_emits_half_hour_report_with_closed_customers():
|
||||
report = next(event for event in events if event["event"] == "half_hour_report")
|
||||
assert report["window_end"] == "2026-04-15T11:30:00+08:00"
|
||||
assert report["closed_customers"][0]["person_id"] == "cust_1"
|
||||
|
||||
|
||||
def test_long_stay_count_excludes_staff():
|
||||
sessions = [
|
||||
{"role": "customer", "state": "active", "dwell_seconds": 700},
|
||||
{"role": "staff", "state": "active", "dwell_seconds": 40000},
|
||||
]
|
||||
assert long_stay_count(sessions, min_dwell_seconds=600) == 1
|
||||
assert report["queue_metrics"]["over_threshold_count"] == 0
|
||||
assert report["queue_metrics"]["under_threshold_count"] == 1
|
||||
|
||||
@@ -14,6 +14,10 @@ def build_client(project_root: Path):
|
||||
config_path.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"timezone: Asia/Shanghai\n"
|
||||
"thresholds:\n"
|
||||
" queue_time_threshold_seconds: 300\n"
|
||||
" crowded_count_threshold: 5\n"
|
||||
" normal_count_threshold: 2\n"
|
||||
"stream:\n"
|
||||
" rtsp_url: rtsp://before-update\n"
|
||||
" sample_fps: 2.0\n"
|
||||
@@ -28,13 +32,6 @@ def build_client(project_root: Path):
|
||||
(logs_dir / "events.jsonl").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
json.dumps(
|
||||
{
|
||||
"event": "long_stay_alert",
|
||||
"camera_id": "store_cam_01",
|
||||
"ts": "2026-04-16T09:00:00+08:00",
|
||||
}
|
||||
),
|
||||
json.dumps(
|
||||
{
|
||||
"event": "half_hour_report",
|
||||
@@ -50,6 +47,14 @@ def build_client(project_root: Path):
|
||||
{"person_id": "cust_3", "final_dwell_seconds": 450}
|
||||
],
|
||||
"staff_seen_count": 1,
|
||||
"queue_metrics": {
|
||||
"queue_time_threshold_seconds": 300,
|
||||
"over_threshold_count": 2,
|
||||
"under_threshold_count": 1,
|
||||
"queue_level": "normal",
|
||||
"previous_queue_level": null,
|
||||
"status_change": "initial",
|
||||
},
|
||||
}
|
||||
),
|
||||
json.dumps(
|
||||
@@ -67,6 +72,14 @@ def build_client(project_root: Path):
|
||||
{"person_id": "cust_6", "final_dwell_seconds": 120},
|
||||
],
|
||||
"staff_seen_count": 0,
|
||||
"queue_metrics": {
|
||||
"queue_time_threshold_seconds": 300,
|
||||
"over_threshold_count": 6,
|
||||
"under_threshold_count": 2,
|
||||
"queue_level": "crowded",
|
||||
"previous_queue_level": "normal",
|
||||
"status_change": "queue_increased",
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
@@ -126,10 +139,14 @@ def test_get_manage_summary(tmp_path: Path):
|
||||
assert response.status_code == 200
|
||||
assert response.json["result_type"] == "store_dwell_alert"
|
||||
assert response.json["last_result_time"] == "2026-04-16T10:00:00+08:00"
|
||||
assert response.json["metrics"]["alert_count"] == 1
|
||||
assert response.json["metrics"]["active_customer_count"] == 1
|
||||
assert response.json["metrics"]["longest_dwell_seconds"] == 900
|
||||
assert response.json["metrics"]["recent_window_stats"][0]["window_end"] == "2026-04-16T10:00:00+08:00"
|
||||
assert response.json["metrics"]["queue_level"] == "crowded"
|
||||
assert response.json["metrics"]["over_threshold_count"] == 6
|
||||
assert response.json["metrics"]["under_threshold_count"] == 2
|
||||
assert response.json["metrics"]["status_change"] == "queue_increased"
|
||||
assert (
|
||||
response.json["metrics"]["recent_window_stats"][0]["window_end"]
|
||||
== "2026-04-16T10:00:00+08:00"
|
||||
)
|
||||
|
||||
|
||||
def test_get_manage_windows(tmp_path: Path):
|
||||
|
||||
Reference in New Issue
Block a user