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:
2026-05-09 11:35:55 +08:00
parent be5014c582
commit ea618fd674
26 changed files with 1605 additions and 117 deletions

View File

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

View File

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

View File

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

View File

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

View File

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