feat: initialize managed portal
This commit is contained in:
1
managed/store_dwell_alert/app/__init__.py
Normal file
1
managed/store_dwell_alert/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Store dwell alert application package."""
|
||||
103
managed/store_dwell_alert/app/config.py
Normal file
103
managed/store_dwell_alert/app/config.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Thresholds:
|
||||
min_people: int = 5
|
||||
min_dwell_seconds: int = 600
|
||||
pause_timeout_seconds: int = 300
|
||||
alert_cooldown_seconds: int = 600
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StreamConfig:
|
||||
rtsp_url: str = ""
|
||||
sample_fps: float = 2.0
|
||||
reconnect_backoff_seconds: float = 5.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StaffConfig:
|
||||
gallery_dir: str = "data/staff_gallery"
|
||||
min_hits: int = 3
|
||||
similarity_threshold: float = 0.9
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WebhookConfig:
|
||||
alert_url: str = ""
|
||||
report_url: str = ""
|
||||
timeout_seconds: float = 5.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EventSinkConfig:
|
||||
path: str = "logs/events.jsonl"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppConfig:
|
||||
camera_id: str
|
||||
timezone: str = "Asia/Shanghai"
|
||||
thresholds: Thresholds = field(default_factory=Thresholds)
|
||||
stream: StreamConfig = field(default_factory=StreamConfig)
|
||||
staff: StaffConfig = field(default_factory=StaffConfig)
|
||||
event_sink: EventSinkConfig = field(default_factory=EventSinkConfig)
|
||||
webhook: WebhookConfig = field(default_factory=WebhookConfig)
|
||||
|
||||
|
||||
def _load_section(raw: dict, key: str, cls):
|
||||
return cls(**raw.get(key, {}))
|
||||
|
||||
|
||||
def resolve_config_path(config_path: str | Path | None = None) -> Path:
|
||||
if config_path is not None:
|
||||
return Path(config_path).expanduser().resolve()
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
local_path = project_root / "config" / "108.local.yaml"
|
||||
if local_path.exists():
|
||||
return local_path
|
||||
return project_root / "config" / "config.example.yaml"
|
||||
|
||||
|
||||
def resolve_project_root(config_path: str | Path) -> Path:
|
||||
return Path(config_path).expanduser().resolve().parent.parent
|
||||
|
||||
|
||||
def resolve_project_path(project_root: str | Path, raw_path: str | Path) -> Path:
|
||||
path = Path(raw_path)
|
||||
if path.is_absolute():
|
||||
return path.resolve()
|
||||
return (Path(project_root).resolve() / path).resolve()
|
||||
|
||||
|
||||
def load_config_document(path: Path) -> dict:
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
|
||||
|
||||
def load_config(path: Path) -> AppConfig:
|
||||
raw = load_config_document(path)
|
||||
return AppConfig(
|
||||
camera_id=raw["camera_id"],
|
||||
timezone=raw.get("timezone", "Asia/Shanghai"),
|
||||
thresholds=_load_section(raw, "thresholds", Thresholds),
|
||||
stream=_load_section(raw, "stream", StreamConfig),
|
||||
staff=_load_section(raw, "staff", StaffConfig),
|
||||
event_sink=_load_section(raw, "event_sink", EventSinkConfig),
|
||||
webhook=_load_section(raw, "webhook", WebhookConfig),
|
||||
)
|
||||
|
||||
|
||||
def save_config_document(path: Path, raw: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
temp_path.write_text(
|
||||
yaml.safe_dump(raw, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temp_path.replace(path)
|
||||
148
managed/store_dwell_alert/app/main.py
Normal file
148
managed/store_dwell_alert/app/main.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
|
||||
from app.config import (
|
||||
AppConfig,
|
||||
load_config,
|
||||
resolve_config_path,
|
||||
resolve_project_path,
|
||||
resolve_project_root,
|
||||
)
|
||||
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.staff_filter import StaffMatcher, load_staff_gallery
|
||||
from app.modules.stream_reader import RTSPFrameReader
|
||||
|
||||
|
||||
def build_app(config_path: str | Path | None = None) -> dict:
|
||||
resolved_config_path = resolve_config_path(config_path)
|
||||
config = load_config(resolved_config_path)
|
||||
project_root = resolve_project_root(resolved_config_path)
|
||||
event_sink_path = resolve_project_path(project_root, config.event_sink.path)
|
||||
gallery_dir = resolve_project_path(project_root, config.staff.gallery_dir)
|
||||
|
||||
return {
|
||||
"config": config,
|
||||
"config_path": resolved_config_path,
|
||||
"stream_reader": RTSPFrameReader(
|
||||
rtsp_url=config.stream.rtsp_url,
|
||||
sample_fps=config.stream.sample_fps,
|
||||
reconnect_backoff_seconds=config.stream.reconnect_backoff_seconds,
|
||||
),
|
||||
"tracker": YOLOTrackerAdapter(),
|
||||
"identity_resolver": IdentityResolver(
|
||||
pause_timeout_seconds=config.thresholds.pause_timeout_seconds,
|
||||
),
|
||||
"staff_matcher": StaffMatcher(
|
||||
gallery=load_staff_gallery(gallery_dir),
|
||||
similarity_threshold=config.staff.similarity_threshold,
|
||||
min_hits=config.staff.min_hits,
|
||||
),
|
||||
"dwell_engine": DwellEngine(
|
||||
camera_id=config.camera_id,
|
||||
min_people=config.thresholds.min_people,
|
||||
min_dwell_seconds=config.thresholds.min_dwell_seconds,
|
||||
pause_timeout_seconds=config.thresholds.pause_timeout_seconds,
|
||||
alert_cooldown_seconds=config.thresholds.alert_cooldown_seconds,
|
||||
),
|
||||
"notifier": append_json_event,
|
||||
"event_sink_path": event_sink_path,
|
||||
}
|
||||
|
||||
|
||||
def process_observations(
|
||||
app: dict,
|
||||
observations: list[dict],
|
||||
when,
|
||||
) -> list[dict]:
|
||||
events = app["dwell_engine"].process_observations(observations, when)
|
||||
for event in events:
|
||||
app["notifier"](app["event_sink_path"], event)
|
||||
return events
|
||||
|
||||
|
||||
def process_frame(app: dict, frame, when: datetime | None = None) -> list[dict]:
|
||||
observation_time = when or datetime.now().astimezone()
|
||||
tracks = app["tracker"].track(frame)
|
||||
observations = app["identity_resolver"].resolve(tracks, observation_time)
|
||||
observations = app["staff_matcher"].classify(observations)
|
||||
return process_observations(app, observations, observation_time)
|
||||
|
||||
|
||||
def run_once(app: dict) -> list[dict]:
|
||||
frame = app["stream_reader"].read()
|
||||
if frame is None:
|
||||
return []
|
||||
return process_frame(app, frame)
|
||||
|
||||
|
||||
def run_forever(app: dict, max_frames: int | None = None) -> int:
|
||||
processed_frames = 0
|
||||
try:
|
||||
while True:
|
||||
frame = app["stream_reader"].read()
|
||||
if frame is None:
|
||||
sleep(0.2)
|
||||
continue
|
||||
process_frame(app, frame)
|
||||
processed_frames += 1
|
||||
if max_frames is not None and processed_frames >= max_frames:
|
||||
break
|
||||
finally:
|
||||
app["stream_reader"].close()
|
||||
return processed_frames
|
||||
|
||||
|
||||
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(
|
||||
"--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(
|
||||
"--max-frames",
|
||||
type=int,
|
||||
help="Maximum frames to process before exiting",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = parse_args()
|
||||
args = parser.parse_args()
|
||||
if args.manage_api:
|
||||
from app.manage_api import run_manage_api
|
||||
|
||||
run_manage_api(args.config, host=args.host, port=args.port)
|
||||
return 0
|
||||
|
||||
app = build_app(args.config)
|
||||
config: AppConfig = app["config"]
|
||||
print(f"Loaded config from {app['config_path']}")
|
||||
print(f"Camera: {config.camera_id}")
|
||||
print(f"RTSP: {config.stream.rtsp_url}")
|
||||
print(f"Event sink: {app['event_sink_path']}")
|
||||
print(f"Loaded {len(app['staff_matcher'].gallery)} staff gallery embedding(s)")
|
||||
if args.once:
|
||||
events = run_once(app)
|
||||
print(f"Generated {len(events)} event(s)")
|
||||
app["stream_reader"].close()
|
||||
return 0
|
||||
processed_frames = run_forever(app, max_frames=args.max_frames)
|
||||
print(f"Processed {processed_frames} frame(s)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
394
managed/store_dwell_alert/app/manage_api.py
Normal file
394
managed/store_dwell_alert/app/manage_api.py
Normal file
@@ -0,0 +1,394 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from argparse import ArgumentParser
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, request, send_file
|
||||
|
||||
from app.config import (
|
||||
load_config,
|
||||
load_config_document,
|
||||
resolve_config_path,
|
||||
resolve_project_path,
|
||||
resolve_project_root,
|
||||
save_config_document,
|
||||
)
|
||||
|
||||
|
||||
PROJECT_TYPE = "store_dwell_alert"
|
||||
DEFAULT_MANAGE_PORT = 18081
|
||||
MAX_PREVIEW_LINES = 2000
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ManageContext:
|
||||
config_path: Path
|
||||
project_root: Path
|
||||
|
||||
|
||||
def create_app(config_path: str | Path | None = None) -> Flask:
|
||||
resolved_config = resolve_config_path(config_path)
|
||||
ctx = ManageContext(
|
||||
config_path=resolved_config,
|
||||
project_root=resolve_project_root(resolved_config),
|
||||
)
|
||||
app = Flask(__name__)
|
||||
app.config["MANAGE_CONTEXT"] = ctx
|
||||
|
||||
@app.get("/api/manage/health")
|
||||
def get_health():
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"project_type": PROJECT_TYPE,
|
||||
"version": "dev",
|
||||
"runtime_status": "running",
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/api/manage/config")
|
||||
def get_config():
|
||||
return jsonify(_config_payload(ctx))
|
||||
|
||||
@app.put("/api/manage/config")
|
||||
def update_config():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
rtsp_url = payload.get("rtsp_url")
|
||||
if not isinstance(rtsp_url, str) or not rtsp_url.strip():
|
||||
return jsonify({"error": "rtsp_url is required"}), 400
|
||||
|
||||
raw = load_config_document(ctx.config_path)
|
||||
stream = raw.setdefault("stream", {})
|
||||
stream["rtsp_url"] = rtsp_url.strip()
|
||||
save_config_document(ctx.config_path, raw)
|
||||
return jsonify(_config_payload(ctx))
|
||||
|
||||
@app.get("/api/manage/summary")
|
||||
def get_summary():
|
||||
return jsonify(_build_summary(ctx))
|
||||
|
||||
@app.get("/api/manage/windows")
|
||||
def get_windows():
|
||||
page = max(_int_arg("page", 1), 1)
|
||||
page_size = max(_int_arg("page_size", 24), 1)
|
||||
limit = request.args.get("limit")
|
||||
|
||||
items = list(_load_window_stats(ctx))
|
||||
if limit is not None:
|
||||
items = items[: max(_int_value(limit), 0)]
|
||||
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
return jsonify(
|
||||
{
|
||||
"items": items[start:end],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": len(items),
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/api/manage/files")
|
||||
def get_files():
|
||||
return jsonify({"files": _list_result_files(ctx)})
|
||||
|
||||
@app.get("/api/manage/files/preview")
|
||||
def preview_file():
|
||||
target = _resolve_sandbox_file(ctx, request.args.get("path", ""))
|
||||
lines = _tail_lines(target, _bounded_preview_lines(request.args.get("lines")))
|
||||
return jsonify(
|
||||
{
|
||||
"path": _relative_path(ctx, target),
|
||||
"lines": lines,
|
||||
"count": len(lines),
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/api/manage/files/download")
|
||||
def download_file():
|
||||
target = _resolve_sandbox_file(ctx, request.args.get("path", ""))
|
||||
return send_file(target, as_attachment=True, download_name=target.name)
|
||||
|
||||
@app.errorhandler(ValueError)
|
||||
def handle_value_error(error: ValueError):
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
@app.errorhandler(FileNotFoundError)
|
||||
def handle_missing_file(error: FileNotFoundError):
|
||||
return jsonify({"error": str(error)}), 404
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def run_manage_api(
|
||||
config_path: str | Path | None = None,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = DEFAULT_MANAGE_PORT,
|
||||
) -> None:
|
||||
app = create_app(config_path)
|
||||
app.run(host=host, port=port)
|
||||
|
||||
|
||||
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")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = parse_args()
|
||||
args = parser.parse_args()
|
||||
run_manage_api(args.config, host=args.host, port=args.port)
|
||||
return 0
|
||||
|
||||
|
||||
def _config_payload(ctx: ManageContext) -> dict:
|
||||
config = load_config(ctx.config_path)
|
||||
event_sink_path = resolve_project_path(ctx.project_root, config.event_sink.path)
|
||||
return {
|
||||
"project_type": PROJECT_TYPE,
|
||||
"config_path": str(ctx.config_path),
|
||||
"camera_id": config.camera_id,
|
||||
"timezone": config.timezone,
|
||||
"stream": {
|
||||
"rtsp_url": config.stream.rtsp_url,
|
||||
"sample_fps": config.stream.sample_fps,
|
||||
"reconnect_backoff_seconds": config.stream.reconnect_backoff_seconds,
|
||||
},
|
||||
"event_sink": {
|
||||
"path": str(event_sink_path),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_summary(ctx: ManageContext) -> dict:
|
||||
events_path = _events_path(ctx)
|
||||
if not events_path.exists():
|
||||
return {
|
||||
"result_type": PROJECT_TYPE,
|
||||
"headline": "No event output yet",
|
||||
"metrics": {
|
||||
"events_path": str(events_path),
|
||||
"recent_window_stats": [],
|
||||
"all_window_stats": [],
|
||||
},
|
||||
}
|
||||
|
||||
alert_count = 0
|
||||
last_alert_time = ""
|
||||
last_report_time = ""
|
||||
active_count = 0
|
||||
longest_dwell_seconds = 0
|
||||
window_stats: list[dict] = []
|
||||
|
||||
with events_path.open("r", encoding="utf-8") as handle:
|
||||
for raw_line in handle:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
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":
|
||||
last_report_time = _string_value(payload.get("window_end"))
|
||||
active_count = _int_value(payload.get("active_customer_count"))
|
||||
stat = _build_window_stat(payload)
|
||||
window_stats.append(stat)
|
||||
longest_dwell_seconds = max(
|
||||
longest_dwell_seconds,
|
||||
stat["max_wait_seconds"],
|
||||
)
|
||||
|
||||
window_stats.sort(
|
||||
key=lambda item: _parse_timestamp(item["window_end"]),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
headline = "No alerts or reports yet"
|
||||
if last_report_time:
|
||||
headline = (
|
||||
"Latest report shows "
|
||||
f"{active_count} active customers, longest dwell {longest_dwell_seconds}s"
|
||||
)
|
||||
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),
|
||||
"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,
|
||||
"events_path": str(events_path),
|
||||
"recent_window_stats": window_stats[:24],
|
||||
"all_window_stats": window_stats,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _load_window_stats(ctx: ManageContext) -> list[dict]:
|
||||
return list(_build_summary(ctx)["metrics"]["all_window_stats"])
|
||||
|
||||
|
||||
def _list_result_files(ctx: ManageContext) -> list[dict]:
|
||||
files: list[dict] = []
|
||||
for path, label in (
|
||||
(_events_path(ctx), "Events JSONL"),
|
||||
(ctx.project_root / "logs" / "runtime.log", "Runtime Log"),
|
||||
):
|
||||
if not path.exists() or not path.is_file():
|
||||
continue
|
||||
info = path.stat()
|
||||
files.append(
|
||||
{
|
||||
"path": _relative_path(ctx, path),
|
||||
"name": path.name,
|
||||
"label": label,
|
||||
"kind": path.suffix.lstrip(".").lower(),
|
||||
"size": info.st_size,
|
||||
"modified_at": datetime.fromtimestamp(info.st_mtime).astimezone().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
files.sort(key=lambda item: item["path"])
|
||||
return files
|
||||
|
||||
|
||||
def _events_path(ctx: ManageContext) -> Path:
|
||||
config = load_config(ctx.config_path)
|
||||
return resolve_project_path(ctx.project_root, config.event_sink.path)
|
||||
|
||||
|
||||
def _build_window_stat(payload: dict) -> dict:
|
||||
active_wait_seconds = _int_list(payload.get("active_customers"), "dwell_seconds")
|
||||
closed_wait_seconds = _int_list(
|
||||
payload.get("closed_customers"),
|
||||
"final_dwell_seconds",
|
||||
)
|
||||
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,
|
||||
"max_wait_seconds": max(
|
||||
max(active_wait_seconds, default=0),
|
||||
max(closed_wait_seconds, default=0),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_sandbox_file(ctx: ManageContext, raw_path: str) -> Path:
|
||||
relative = raw_path.strip().lstrip("/")
|
||||
if not relative:
|
||||
raise ValueError("path is required")
|
||||
|
||||
target = (ctx.project_root / relative).resolve()
|
||||
project_root = ctx.project_root.resolve()
|
||||
if target != project_root and project_root not in target.parents:
|
||||
raise ValueError("invalid file path")
|
||||
if not target.exists() or not target.is_file():
|
||||
raise FileNotFoundError(relative)
|
||||
return target
|
||||
|
||||
|
||||
def _relative_path(ctx: ManageContext, target: Path) -> str:
|
||||
return target.resolve().relative_to(ctx.project_root.resolve()).as_posix()
|
||||
|
||||
|
||||
def _tail_lines(path: Path, line_count: int) -> list[str]:
|
||||
lines: list[str] = []
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
for raw_line in handle:
|
||||
lines.append(raw_line.rstrip("\n"))
|
||||
if len(lines) > line_count:
|
||||
lines = lines[1:]
|
||||
return lines
|
||||
|
||||
|
||||
def _bounded_preview_lines(raw_value: str | None) -> int:
|
||||
if raw_value is None:
|
||||
return 200
|
||||
value = _int_value(raw_value)
|
||||
if value <= 0:
|
||||
return 200
|
||||
return min(value, MAX_PREVIEW_LINES)
|
||||
|
||||
|
||||
def _int_arg(name: str, default: int) -> int:
|
||||
value = request.args.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return _int_value(value)
|
||||
|
||||
|
||||
def _string_value(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def _int_value(value) -> int:
|
||||
if value is None:
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def _int_list(value, field: str) -> list[int]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
values: list[int] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
values.append(_int_value(item.get(field)))
|
||||
return values
|
||||
|
||||
|
||||
def _latest_timestamp(*values: str) -> str:
|
||||
latest_raw = ""
|
||||
latest_at: datetime | None = None
|
||||
for value in values:
|
||||
if not value.strip():
|
||||
continue
|
||||
parsed = _parse_timestamp(value)
|
||||
if parsed is None:
|
||||
if not latest_raw:
|
||||
latest_raw = value
|
||||
continue
|
||||
if latest_at is None or parsed > latest_at:
|
||||
latest_at = parsed
|
||||
latest_raw = value
|
||||
return latest_raw
|
||||
|
||||
|
||||
def _parse_timestamp(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=datetime.now().astimezone().tzinfo)
|
||||
return parsed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1
managed/store_dwell_alert/app/modules/__init__.py
Normal file
1
managed/store_dwell_alert/app/modules/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Runtime modules for the store dwell alert service."""
|
||||
89
managed/store_dwell_alert/app/modules/detector_tracker.py
Normal file
89
managed/store_dwell_alert/app/modules/detector_tracker.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.identity_resolver import build_color_signature
|
||||
|
||||
|
||||
def filter_person_detections(detections: list[dict]) -> list[dict]:
|
||||
return [item for item in detections if item["class_name"] == "person"]
|
||||
|
||||
|
||||
def extract_tracked_people(results: list) -> list[dict]:
|
||||
tracked_people: list[dict] = []
|
||||
for result in results:
|
||||
boxes = getattr(result, "boxes", None)
|
||||
if boxes is None:
|
||||
continue
|
||||
for box in boxes:
|
||||
class_name = result.names[int(box.cls[0])]
|
||||
if class_name != "person":
|
||||
continue
|
||||
tracked_people.append(
|
||||
{
|
||||
"track_id": int(box.id[0]) if getattr(box, "id", None) is not None else None,
|
||||
"class_name": class_name,
|
||||
"confidence": float(box.conf[0]),
|
||||
"xyxy": [float(value) for value in box.xyxy[0]],
|
||||
}
|
||||
)
|
||||
return tracked_people
|
||||
|
||||
|
||||
def attach_track_signatures(frame, tracked_people: list[dict]) -> list[dict]:
|
||||
if frame is None:
|
||||
return tracked_people
|
||||
|
||||
frame_height = len(frame)
|
||||
frame_width = len(frame[0]) if frame_height else 0
|
||||
enriched: list[dict] = []
|
||||
for item in tracked_people:
|
||||
x1, y1, x2, y2 = [int(value) for value in item["xyxy"]]
|
||||
x1 = max(0, min(frame_width, x1))
|
||||
x2 = max(0, min(frame_width, x2))
|
||||
y1 = max(0, min(frame_height, y1))
|
||||
y2 = max(0, min(frame_height, y2))
|
||||
if y2 > y1 and x2 > x1:
|
||||
try:
|
||||
crop = frame[y1:y2, x1:x2]
|
||||
except TypeError:
|
||||
crop = [row[x1:x2] for row in frame[y1:y2]]
|
||||
else:
|
||||
crop = None
|
||||
enriched.append({**item, "signature": build_color_signature(crop)})
|
||||
return enriched
|
||||
|
||||
|
||||
class YOLOTrackerAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str = "yolo11n.pt",
|
||||
conf: float = 0.25,
|
||||
tracker: str = "botsort.yaml",
|
||||
model_factory=None,
|
||||
) -> None:
|
||||
self.model_name = model_name
|
||||
self.conf = conf
|
||||
self.tracker = tracker
|
||||
self.model_factory = model_factory
|
||||
self.model = None
|
||||
|
||||
def load(self) -> None:
|
||||
if self.model_factory is None:
|
||||
try:
|
||||
from ultralytics import YOLO # type: ignore
|
||||
except ImportError as exc: # pragma: no cover - depends on runtime deps
|
||||
raise RuntimeError("ultralytics is required for YOLO tracking") from exc
|
||||
self.model_factory = YOLO
|
||||
self.model = self.model_factory(self.model_name)
|
||||
|
||||
def track(self, frame) -> list[dict]:
|
||||
if self.model is None:
|
||||
self.load()
|
||||
results = self.model.track(
|
||||
frame,
|
||||
persist=True,
|
||||
classes=[0],
|
||||
verbose=False,
|
||||
conf=self.conf,
|
||||
tracker=self.tracker,
|
||||
)
|
||||
return attach_track_signatures(frame, extract_tracked_people(results))
|
||||
232
managed/store_dwell_alert/app/modules/dwell_engine.py
Normal file
232
managed/store_dwell_alert/app/modules/dwell_engine.py
Normal file
@@ -0,0 +1,232 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from app.modules.reporter import floor_half_hour, previous_half_hour_window
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DwellSession:
|
||||
person_id: str
|
||||
session_id: str
|
||||
entered_at: datetime
|
||||
role: str = "customer"
|
||||
state: str = "active"
|
||||
accumulated_dwell_seconds: int = 0
|
||||
active_started_at: datetime | None = None
|
||||
last_seen_at: datetime | None = None
|
||||
pause_started_at: datetime | None = None
|
||||
closed_at: datetime | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.active_started_at is None:
|
||||
self.active_started_at = self.entered_at
|
||||
if self.last_seen_at is None:
|
||||
self.last_seen_at = self.entered_at
|
||||
|
||||
def dwell_seconds(self, when: datetime | None = None) -> int:
|
||||
if self.state == "active" and self.active_started_at is not None:
|
||||
current_time = when or self.last_seen_at or self.entered_at
|
||||
return self.accumulated_dwell_seconds + max(
|
||||
0,
|
||||
int((current_time - self.active_started_at).total_seconds()),
|
||||
)
|
||||
return self.accumulated_dwell_seconds
|
||||
|
||||
def mark_seen(self, when: datetime) -> None:
|
||||
if self.state == "paused":
|
||||
self.active_started_at = when
|
||||
self.pause_started_at = None
|
||||
elif self.active_started_at is None:
|
||||
self.active_started_at = when
|
||||
self.last_seen_at = when
|
||||
self.state = "active"
|
||||
|
||||
def pause(self, when: datetime) -> None:
|
||||
if self.state != "active" or self.active_started_at is None:
|
||||
return
|
||||
self.accumulated_dwell_seconds += max(
|
||||
0,
|
||||
int((when - self.active_started_at).total_seconds()),
|
||||
)
|
||||
self.pause_started_at = when
|
||||
self.last_seen_at = when
|
||||
self.active_started_at = None
|
||||
self.state = "paused"
|
||||
|
||||
def close_if_expired(self, when: datetime, pause_timeout_seconds: int) -> bool:
|
||||
if self.pause_started_at is None:
|
||||
return False
|
||||
if int((when - self.pause_started_at).total_seconds()) <= pause_timeout_seconds:
|
||||
return False
|
||||
self.closed_at = when
|
||||
self.state = "closed"
|
||||
return True
|
||||
|
||||
def as_event_dict(self, when: datetime | None = None) -> dict:
|
||||
return {
|
||||
"person_id": self.person_id,
|
||||
"session_id": self.session_id,
|
||||
"role": self.role,
|
||||
"status": self.state,
|
||||
"dwell_seconds": self.dwell_seconds(when),
|
||||
}
|
||||
|
||||
|
||||
class DwellEngine:
|
||||
def __init__(
|
||||
self,
|
||||
camera_id: str,
|
||||
min_people: int,
|
||||
min_dwell_seconds: 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.pause_timeout_seconds = pause_timeout_seconds
|
||||
self.alert_cooldown_seconds = alert_cooldown_seconds
|
||||
self.sessions: dict[str, DwellSession] = {}
|
||||
self.closed_sessions: list[DwellSession] = []
|
||||
self.session_counts: dict[str, int] = {}
|
||||
self.alert_rearmed = True
|
||||
self.last_alert_at: datetime | None = None
|
||||
self.last_report_boundary: datetime | 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:
|
||||
session = DwellSession(
|
||||
person_id=person_id,
|
||||
session_id=self._next_session_id(person_id),
|
||||
entered_at=when,
|
||||
role=role,
|
||||
)
|
||||
self.sessions[person_id] = session
|
||||
return session
|
||||
|
||||
def process_observations(self, observations: list[dict], when: datetime) -> list[dict]:
|
||||
events: list[dict] = []
|
||||
seen_people: set[str] = set()
|
||||
|
||||
for observation in observations:
|
||||
person_id = observation["person_id"]
|
||||
role = observation.get("role", "customer")
|
||||
seen_people.add(person_id)
|
||||
|
||||
session = self.sessions.get(person_id)
|
||||
if session is None:
|
||||
session = self._create_session(person_id, role, when)
|
||||
else:
|
||||
session.role = role
|
||||
session.mark_seen(when)
|
||||
|
||||
for person_id, session in list(self.sessions.items()):
|
||||
if person_id in seen_people:
|
||||
continue
|
||||
if session.state == "active":
|
||||
session.pause(when)
|
||||
if session.close_if_expired(when, self.pause_timeout_seconds):
|
||||
self.closed_sessions.append(session)
|
||||
del self.sessions[person_id]
|
||||
|
||||
alert_event = self._build_alert_event(when)
|
||||
if alert_event is not None:
|
||||
events.append(alert_event)
|
||||
|
||||
report_event = self._build_half_hour_report(when)
|
||||
if report_event is not None:
|
||||
events.append(report_event)
|
||||
|
||||
return events
|
||||
|
||||
def _active_customer_sessions(self, when: datetime) -> list[DwellSession]:
|
||||
return [
|
||||
session
|
||||
for session in self.sessions.values()
|
||||
if session.role == "customer"
|
||||
and session.state == "active"
|
||||
and session.dwell_seconds(when) >= self.min_dwell_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:
|
||||
self.alert_rearmed = True
|
||||
return None
|
||||
if not self.alert_rearmed:
|
||||
return None
|
||||
self.alert_rearmed = False
|
||||
self.last_alert_at = when
|
||||
return {
|
||||
"event": "long_stay_alert",
|
||||
"camera_id": self.camera_id,
|
||||
"ts": when.isoformat(),
|
||||
"threshold": {
|
||||
"min_people": self.min_people,
|
||||
"min_dwell_seconds": self.min_dwell_seconds,
|
||||
},
|
||||
"active_long_stay_count": len(long_stay_sessions),
|
||||
"people": [
|
||||
session.as_event_dict(when)
|
||||
for session in sorted(
|
||||
long_stay_sessions,
|
||||
key=lambda item: item.dwell_seconds(when),
|
||||
reverse=True,
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
def _build_half_hour_report(self, when: datetime) -> dict | None:
|
||||
boundary = floor_half_hour(when)
|
||||
if boundary == when and self.last_report_boundary == boundary:
|
||||
return
|
||||
if boundary == self.last_report_boundary:
|
||||
return None
|
||||
if when < boundary:
|
||||
return None
|
||||
|
||||
window_start, window_end = previous_half_hour_window(when)
|
||||
active_customers = [
|
||||
session.as_event_dict(when)
|
||||
for session in self.sessions.values()
|
||||
if session.role == "customer" and session.state == "active"
|
||||
]
|
||||
closed_customers = [
|
||||
{
|
||||
"person_id": session.person_id,
|
||||
"session_id": session.session_id,
|
||||
"final_dwell_seconds": session.dwell_seconds(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")
|
||||
self.last_report_boundary = boundary
|
||||
return {
|
||||
"event": "half_hour_report",
|
||||
"camera_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,
|
||||
}
|
||||
|
||||
|
||||
def long_stay_count(sessions: list[dict], min_dwell_seconds: int) -> int:
|
||||
return sum(
|
||||
1
|
||||
for item in sessions
|
||||
if item["role"] == "customer"
|
||||
and item["state"] == "active"
|
||||
and item["dwell_seconds"] >= min_dwell_seconds
|
||||
)
|
||||
173
managed/store_dwell_alert/app/modules/identity_resolver.py
Normal file
173
managed/store_dwell_alert/app/modules/identity_resolver.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from math import sqrt
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
def choose_reentry_match(
|
||||
paused_people: list[dict],
|
||||
now_ts: int,
|
||||
pause_timeout_seconds: int,
|
||||
min_similarity: float,
|
||||
) -> str | None:
|
||||
valid = [
|
||||
item
|
||||
for item in paused_people
|
||||
if now_ts - item["paused_at"] <= pause_timeout_seconds
|
||||
and item["similarity"] >= min_similarity
|
||||
]
|
||||
if not valid:
|
||||
return None
|
||||
valid.sort(key=lambda item: (item["similarity"], item["paused_at"]), reverse=True)
|
||||
return valid[0]["person_id"]
|
||||
|
||||
|
||||
def _average(values: Iterable[float]) -> float:
|
||||
values = list(values)
|
||||
if not values:
|
||||
return 0.0
|
||||
return sum(values) / len(values)
|
||||
|
||||
|
||||
def build_color_signature(crop) -> list[float]:
|
||||
if crop is None:
|
||||
return [0.0, 0.0, 0.0]
|
||||
height = len(crop)
|
||||
if height == 0:
|
||||
return [0.0, 0.0, 0.0]
|
||||
width = len(crop[0])
|
||||
if width == 0:
|
||||
return [0.0, 0.0, 0.0]
|
||||
|
||||
blue_values = []
|
||||
green_values = []
|
||||
red_values = []
|
||||
for row in crop:
|
||||
for pixel in row:
|
||||
blue_values.append(float(pixel[0]))
|
||||
green_values.append(float(pixel[1]))
|
||||
red_values.append(float(pixel[2]))
|
||||
return [
|
||||
round(_average(blue_values) / 255.0, 4),
|
||||
round(_average(green_values) / 255.0, 4),
|
||||
round(_average(red_values) / 255.0, 4),
|
||||
]
|
||||
|
||||
|
||||
def signature_similarity(left: list[float], right: list[float]) -> float:
|
||||
if not left or not right:
|
||||
return 0.0
|
||||
distance = sqrt(sum((left[idx] - right[idx]) ** 2 for idx in range(min(len(left), len(right)))))
|
||||
return max(0.0, 1.0 - distance)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ActiveIdentity:
|
||||
person_id: str
|
||||
track_id: int
|
||||
signature: list[float]
|
||||
last_seen_at: datetime
|
||||
role: str = "customer"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PausedIdentity:
|
||||
person_id: str
|
||||
signature: list[float]
|
||||
paused_at: datetime
|
||||
role: str = "customer"
|
||||
|
||||
|
||||
class IdentityResolver:
|
||||
def __init__(
|
||||
self,
|
||||
pause_timeout_seconds: int,
|
||||
reentry_similarity_threshold: float = 0.92,
|
||||
) -> None:
|
||||
self.pause_timeout_seconds = pause_timeout_seconds
|
||||
self.reentry_similarity_threshold = reentry_similarity_threshold
|
||||
self.active_by_track: dict[int, ActiveIdentity] = {}
|
||||
self.paused_by_person: dict[str, PausedIdentity] = {}
|
||||
self.person_counter = 0
|
||||
|
||||
def _next_person_id(self) -> str:
|
||||
self.person_counter += 1
|
||||
return f"cust_{self.person_counter:05d}"
|
||||
|
||||
def _expire_paused(self, when: datetime) -> None:
|
||||
expired = [
|
||||
person_id
|
||||
for person_id, paused in self.paused_by_person.items()
|
||||
if int((when - paused.paused_at).total_seconds()) > self.pause_timeout_seconds
|
||||
]
|
||||
for person_id in expired:
|
||||
del self.paused_by_person[person_id]
|
||||
|
||||
def _match_paused(self, signature: list[float], when: datetime) -> str | None:
|
||||
self._expire_paused(when)
|
||||
best_person_id = None
|
||||
best_similarity = 0.0
|
||||
for person_id, paused in self.paused_by_person.items():
|
||||
similarity = signature_similarity(signature, paused.signature)
|
||||
if similarity < self.reentry_similarity_threshold:
|
||||
continue
|
||||
if similarity > best_similarity:
|
||||
best_person_id = person_id
|
||||
best_similarity = similarity
|
||||
if best_person_id is not None:
|
||||
del self.paused_by_person[best_person_id]
|
||||
return best_person_id
|
||||
|
||||
def resolve(self, tracks: list[dict], when: datetime) -> list[dict]:
|
||||
current_track_ids = {
|
||||
track["track_id"]
|
||||
for track in tracks
|
||||
if track.get("track_id") is not None
|
||||
}
|
||||
|
||||
disappeared_track_ids = [
|
||||
track_id
|
||||
for track_id in self.active_by_track
|
||||
if track_id not in current_track_ids
|
||||
]
|
||||
for track_id in disappeared_track_ids:
|
||||
active = self.active_by_track.pop(track_id)
|
||||
self.paused_by_person[active.person_id] = PausedIdentity(
|
||||
person_id=active.person_id,
|
||||
signature=active.signature,
|
||||
paused_at=when,
|
||||
role=active.role,
|
||||
)
|
||||
|
||||
observations: list[dict] = []
|
||||
for track in tracks:
|
||||
track_id = track.get("track_id")
|
||||
if track_id is None:
|
||||
continue
|
||||
signature = track.get("signature", [0.0, 0.0, 0.0])
|
||||
active = self.active_by_track.get(track_id)
|
||||
if active is None:
|
||||
person_id = self._match_paused(signature, when) or self._next_person_id()
|
||||
active = ActiveIdentity(
|
||||
person_id=person_id,
|
||||
track_id=track_id,
|
||||
signature=signature,
|
||||
last_seen_at=when,
|
||||
role=track.get("role", "customer"),
|
||||
)
|
||||
self.active_by_track[track_id] = active
|
||||
else:
|
||||
active.signature = signature
|
||||
active.last_seen_at = when
|
||||
|
||||
observations.append(
|
||||
{
|
||||
"person_id": active.person_id,
|
||||
"track_id": track_id,
|
||||
"role": active.role,
|
||||
"signature": signature,
|
||||
}
|
||||
)
|
||||
return observations
|
||||
20
managed/store_dwell_alert/app/modules/notifier.py
Normal file
20
managed/store_dwell_alert/app/modules/notifier.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib import 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")
|
||||
req.timeout_seconds = timeout_seconds
|
||||
return req
|
||||
|
||||
|
||||
def append_json_event(path: str | Path, payload: dict) -> None:
|
||||
output_path = Path(path)
|
||||
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")
|
||||
19
managed/store_dwell_alert/app/modules/reporter.py
Normal file
19
managed/store_dwell_alert/app/modules/reporter.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def should_emit_half_hour_report(ts: str) -> bool:
|
||||
dt = datetime.fromisoformat(ts)
|
||||
return dt.minute in {0, 30} and dt.second == 0
|
||||
|
||||
|
||||
def floor_half_hour(dt: datetime) -> datetime:
|
||||
minute = 0 if dt.minute < 30 else 30
|
||||
return dt.replace(minute=minute, second=0, microsecond=0)
|
||||
|
||||
|
||||
def previous_half_hour_window(dt: datetime) -> tuple[datetime, datetime]:
|
||||
window_end = floor_half_hour(dt)
|
||||
window_start = window_end - timedelta(minutes=30)
|
||||
return window_start, window_end
|
||||
123
managed/store_dwell_alert/app/modules/staff_filter.py
Normal file
123
managed/store_dwell_alert/app/modules/staff_filter.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from app.modules.identity_resolver import build_color_signature, signature_similarity
|
||||
|
||||
|
||||
def staff_vote(matches: list[bool], min_hits: int) -> bool:
|
||||
return sum(1 for item in matches if item) >= min_hits
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StaffEmbedding:
|
||||
staff_id: str
|
||||
signature: list[float]
|
||||
source: str
|
||||
|
||||
|
||||
def _normalize_signature(signature: list[float]) -> list[float]:
|
||||
if len(signature) < 3:
|
||||
return [0.0, 0.0, 0.0]
|
||||
return [round(float(value), 4) for value in signature[:3]]
|
||||
|
||||
|
||||
def load_staff_gallery(gallery_dir: str | Path) -> list[StaffEmbedding]:
|
||||
path = Path(gallery_dir)
|
||||
if not path.exists():
|
||||
return []
|
||||
|
||||
embeddings: list[StaffEmbedding] = []
|
||||
for json_path in sorted(path.glob("*.json")):
|
||||
raw = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
raw = [raw]
|
||||
for item in raw:
|
||||
staff_id = item.get("staff_id") or json_path.stem
|
||||
signature = _normalize_signature(item.get("signature", []))
|
||||
embeddings.append(
|
||||
StaffEmbedding(
|
||||
staff_id=staff_id,
|
||||
signature=signature,
|
||||
source=str(json_path),
|
||||
)
|
||||
)
|
||||
|
||||
image_paths = []
|
||||
for pattern in ("*.jpg", "*.jpeg", "*.png", "*.bmp", "*.webp"):
|
||||
image_paths.extend(sorted(path.glob(pattern)))
|
||||
|
||||
if not image_paths:
|
||||
return embeddings
|
||||
|
||||
try:
|
||||
import cv2 # type: ignore
|
||||
except ImportError: # pragma: no cover - runtime dependency
|
||||
return embeddings
|
||||
|
||||
for image_path in image_paths:
|
||||
image = cv2.imread(str(image_path))
|
||||
if image is None:
|
||||
continue
|
||||
staff_id = image_path.stem.split("_")[0]
|
||||
embeddings.append(
|
||||
StaffEmbedding(
|
||||
staff_id=staff_id,
|
||||
signature=build_color_signature(image),
|
||||
source=str(image_path),
|
||||
)
|
||||
)
|
||||
return embeddings
|
||||
|
||||
|
||||
class StaffMatcher:
|
||||
def __init__(
|
||||
self,
|
||||
gallery: list[StaffEmbedding],
|
||||
similarity_threshold: float,
|
||||
min_hits: int,
|
||||
vote_window: int | None = None,
|
||||
) -> None:
|
||||
self.gallery = gallery
|
||||
self.similarity_threshold = similarity_threshold
|
||||
self.min_hits = min_hits
|
||||
self.vote_window = vote_window or max(5, min_hits)
|
||||
self.votes: dict[str, deque[bool]] = defaultdict(
|
||||
lambda: deque(maxlen=self.vote_window)
|
||||
)
|
||||
|
||||
def match_signature(self, signature: list[float]) -> StaffEmbedding | None:
|
||||
best_match = None
|
||||
best_similarity = 0.0
|
||||
for embedding in self.gallery:
|
||||
similarity = signature_similarity(signature, embedding.signature)
|
||||
if similarity < self.similarity_threshold:
|
||||
continue
|
||||
if similarity > best_similarity:
|
||||
best_match = embedding
|
||||
best_similarity = similarity
|
||||
return best_match
|
||||
|
||||
def classify(self, observations: list[dict]) -> list[dict]:
|
||||
classified: list[dict] = []
|
||||
for observation in observations:
|
||||
person_id = observation["person_id"]
|
||||
signature = observation.get("signature", [0.0, 0.0, 0.0])
|
||||
embedding = self.match_signature(signature)
|
||||
vote_history = self.votes[person_id]
|
||||
vote_history.append(embedding is not None)
|
||||
role = "staff" if staff_vote(list(vote_history), self.min_hits) else "customer"
|
||||
classified.append(
|
||||
{
|
||||
**observation,
|
||||
"role": role,
|
||||
"staff_id": embedding.staff_id if embedding is not None else None,
|
||||
}
|
||||
)
|
||||
return classified
|
||||
|
||||
def forget(self, person_id: str) -> None:
|
||||
self.votes.pop(person_id, None)
|
||||
79
managed/store_dwell_alert/app/modules/stream_reader.py
Normal file
79
managed/store_dwell_alert/app/modules/stream_reader.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from time import monotonic, sleep
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StreamHealth:
|
||||
max_failures: int
|
||||
failures: int = 0
|
||||
|
||||
@property
|
||||
def is_disconnected(self) -> bool:
|
||||
return self.failures >= self.max_failures
|
||||
|
||||
def record_failure(self) -> None:
|
||||
self.failures += 1
|
||||
|
||||
def reset(self) -> None:
|
||||
self.failures = 0
|
||||
|
||||
|
||||
class RTSPFrameReader:
|
||||
def __init__(
|
||||
self,
|
||||
rtsp_url: str,
|
||||
sample_fps: float,
|
||||
reconnect_backoff_seconds: float,
|
||||
capture_factory: Callable[[str], Any] | None = None,
|
||||
) -> None:
|
||||
self.rtsp_url = rtsp_url
|
||||
self.sample_fps = sample_fps
|
||||
self.reconnect_backoff_seconds = reconnect_backoff_seconds
|
||||
self.capture_factory = capture_factory
|
||||
self.health = StreamHealth(max_failures=3)
|
||||
self.capture = None
|
||||
self.last_read_at: float | None = None
|
||||
|
||||
def open(self) -> None:
|
||||
if self.capture_factory is None:
|
||||
try:
|
||||
import cv2 # type: ignore
|
||||
except ImportError as exc: # pragma: no cover - depends on runtime deps
|
||||
raise RuntimeError("opencv-python is required for RTSP reading") from exc
|
||||
self.capture_factory = cv2.VideoCapture
|
||||
self.capture = self.capture_factory(self.rtsp_url)
|
||||
self.health.reset()
|
||||
|
||||
def _throttle(self) -> None:
|
||||
if self.sample_fps <= 0:
|
||||
return
|
||||
interval = 1.0 / self.sample_fps
|
||||
if self.last_read_at is None:
|
||||
return
|
||||
remaining = interval - (monotonic() - self.last_read_at)
|
||||
if remaining > 0:
|
||||
sleep(remaining)
|
||||
|
||||
def read(self):
|
||||
if self.capture is None:
|
||||
self.open()
|
||||
self._throttle()
|
||||
ok, frame = self.capture.read()
|
||||
if not ok:
|
||||
self.health.record_failure()
|
||||
if self.health.is_disconnected:
|
||||
self.close()
|
||||
sleep(self.reconnect_backoff_seconds)
|
||||
return None
|
||||
self.health.reset()
|
||||
self.last_read_at = monotonic()
|
||||
return frame
|
||||
|
||||
def close(self) -> None:
|
||||
if self.capture is not None and hasattr(self.capture, "release"):
|
||||
self.capture.release()
|
||||
self.capture = None
|
||||
self.last_read_at = None
|
||||
23
managed/store_dwell_alert/app/schemas.py
Normal file
23
managed/store_dwell_alert/app/schemas.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PersonIdentity:
|
||||
person_id: str
|
||||
role: str = "customer"
|
||||
track_id: str | None = None
|
||||
state: str = "active"
|
||||
dwell_seconds: int = 0
|
||||
last_seen_ts: int = 0
|
||||
pause_start_ts: int | None = None
|
||||
embedding: list[float] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AlertEvent:
|
||||
event: str
|
||||
camera_id: str
|
||||
ts: str
|
||||
payload: dict
|
||||
Reference in New Issue
Block a user