feat: initialize managed portal
This commit is contained in:
11
managed/store_dwell_alert/.dockerignore
Normal file
11
managed/store_dwell_alert/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.DS_Store
|
||||
.git
|
||||
.pytest_cache
|
||||
.venv
|
||||
__pycache__
|
||||
dist
|
||||
logs/*.jsonl
|
||||
logs/*.log
|
||||
config/*.local.yaml
|
||||
config/local.yaml
|
||||
wheelhouse
|
||||
11
managed/store_dwell_alert/.gitignore
vendored
Normal file
11
managed/store_dwell_alert/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
.DS_Store
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
__pycache__/
|
||||
config/*.local.yaml
|
||||
config/local.yaml
|
||||
logs/*.jsonl
|
||||
logs/*.log
|
||||
dist/
|
||||
wheelhouse/
|
||||
weights/*.pt
|
||||
39
managed/store_dwell_alert/Dockerfile
Normal file
39
managed/store_dwell_alert/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libgomp1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN python -m pip install --upgrade pip setuptools wheel \
|
||||
&& python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu \
|
||||
"torch==2.6.0+cpu" "torchvision==0.21.0+cpu" \
|
||||
&& python -m pip install -r /app/requirements.txt
|
||||
|
||||
COPY app /app/app
|
||||
COPY config /app/config
|
||||
COPY data /app/data
|
||||
COPY scripts/docker-entrypoint.sh /app/scripts/docker-entrypoint.sh
|
||||
COPY weights /app/weights
|
||||
COPY README.md README_zh.md /app/
|
||||
|
||||
RUN test -f /app/weights/yolo11n.pt \
|
||||
&& chmod +x /app/scripts/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 18081
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18081/api/manage/health', timeout=3).read()" || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"]
|
||||
161
managed/store_dwell_alert/README.md
Normal file
161
managed/store_dwell_alert/README.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Store Dwell Alert on 192.168.5.108
|
||||
|
||||
Local planning project for a single-camera RTSP long-stay alert system running on `192.168.5.108`.
|
||||
|
||||
Documents:
|
||||
- `docs/plans/2026-04-15-store-dwell-alert-design.md`
|
||||
- `docs/plans/2026-04-15-store-dwell-alert.md`
|
||||
|
||||
Scope:
|
||||
- Single RTSP store camera
|
||||
- `YOLO11n` person detection
|
||||
- Full-frame tracking and lightweight re-association
|
||||
- Staff whitelist exclusion
|
||||
- Local JSONL event sink
|
||||
- Half-hour dwell reports
|
||||
- Offline bundle packaging for `.108`-class Ubuntu hosts
|
||||
- Chinese operations guide: `README_zh.md`
|
||||
|
||||
Runtime:
|
||||
- Default config path: `config/108.local.yaml`
|
||||
- Event sink path: `logs/events.jsonl`
|
||||
- Continuous mode: `python -m app.main`
|
||||
- Single-frame smoke test: `python -m app.main --once`
|
||||
- Management API mode: `python -m app.manage_api --config config/local.yaml --port 18081`
|
||||
|
||||
Staff gallery:
|
||||
- Directory: `data/staff_gallery/`
|
||||
- Supported formats:
|
||||
- `*.json` signature files
|
||||
- `*.jpg`, `*.jpeg`, `*.png`, `*.bmp`, `*.webp` sample crops
|
||||
- JSON example:
|
||||
|
||||
```json
|
||||
{
|
||||
"staff_id": "staff_001",
|
||||
"signature": [0.182, 0.244, 0.317]
|
||||
}
|
||||
```
|
||||
|
||||
Offline bundle:
|
||||
- Target host assumption: Ubuntu 24.04, Python 3.12, NVIDIA GPU compatible with the `.108` runtime stack
|
||||
- Bundle script: `bash scripts/package_bundle.sh`
|
||||
- Bundle output: `dist/store_dwell_alert_bundle_<date>.tar.gz`
|
||||
- Bundle prerequisites before packaging:
|
||||
- place Linux wheels under `wheelhouse/`
|
||||
- place `yolo11n.pt` under `weights/yolo11n.pt`
|
||||
|
||||
Offline install on target host:
|
||||
1. Extract `store_dwell_alert_bundle_<date>.tar.gz`
|
||||
2. Edit `scripts/run.sh` and set `RTSP_URL`
|
||||
3. Run `sudo bash scripts/install.sh`
|
||||
4. The installer enables and starts `store-dwell-alert.service`
|
||||
|
||||
Optional service install:
|
||||
1. Run `bash scripts/install_service.sh`
|
||||
2. Start with `sudo systemctl start store-dwell-alert.service`
|
||||
|
||||
Docker image:
|
||||
- Required runtime weight in build context: `weights/yolo11n.pt`
|
||||
- Build: `docker build -t store-dwell-alert:test .`
|
||||
- Compose: `docker compose up --build store-dwell-alert`
|
||||
- Health: `http://127.0.0.1:18081/api/manage/health`
|
||||
- Default container entrypoint seeds `config/local.yaml` from `config/config.example.yaml` and then starts the management API
|
||||
- Persisted host paths:
|
||||
- `./config -> /app/config`
|
||||
- `./data -> /app/data`
|
||||
- `./logs -> /app/logs`
|
||||
|
||||
## 中文离线部署说明
|
||||
|
||||
适用环境:
|
||||
- `Ubuntu 24.04`
|
||||
- `Python 3.12`
|
||||
- 已安装 NVIDIA 驱动,`nvidia-smi` 可用
|
||||
- 已安装 `ffmpeg`
|
||||
|
||||
离线包准备:
|
||||
- 成品包示例:`store_dwell_alert_bundle_2026-04-16.tar.gz`
|
||||
- 包内已经包含:
|
||||
- 项目源码
|
||||
- `wheelhouse/` 离线依赖
|
||||
- `weights/yolo11n.pt`
|
||||
- `scripts/install.sh`
|
||||
- `scripts/run.sh`
|
||||
- `scripts/install_service.sh`
|
||||
|
||||
部署步骤:
|
||||
1. 把离线包传到目标机器
|
||||
2. 解压:
|
||||
|
||||
```bash
|
||||
tar -xzf store_dwell_alert_bundle_2026-04-16.tar.gz
|
||||
cd store_dwell_alert_bundle
|
||||
```
|
||||
|
||||
3. 执行离线安装:
|
||||
|
||||
```bash
|
||||
bash scripts/install.sh
|
||||
```
|
||||
|
||||
4. 编辑运行脚本,至少改这几个变量:
|
||||
- `RTSP_URL`
|
||||
- `CAMERA_ID`
|
||||
- `EVENT_SINK_PATH`
|
||||
|
||||
```bash
|
||||
vim scripts/run.sh
|
||||
```
|
||||
|
||||
5. 手工启动:
|
||||
|
||||
```bash
|
||||
bash scripts/run.sh
|
||||
```
|
||||
|
||||
常见输出位置:
|
||||
- 事件结果:`logs/events.jsonl`
|
||||
- 本地配置:`config/local.yaml`
|
||||
- 店员图库:`data/staff_gallery/`
|
||||
|
||||
如果需要做店员排除:
|
||||
- 把店员样本图放到 `data/staff_gallery/`
|
||||
- 或者放入对应的 `*.json` 特征文件
|
||||
|
||||
配置完成后的常驻运行:
|
||||
1. 安装服务:
|
||||
|
||||
```bash
|
||||
bash scripts/install_service.sh
|
||||
```
|
||||
|
||||
2. 启动服务:
|
||||
|
||||
```bash
|
||||
sudo systemctl start store-dwell-alert.service
|
||||
```
|
||||
|
||||
3. 查看状态:
|
||||
|
||||
```bash
|
||||
sudo systemctl status store-dwell-alert.service
|
||||
```
|
||||
|
||||
4. 停止服务:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop store-dwell-alert.service
|
||||
```
|
||||
|
||||
5. 开机自启:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable store-dwell-alert.service
|
||||
```
|
||||
|
||||
排查建议:
|
||||
- `scripts/install.sh` 失败时,先确认 `python3.12`、`ffmpeg`、`nvidia-smi` 都存在
|
||||
- `scripts/run.sh` 失败时,先确认 `RTSP_URL` 已改成真实地址
|
||||
- 如果程序启动了但没有结果,先看 `logs/events.jsonl` 是否生成
|
||||
- 如果识别不到店员,先检查 `data/staff_gallery/` 是否有有效样本
|
||||
73
managed/store_dwell_alert/README_zh.md
Normal file
73
managed/store_dwell_alert/README_zh.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 店内停留报警项目中文说明
|
||||
|
||||
这个项目用于单路店内 RTSP 摄像头的人体检测、停留计时和本地 JSON 事件输出。
|
||||
|
||||
## 功能
|
||||
|
||||
- 基于 `YOLO11n` 的店内人员检测与跟踪
|
||||
- 停留时长统计
|
||||
- 短暂离开后重关联
|
||||
- 店员白名单排除
|
||||
- 半小时统计
|
||||
- 本地 `JSONL` 事件输出
|
||||
- 安装完成后自动启动
|
||||
- 开机自动启动
|
||||
|
||||
## 目标机器
|
||||
|
||||
- `Ubuntu 24.04`
|
||||
- `Python 3.12`
|
||||
- NVIDIA 显卡可用
|
||||
- `nvidia-smi` 可正常执行
|
||||
|
||||
## 安装前需要修改
|
||||
|
||||
先编辑 `scripts/run.sh`,至少改这几个变量:
|
||||
|
||||
- `RTSP_URL`
|
||||
- `CAMERA_ID`
|
||||
- `EVENT_SINK_PATH`
|
||||
|
||||
## 安装
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/install.sh
|
||||
```
|
||||
|
||||
安装脚本会自动:
|
||||
|
||||
- 检查并安装 `ffmpeg`
|
||||
- 检查并安装 `python3.12-venv`
|
||||
- 创建 `.venv`
|
||||
- 离线安装 `wheelhouse/` 中的依赖
|
||||
- 生成 `config/local.yaml`
|
||||
- 安装 `systemd` 服务
|
||||
- 自动启动服务
|
||||
- 设置开机自启
|
||||
|
||||
## 服务管理
|
||||
|
||||
服务名:
|
||||
|
||||
```bash
|
||||
store-dwell-alert.service
|
||||
```
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
sudo systemctl status store-dwell-alert.service
|
||||
sudo systemctl restart store-dwell-alert.service
|
||||
sudo systemctl stop store-dwell-alert.service
|
||||
sudo systemctl start store-dwell-alert.service
|
||||
sudo systemctl disable store-dwell-alert.service
|
||||
```
|
||||
|
||||
## 输出位置
|
||||
|
||||
- 运行日志:`logs/runtime.log`
|
||||
- 事件结果:`logs/events.jsonl`
|
||||
- 本地配置:`config/local.yaml`
|
||||
- 店员图库:`data/staff_gallery/`
|
||||
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
|
||||
26
managed/store_dwell_alert/config/config.example.yaml
Normal file
26
managed/store_dwell_alert/config/config.example.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
camera_id: store_cam_01
|
||||
timezone: Asia/Shanghai
|
||||
|
||||
thresholds:
|
||||
min_people: 5
|
||||
min_dwell_seconds: 600
|
||||
pause_timeout_seconds: 300
|
||||
alert_cooldown_seconds: 600
|
||||
|
||||
stream:
|
||||
rtsp_url: rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream
|
||||
sample_fps: 2.0
|
||||
reconnect_backoff_seconds: 5.0
|
||||
|
||||
staff:
|
||||
gallery_dir: data/staff_gallery
|
||||
min_hits: 3
|
||||
similarity_threshold: 0.9
|
||||
|
||||
event_sink:
|
||||
path: logs/events.jsonl
|
||||
|
||||
webhook:
|
||||
alert_url: ""
|
||||
report_url: ""
|
||||
timeout_seconds: 5.0
|
||||
1
managed/store_dwell_alert/data/runtime/.gitkeep
Normal file
1
managed/store_dwell_alert/data/runtime/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
managed/store_dwell_alert/data/staff_gallery/.gitkeep
Normal file
1
managed/store_dwell_alert/data/staff_gallery/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
13
managed/store_dwell_alert/deploy/store-dwell-alert.service
Normal file
13
managed/store_dwell_alert/deploy/store-dwell-alert.service
Normal file
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Store Dwell Alert
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/xiaozheng/store_dwell_alert
|
||||
ExecStart=/home/xiaozheng/store_dwell_alert/.venv/bin/python -m app.main
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Store Dwell Alert
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=__PROJECT_DIR__
|
||||
User=__RUN_USER__
|
||||
Group=__RUN_GROUP__
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=__PROJECT_DIR__/.venv/bin/python -m app.main --config __CONFIG_PATH__
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:__PROJECT_DIR__/logs/runtime.log
|
||||
StandardError=append:__PROJECT_DIR__/logs/runtime.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
21
managed/store_dwell_alert/docker-compose.yml
Normal file
21
managed/store_dwell_alert/docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
store-dwell-alert:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: store-dwell-alert:local
|
||||
container_name: store-dwell-alert
|
||||
ports:
|
||||
- "18081:18081"
|
||||
environment:
|
||||
CAMERA_ID: ${CAMERA_ID:-store_cam_01}
|
||||
RTSP_URL: ${RTSP_URL:-}
|
||||
EVENT_SINK_PATH: ${EVENT_SINK_PATH:-logs/events.jsonl}
|
||||
API_HOST: 0.0.0.0
|
||||
API_PORT: 18081
|
||||
CONFIG_PATH: /app/config/local.yaml
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,326 @@
|
||||
# Store Dwell Alert Design
|
||||
|
||||
**Date:** 2026-04-15
|
||||
**Target Host:** `192.168.5.108`
|
||||
**Video Source:** single `RTSP` stream
|
||||
**Alert Channel:** `HTTP webhook`
|
||||
|
||||
## Goal
|
||||
|
||||
Design a single-camera store monitoring service that detects long customer stays with `YOLO11n`, excludes staff from customer timing, alerts when at least 5 customers have each stayed more than 10 minutes, and emits a half-hour report with current and recently closed dwell sessions.
|
||||
|
||||
## Environment Summary
|
||||
|
||||
The target machine `192.168.5.108` is suitable for this service:
|
||||
|
||||
- `Ubuntu 24.04.4`
|
||||
- `Python 3.12.3`
|
||||
- `NVIDIA RTX 3080 20GB`
|
||||
- `Docker 29.3.0`
|
||||
- `ffmpeg` not installed yet
|
||||
- `ultralytics` not installed yet
|
||||
- ample free disk space
|
||||
|
||||
The comparison host `192.168.5.154` has similar compute capability but much less free disk space, so `.108` is the preferred deployment target.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
- Read one store camera `RTSP` stream continuously.
|
||||
- Detect only `person` objects using `YOLO11n`.
|
||||
- Track people across frames.
|
||||
- If a person briefly leaves and returns within 5 minutes, treat them as the same customer and resume timing.
|
||||
- If a person is absent for more than 5 minutes, close the current dwell session and preserve the dwell time accumulated before the pause.
|
||||
- Exclude staff from customer dwell timing and alert thresholds.
|
||||
- Trigger an alert when 5 or more customers each have `dwell_seconds >= 600`.
|
||||
- Emit a half-hour report every `:00` and `:30`.
|
||||
- Send both alerts and reports through `HTTP webhook`.
|
||||
|
||||
### Non-Functional
|
||||
|
||||
- Run on a single GPU host with long-lived service behavior.
|
||||
- Handle RTSP interruption and auto-reconnect.
|
||||
- Avoid alert spam through cooldown control.
|
||||
- Keep enough local logs and snapshots for troubleshooting.
|
||||
|
||||
## Constraints and Key Decisions
|
||||
|
||||
- There is no stable entrance/exit ROI available.
|
||||
- Re-identification must rely on full-frame tracking plus appearance features rather than doorway logic.
|
||||
- Staff are common, visually stable, and often wear similar clothing, so staff must be excluded via a manually curated whitelist rather than dwell duration heuristics.
|
||||
- The business identity must be `person_id`, not raw `track_id`.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Use `YOLO11n + tracker + appearance ReID + business dwell state machine`.
|
||||
|
||||
Why this approach:
|
||||
|
||||
- `YOLO11n` is fast enough for a single RTSP stream on `.108`.
|
||||
- The main business risk is not raw person detection but stable identity across short disappear/reappear intervals.
|
||||
- A tracker alone cannot satisfy the 5-minute return rule because tracker IDs are short-lived.
|
||||
- A manual staff gallery is safer than attempting to auto-learn "frequent people" as staff.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- `YOLO11n + DeepSORT`: simpler but weaker for multi-minute re-association without a door ROI.
|
||||
- Heavier detector or heavier ReID from day one: more cost and tuning burden than needed for a single camera MVP.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
The service runs as a single process on `192.168.5.108` with these modules:
|
||||
|
||||
1. `stream_reader`
|
||||
Pulls RTSP frames, handles reconnects, timestamps frames, and controls sampling FPS.
|
||||
|
||||
2. `detector_tracker`
|
||||
Runs `YOLO11n` person detection and short-term multi-object tracking to produce `bbox`, `track_id`, confidence, and crops.
|
||||
|
||||
3. `identity_resolver`
|
||||
Converts tracker outputs into stable `person_id` identities. Maintains active identities plus a recent paused cache for re-association within 5 minutes.
|
||||
|
||||
4. `staff_filter`
|
||||
Matches person appearance embeddings against a manually registered staff gallery. Staff are flagged and excluded from customer alert logic.
|
||||
|
||||
5. `dwell_engine`
|
||||
Owns the customer dwell session state machine, threshold evaluation, cooldown logic, and half-hour report windowing.
|
||||
|
||||
6. `notifier_reporter`
|
||||
Sends alert/report webhook payloads, persists local JSONL logs, and optionally stores debug snapshots.
|
||||
|
||||
## Data Flow
|
||||
|
||||
`RTSP -> frame sampling -> YOLO11n person detections -> tracker -> crop/embedding -> staff match -> person re-association -> dwell session update -> alert/report evaluation -> webhook/logging`
|
||||
|
||||
Important design rule:
|
||||
|
||||
- Detection and tracking generate transient runtime IDs.
|
||||
- Business counting, dwell timing, and webhook payloads use stable `person_id`.
|
||||
|
||||
## Identity and Re-Association Model
|
||||
|
||||
Each tracked person is represented by:
|
||||
|
||||
- `person_id`
|
||||
- latest tracker `track_id`
|
||||
- running appearance embedding
|
||||
- current role: `customer` or `staff`
|
||||
- session state: `active`, `paused`, `closed`
|
||||
- `dwell_seconds_accumulated`
|
||||
- `last_seen_ts`
|
||||
- `pause_start_ts`
|
||||
|
||||
Re-association logic:
|
||||
|
||||
- When a current tracker target disappears, its session moves from `active` to `paused`.
|
||||
- While paused, the service retains the appearance embedding and metadata for 5 minutes.
|
||||
- A new tracker target is compared against paused identities.
|
||||
- If similarity passes threshold inside the 5-minute window, the new target is merged back into the original `person_id`.
|
||||
- If no match occurs within 5 minutes, the paused session becomes `closed`.
|
||||
|
||||
This solves "leave briefly, come back, continue timing" without relying on a dedicated entrance area.
|
||||
|
||||
## Staff Exclusion Design
|
||||
|
||||
Staff are handled through a manually maintained gallery:
|
||||
|
||||
- Register each staff member under `staff_id`.
|
||||
- Store multiple body crops per staff member, ideally 3 to 10 images with the expected work uniform.
|
||||
- During runtime, customer candidates are embedded and compared against the gallery.
|
||||
- A single match is not enough; the identity must hit the staff threshold across multiple frames before promotion to `role=staff`.
|
||||
|
||||
Why manual whitelist:
|
||||
|
||||
- Long dwell duration does not imply staff.
|
||||
- Frequent appearance does not imply staff.
|
||||
- The user explicitly identified staff clothing consistency as a usable signal.
|
||||
|
||||
Behavior:
|
||||
|
||||
- `role=staff` identities are excluded from customer threshold counting.
|
||||
- Staff can still be logged separately for observability.
|
||||
|
||||
## Dwell Session State Machine
|
||||
|
||||
Each customer session has three states:
|
||||
|
||||
### `active`
|
||||
|
||||
- Customer currently visible in stream.
|
||||
- Dwell time continues accumulating.
|
||||
|
||||
### `paused`
|
||||
|
||||
- Customer no longer visible.
|
||||
- Dwell accumulation stops immediately.
|
||||
- `pause_start_ts` is recorded.
|
||||
|
||||
### `closed`
|
||||
|
||||
- Customer remained absent for more than 5 minutes.
|
||||
- Session ends and final dwell time is preserved as the accumulated value from before the pause.
|
||||
|
||||
Transitions:
|
||||
|
||||
- `active -> paused`: target disappears.
|
||||
- `paused -> active`: same `person_id` returns within 5 minutes through ReID match.
|
||||
- `paused -> closed`: absent for more than 300 seconds.
|
||||
- `closed`: immutable historical session.
|
||||
|
||||
## Alert Logic
|
||||
|
||||
Alert condition:
|
||||
|
||||
- consider only `role=customer`
|
||||
- consider only `state=active`
|
||||
- count customers with `dwell_seconds >= 600`
|
||||
- if count is `>= 5`, emit `long_stay_alert`
|
||||
|
||||
Cooldown:
|
||||
|
||||
- Apply a 10-minute cooldown after an alert to avoid duplicate webhook spam.
|
||||
- Reset eligibility once long-stay active customer count drops below 5.
|
||||
|
||||
## Half-Hour Report Logic
|
||||
|
||||
Reporting schedule:
|
||||
|
||||
- emit at every local half-hour boundary: `HH:00` and `HH:30`
|
||||
|
||||
Report content:
|
||||
|
||||
- currently active customers and their current dwell times
|
||||
- customers whose sessions closed during the preceding half-hour window
|
||||
- optional staff summary count
|
||||
|
||||
This split is important because otherwise recently departed long-stay customers disappear from operational reporting.
|
||||
|
||||
## Webhook Payloads
|
||||
|
||||
### Long-Stay Alert
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "long_stay_alert",
|
||||
"camera_id": "store_cam_01",
|
||||
"ts": "2026-04-15T11:30:00+08:00",
|
||||
"threshold": {
|
||||
"min_people": 5,
|
||||
"min_dwell_seconds": 600
|
||||
},
|
||||
"active_long_stay_count": 6,
|
||||
"people": [
|
||||
{
|
||||
"person_id": "cust_1024",
|
||||
"role": "customer",
|
||||
"status": "active",
|
||||
"dwell_seconds": 835
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Half-Hour Report
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "half_hour_report",
|
||||
"camera_id": "store_cam_01",
|
||||
"window_start": "2026-04-15T11:00:00+08:00",
|
||||
"window_end": "2026-04-15T11:30:00+08:00",
|
||||
"active_customer_count": 4,
|
||||
"active_customers": [
|
||||
{
|
||||
"person_id": "cust_1024",
|
||||
"dwell_seconds": 835
|
||||
}
|
||||
],
|
||||
"closed_customers": [
|
||||
{
|
||||
"person_id": "cust_0988",
|
||||
"final_dwell_seconds": 1240
|
||||
}
|
||||
],
|
||||
"staff_seen_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment Plan on 192.168.5.108
|
||||
|
||||
Recommended initial deployment:
|
||||
|
||||
- install `ffmpeg`
|
||||
- install `python3-venv`
|
||||
- create a dedicated virtual environment
|
||||
- install runtime dependencies
|
||||
- run as a `systemd` service
|
||||
|
||||
Reasoning:
|
||||
|
||||
- The host is already a straightforward Python/GPU machine.
|
||||
- A venv-based service is easier to debug first than containerizing around RTSP, GPU, and model dependencies.
|
||||
- Containerization can be added later after validation.
|
||||
|
||||
## Suggested Project Structure
|
||||
|
||||
```text
|
||||
store_dwell_alert/
|
||||
app/
|
||||
main.py
|
||||
config.py
|
||||
schemas.py
|
||||
modules/
|
||||
stream_reader.py
|
||||
detector_tracker.py
|
||||
reid_encoder.py
|
||||
identity_resolver.py
|
||||
staff_filter.py
|
||||
dwell_engine.py
|
||||
notifier.py
|
||||
reporter.py
|
||||
config/
|
||||
config.example.yaml
|
||||
data/
|
||||
staff_gallery/
|
||||
runtime/
|
||||
logs/
|
||||
tests/
|
||||
test_config.py
|
||||
test_dwell_engine.py
|
||||
test_identity_resolver.py
|
||||
test_staff_filter.py
|
||||
test_reporter.py
|
||||
scripts/
|
||||
bootstrap_108.sh
|
||||
run_local.sh
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- RTSP disconnect: reconnect with backoff, preserve paused identities during outage.
|
||||
- Webhook failure: retry several times, then persist failed events locally.
|
||||
- Model inference exception: record structured error, restart processing loop.
|
||||
- Disk growth: rotate logs and clean old snapshots.
|
||||
- Low-confidence ReID: keep identity unresolved rather than aggressively merge people.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Minimum acceptance tests:
|
||||
|
||||
1. Single customer stays longer than 10 minutes: no alert because threshold count is below 5.
|
||||
2. Five customers each stay longer than 10 minutes: one alert webhook fires.
|
||||
3. Customer leaves for 3 minutes and returns: same `person_id`, dwell resumes.
|
||||
4. Customer leaves for 6 minutes and returns: old session closes, new session starts.
|
||||
5. Staff remains visible all day: not counted toward customer alert threshold.
|
||||
6. Half-hour report contains both active and closed customer sections.
|
||||
|
||||
## Open Risks
|
||||
|
||||
- Full-frame ReID without a doorway ROI can still produce mistaken merges in crowded scenes.
|
||||
- Staff and customer clothing similarity can cause false staff matches if gallery thresholds are too loose.
|
||||
- Camera angle quality heavily affects body-crop embedding quality.
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
Implement the service first on `192.168.5.108` using `YOLO11n`, a lightweight tracker, an appearance-based re-association cache, and a manual staff whitelist. Keep the first version single-stream, single-process, and webhook-driven. This is the narrowest design that satisfies the user’s required behavior without adding unnecessary operational complexity.
|
||||
@@ -0,0 +1,530 @@
|
||||
# Store Dwell Alert Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build a single-camera RTSP service on `192.168.5.108` that detects long customer stays with `YOLO11n`, excludes staff using a whitelist gallery, resumes dwell timing across short disappear/reappear gaps, and sends HTTP webhook alerts plus half-hour reports.
|
||||
|
||||
**Architecture:** The service is a single Python process organized around RTSP ingestion, person detection/tracking, appearance-based identity resolution, staff filtering, dwell session state management, and webhook/report dispatch. Business timing is keyed on stable `person_id` identities rather than tracker IDs so short absences can pause and resume the same customer session.
|
||||
|
||||
**Tech Stack:** Python 3.12, `ultralytics`, `torch`, `opencv-python`, `ffmpeg`, `pytest`, `requests`, YAML config, `systemd`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Bootstrap project skeleton and configuration loading
|
||||
|
||||
**Files:**
|
||||
- Create: `app/__init__.py`
|
||||
- Create: `app/config.py`
|
||||
- Create: `app/schemas.py`
|
||||
- Create: `config/config.example.yaml`
|
||||
- Create: `tests/test_config.py`
|
||||
- Create: `requirements.txt`
|
||||
- Create: `scripts/bootstrap_108.sh`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import load_config
|
||||
|
||||
|
||||
def test_load_config_reads_thresholds(tmp_path: Path):
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"thresholds:\n"
|
||||
" min_people: 5\n"
|
||||
" min_dwell_seconds: 600\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
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_config.py::test_load_config_reads_thresholds -v`
|
||||
Expected: FAIL because `app.config` and `load_config` do not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class Thresholds:
|
||||
min_people: int
|
||||
min_dwell_seconds: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
camera_id: str
|
||||
thresholds: Thresholds
|
||||
|
||||
|
||||
def load_config(path: Path) -> AppConfig:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
return AppConfig(
|
||||
camera_id=raw["camera_id"],
|
||||
thresholds=Thresholds(**raw["thresholds"]),
|
||||
)
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_config.py::test_load_config_reads_thresholds -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/__init__.py app/config.py app/schemas.py config/config.example.yaml tests/test_config.py requirements.txt scripts/bootstrap_108.sh
|
||||
git commit -m "chore: bootstrap dwell alert project structure"
|
||||
```
|
||||
|
||||
### Task 2: Add RTSP reader with reconnect and frame sampling
|
||||
|
||||
**Files:**
|
||||
- Create: `app/modules/stream_reader.py`
|
||||
- Create: `tests/test_stream_reader.py`
|
||||
- Modify: `requirements.txt`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
from app.modules.stream_reader import StreamHealth
|
||||
|
||||
|
||||
def test_stream_health_marks_disconnect_after_failures():
|
||||
health = StreamHealth(max_failures=3)
|
||||
health.record_failure()
|
||||
health.record_failure()
|
||||
health.record_failure()
|
||||
assert health.is_disconnected is True
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_stream_reader.py::test_stream_health_marks_disconnect_after_failures -v`
|
||||
Expected: FAIL because `StreamHealth` does not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
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
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_stream_reader.py::test_stream_health_marks_disconnect_after_failures -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/modules/stream_reader.py tests/test_stream_reader.py requirements.txt
|
||||
git commit -m "feat: add RTSP reader health tracking"
|
||||
```
|
||||
|
||||
### Task 3: Implement dwell session state machine
|
||||
|
||||
**Files:**
|
||||
- Create: `app/modules/dwell_engine.py`
|
||||
- Create: `tests/test_dwell_engine.py`
|
||||
- Modify: `app/schemas.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
from app.modules.dwell_engine import DwellSession
|
||||
|
||||
|
||||
def test_session_pauses_without_adding_absence_time():
|
||||
session = DwellSession(person_id="cust_1", entered_ts=0)
|
||||
session.mark_seen(120)
|
||||
session.pause(130)
|
||||
session.close_if_expired(431, pause_timeout_seconds=300)
|
||||
assert session.state == "closed"
|
||||
assert session.dwell_seconds == 120
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_dwell_engine.py::test_session_pauses_without_adding_absence_time -v`
|
||||
Expected: FAIL because `DwellSession` does not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class DwellSession:
|
||||
person_id: str
|
||||
entered_ts: int
|
||||
state: str = "active"
|
||||
dwell_seconds: int = 0
|
||||
last_seen_ts: int = 0
|
||||
pause_start_ts: int | None = None
|
||||
|
||||
def mark_seen(self, ts: int) -> None:
|
||||
self.dwell_seconds = ts - self.entered_ts
|
||||
self.last_seen_ts = ts
|
||||
self.state = "active"
|
||||
|
||||
def pause(self, ts: int) -> None:
|
||||
self.pause_start_ts = ts
|
||||
self.state = "paused"
|
||||
|
||||
def close_if_expired(self, ts: int, pause_timeout_seconds: int) -> None:
|
||||
if self.pause_start_ts is None:
|
||||
return
|
||||
if ts - self.pause_start_ts > pause_timeout_seconds:
|
||||
self.state = "closed"
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_dwell_engine.py::test_session_pauses_without_adding_absence_time -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/modules/dwell_engine.py app/schemas.py tests/test_dwell_engine.py
|
||||
git commit -m "feat: add dwell session state machine"
|
||||
```
|
||||
|
||||
### Task 4: Implement paused-person re-association
|
||||
|
||||
**Files:**
|
||||
- Create: `app/modules/identity_resolver.py`
|
||||
- Create: `tests/test_identity_resolver.py`
|
||||
- Modify: `app/schemas.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
from app.modules.identity_resolver import choose_reentry_match
|
||||
|
||||
|
||||
def test_choose_reentry_match_prefers_recent_high_similarity():
|
||||
paused_people = [
|
||||
{"person_id": "cust_1", "paused_at": 100, "similarity": 0.91},
|
||||
{"person_id": "cust_2", "paused_at": 80, "similarity": 0.87},
|
||||
]
|
||||
|
||||
result = choose_reentry_match(
|
||||
paused_people=paused_people,
|
||||
now_ts=250,
|
||||
pause_timeout_seconds=300,
|
||||
min_similarity=0.90,
|
||||
)
|
||||
|
||||
assert result == "cust_1"
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_identity_resolver.py::test_choose_reentry_match_prefers_recent_high_similarity -v`
|
||||
Expected: FAIL because `choose_reentry_match` does not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
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"]
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_identity_resolver.py::test_choose_reentry_match_prefers_recent_high_similarity -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/modules/identity_resolver.py app/schemas.py tests/test_identity_resolver.py
|
||||
git commit -m "feat: add paused-person re-association logic"
|
||||
```
|
||||
|
||||
### Task 5: Implement staff whitelist matching
|
||||
|
||||
**Files:**
|
||||
- Create: `app/modules/staff_filter.py`
|
||||
- Create: `tests/test_staff_filter.py`
|
||||
- Create: `data/staff_gallery/.gitkeep`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
from app.modules.staff_filter import staff_vote
|
||||
|
||||
|
||||
def test_staff_vote_requires_multiple_hits():
|
||||
assert staff_vote([True, False, True], min_hits=2) is True
|
||||
assert staff_vote([True, False, False], min_hits=2) is False
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_staff_filter.py::test_staff_vote_requires_multiple_hits -v`
|
||||
Expected: FAIL because `staff_vote` does not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
def staff_vote(matches: list[bool], min_hits: int) -> bool:
|
||||
return sum(1 for item in matches if item) >= min_hits
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_staff_filter.py::test_staff_vote_requires_multiple_hits -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/modules/staff_filter.py tests/test_staff_filter.py data/staff_gallery/.gitkeep
|
||||
git commit -m "feat: add staff whitelist voting logic"
|
||||
```
|
||||
|
||||
### Task 6: Implement alert thresholding and half-hour reporting
|
||||
|
||||
**Files:**
|
||||
- Create: `app/modules/notifier.py`
|
||||
- Create: `app/modules/reporter.py`
|
||||
- Create: `tests/test_reporter.py`
|
||||
- Modify: `app/modules/dwell_engine.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
from app.modules.reporter import should_emit_half_hour_report
|
||||
|
||||
|
||||
def test_half_hour_report_emits_on_half_hour_boundaries():
|
||||
assert should_emit_half_hour_report("2026-04-15T11:00:00+08:00") is True
|
||||
assert should_emit_half_hour_report("2026-04-15T11:30:00+08:00") is True
|
||||
assert should_emit_half_hour_report("2026-04-15T11:17:00+08:00") is False
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_reporter.py::test_half_hour_report_emits_on_half_hour_boundaries -v`
|
||||
Expected: FAIL because `should_emit_half_hour_report` does not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def should_emit_half_hour_report(ts: str) -> bool:
|
||||
dt = datetime.fromisoformat(ts)
|
||||
return dt.minute in {0, 30} and dt.second == 0
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_reporter.py::test_half_hour_report_emits_on_half_hour_boundaries -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/modules/notifier.py app/modules/reporter.py app/modules/dwell_engine.py tests/test_reporter.py
|
||||
git commit -m "feat: add webhook alert and half-hour reporting logic"
|
||||
```
|
||||
|
||||
### Task 7: Integrate YOLO11n inference and tracker adapter
|
||||
|
||||
**Files:**
|
||||
- Create: `app/modules/detector_tracker.py`
|
||||
- Create: `tests/test_detector_tracker.py`
|
||||
- Modify: `requirements.txt`
|
||||
- Modify: `config/config.example.yaml`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
from app.modules.detector_tracker import filter_person_detections
|
||||
|
||||
|
||||
def test_filter_person_detections_keeps_only_person_class():
|
||||
detections = [
|
||||
{"class_name": "person", "confidence": 0.8},
|
||||
{"class_name": "chair", "confidence": 0.9},
|
||||
]
|
||||
|
||||
result = filter_person_detections(detections)
|
||||
|
||||
assert result == [{"class_name": "person", "confidence": 0.8}]
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_detector_tracker.py::test_filter_person_detections_keeps_only_person_class -v`
|
||||
Expected: FAIL because `filter_person_detections` does not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
def filter_person_detections(detections: list[dict]) -> list[dict]:
|
||||
return [item for item in detections if item["class_name"] == "person"]
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_detector_tracker.py::test_filter_person_detections_keeps_only_person_class -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/modules/detector_tracker.py tests/test_detector_tracker.py requirements.txt config/config.example.yaml
|
||||
git commit -m "feat: add YOLO11n detector and tracker adapter"
|
||||
```
|
||||
|
||||
### Task 8: Integrate application loop and deployment assets
|
||||
|
||||
**Files:**
|
||||
- Create: `app/main.py`
|
||||
- Create: `deploy/store-dwell-alert.service`
|
||||
- Create: `scripts/run_local.sh`
|
||||
- Create: `tests/test_main_smoke.py`
|
||||
- Modify: `README.md`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
from app.main import build_app
|
||||
|
||||
|
||||
def test_build_app_returns_named_components():
|
||||
app = build_app()
|
||||
assert "stream_reader" in app
|
||||
assert "dwell_engine" in app
|
||||
assert "notifier" in app
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_main_smoke.py::test_build_app_returns_named_components -v`
|
||||
Expected: FAIL because `build_app` does not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
def build_app() -> dict:
|
||||
return {
|
||||
"stream_reader": object(),
|
||||
"dwell_engine": object(),
|
||||
"notifier": object(),
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_main_smoke.py::test_build_app_returns_named_components -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/main.py deploy/store-dwell-alert.service scripts/run_local.sh tests/test_main_smoke.py README.md
|
||||
git commit -m "feat: wire app entrypoint and deployment assets"
|
||||
```
|
||||
|
||||
### Task 9: Validate end-to-end scenarios on 192.168.5.108
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/config.example.yaml`
|
||||
- Create: `docs/runbook.md`
|
||||
- Create: `tests/fixtures/events/`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
from app.modules.dwell_engine import long_stay_count
|
||||
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_dwell_engine.py::test_long_stay_count_excludes_staff -v`
|
||||
Expected: FAIL until final integration exposes the helper correctly.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
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
|
||||
)
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_dwell_engine.py::test_long_stay_count_excludes_staff -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/modules/dwell_engine.py config/config.example.yaml docs/runbook.md tests/test_dwell_engine.py tests/fixtures/events
|
||||
git commit -m "test: validate end-to-end alert semantics"
|
||||
```
|
||||
@@ -0,0 +1,121 @@
|
||||
# Store Dwell Alert Offline Bundle Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add a portable offline delivery bundle for `store_dwell_alert_108` so the project can be copied to another machine matching `192.168.5.108` and installed without internet access.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Target machines are assumed to match `.108` closely:
|
||||
- Ubuntu 24.04
|
||||
- Python 3.12
|
||||
- NVIDIA GPU in the RTX 3080 class
|
||||
- Compatible CUDA driver already present
|
||||
- The bundle must not depend on live package downloads.
|
||||
- Production configuration must not be embedded in the bundle.
|
||||
- Operators should edit `scripts/run.sh` after install to fill in the RTSP URL and other local values.
|
||||
- Existing runtime behavior must stay unchanged.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Build a native offline bundle similar to `people_flow_project.gz`, but scoped to this lighter project. The bundle should contain source code, locked Python requirements, wheels, model weights, install/run scripts, and a portable service template. The generated archive should be self-contained for installation on the target machine.
|
||||
|
||||
This is preferred over bundling the current `.venv` because Python virtual environments are less portable across machines, paths, and local shared-library differences. It is also preferred over an online installer because the user requires offline installation.
|
||||
|
||||
## Bundle Layout
|
||||
|
||||
The generated archive should unpack into a single directory such as `store_dwell_alert_bundle/` with the following contents:
|
||||
|
||||
- `app/`
|
||||
- `config/config.example.yaml`
|
||||
- `data/staff_gallery/`
|
||||
- `data/runtime/`
|
||||
- `deploy/store-dwell-alert.service.tpl`
|
||||
- `logs/`
|
||||
- `requirements.txt`
|
||||
- `requirements.lock.txt`
|
||||
- `scripts/install.sh`
|
||||
- `scripts/run.sh`
|
||||
- `scripts/install_service.sh`
|
||||
- `wheelhouse/`
|
||||
- `weights/yolo11n.pt`
|
||||
- `README.md`
|
||||
|
||||
The bundle should intentionally omit host-specific files such as `config/108.local.yaml`, local runtime logs, and any active virtual environment.
|
||||
|
||||
## Dependency Strategy
|
||||
|
||||
Dependencies should be split into two groups:
|
||||
|
||||
1. Runtime Python packages needed on the target machine.
|
||||
2. Runtime assets needed to execute immediately after install.
|
||||
|
||||
The offline installer must use:
|
||||
|
||||
```bash
|
||||
pip install --no-index --find-links wheelhouse -r requirements.lock.txt
|
||||
```
|
||||
|
||||
`requirements.lock.txt` should pin exact versions suitable for the `.108` class of host. It should include the versions already proven on the running deployment where practical, especially for `torch`, `torchvision`, `ultralytics`, `opencv-python-headless`, `numpy`, `PyYAML`, and `requests`.
|
||||
|
||||
The `wheelhouse/` directory should contain all required wheels for the locked requirements set. `weights/` should contain `yolo11n.pt` so the target host does not need to download model weights at first run.
|
||||
|
||||
## Install Flow
|
||||
|
||||
`scripts/install.sh` should:
|
||||
|
||||
1. Verify the project root and required bundled files exist.
|
||||
2. Verify `python3.12` and `ffmpeg` are available on the target machine.
|
||||
3. Create `.venv` under the unpacked bundle directory.
|
||||
4. Install Python packages from `wheelhouse/` using `requirements.lock.txt`.
|
||||
5. Ensure runtime directories exist.
|
||||
6. Print the next-step instruction to edit and run `scripts/run.sh`.
|
||||
|
||||
The installer should fail fast with clear messages if any required file is missing.
|
||||
|
||||
## Run Flow
|
||||
|
||||
`scripts/run.sh` should be the operator-facing entry point. It should:
|
||||
|
||||
- Expose editable shell variables near the top of the file, including:
|
||||
- `RTSP_URL`
|
||||
- `CONFIG_TEMPLATE`
|
||||
- `CONFIG_PATH`
|
||||
- `LOG_DIR`
|
||||
- Generate or refresh a local config file from the template if needed.
|
||||
- Validate that `RTSP_URL` has been changed from its placeholder.
|
||||
- Launch `.venv/bin/python -m app.main --config "$CONFIG_PATH"`.
|
||||
|
||||
The script should avoid storing production configuration in git-tracked files.
|
||||
|
||||
## Service Installation
|
||||
|
||||
`deploy/store-dwell-alert.service.tpl` should be a portable template rather than a host-pinned unit. `scripts/install_service.sh` should render the template using the unpack path and config path, then install the final service file with `systemctl`.
|
||||
|
||||
This keeps the bundle portable even when unpacked into different directories on different machines.
|
||||
|
||||
## Packaging Workflow
|
||||
|
||||
Add a development-side script `scripts/package_bundle.sh` that:
|
||||
|
||||
1. Creates a clean staging directory under `dist/`.
|
||||
2. Copies the required source tree and assets into the staging directory.
|
||||
3. Generates `requirements.lock.txt`.
|
||||
4. Builds or refreshes `wheelhouse/`.
|
||||
5. Ensures `weights/yolo11n.pt` is present.
|
||||
6. Writes bundle-facing scripts and service template into the staging directory.
|
||||
7. Produces a tarball such as `dist/store_dwell_alert_bundle_2026-04-16.tar.gz`.
|
||||
|
||||
The script should be idempotent and safe to rerun.
|
||||
|
||||
## Validation
|
||||
|
||||
The bundle feature is complete when all of the following are true:
|
||||
|
||||
- A tarball can be generated locally from the project.
|
||||
- The tarball unpacks into the expected layout.
|
||||
- `scripts/install.sh` installs without using the network.
|
||||
- `scripts/run.sh` starts the app after the operator sets `RTSP_URL`.
|
||||
- `python -m app.main --once` works on a compatible target host using the bundle environment.
|
||||
- Events are written to `logs/events.jsonl`.
|
||||
- The optional service install flow can render and install a working unit file.
|
||||
@@ -0,0 +1,240 @@
|
||||
# Offline Bundle Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build a self-contained offline delivery bundle for `store_dwell_alert_108` that can be unpacked on a `.108`-class machine, installed without internet, and run after the operator edits `scripts/run.sh`.
|
||||
|
||||
**Architecture:** Add a delivery layer around the existing runtime by introducing a bundle staging pipeline, a locked requirements file, an offline wheelhouse, portable install/run scripts, and a relocatable service template. Keep the application logic unchanged and isolate all new behavior to packaging, deployment, and documentation.
|
||||
|
||||
**Tech Stack:** Bash, Python virtual environments, pip wheelhouse installs, tar.gz packaging, systemd, existing `ultralytics` runtime.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Audit runtime inputs and delivery assets
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Create: `docs/plans/2026-04-16-offline-bundle-design.md`
|
||||
- Create: `docs/plans/2026-04-16-offline-bundle.md`
|
||||
|
||||
**Step 1: Verify the current runtime entrypoints and required assets**
|
||||
|
||||
Check `app/main.py`, `requirements.txt`, `config/config.example.yaml`, `scripts/run_local.sh`, and `deploy/store-dwell-alert.service` to identify everything the bundle must include.
|
||||
|
||||
**Step 2: Update the top-level README scope if needed**
|
||||
|
||||
Add a short note that the project now supports building an offline delivery bundle for compatible hosts.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md docs/plans/2026-04-16-offline-bundle-design.md docs/plans/2026-04-16-offline-bundle.md
|
||||
git commit -m "docs: add offline bundle design and plan"
|
||||
```
|
||||
|
||||
### Task 2: Add bundle-facing deployment files
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/store-dwell-alert.service.tpl`
|
||||
- Create: `scripts/install.sh`
|
||||
- Create: `scripts/run.sh`
|
||||
- Create: `scripts/install_service.sh`
|
||||
- Test: `tests/test_bundle_layout.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests that assert:
|
||||
- the new scripts exist
|
||||
- `run.sh` contains a placeholder `RTSP_URL`
|
||||
- the service template contains placeholder tokens rather than a host-pinned path
|
||||
|
||||
**Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_bundle_layout.py -v`
|
||||
|
||||
**Step 3: Write the minimal implementation**
|
||||
|
||||
Create the installer, runner, and service template files with portable placeholders and safe shell behavior.
|
||||
|
||||
**Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_bundle_layout.py -v`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/store-dwell-alert.service.tpl scripts/install.sh scripts/run.sh scripts/install_service.sh tests/test_bundle_layout.py
|
||||
git commit -m "feat: add offline bundle deployment scripts"
|
||||
```
|
||||
|
||||
### Task 3: Lock Python dependencies for offline delivery
|
||||
|
||||
**Files:**
|
||||
- Create: `requirements.lock.txt`
|
||||
- Modify: `requirements.txt`
|
||||
- Test: `tests/test_bundle_layout.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Extend bundle layout tests to assert:
|
||||
- `requirements.lock.txt` exists
|
||||
- it pins exact versions for the runtime packages needed by the bundle
|
||||
|
||||
**Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_bundle_layout.py -v`
|
||||
|
||||
**Step 3: Write the minimal implementation**
|
||||
|
||||
Add a checked-in `requirements.lock.txt` aligned with the known-good runtime stack and keep `requirements.txt` as the human-edited high-level list if needed.
|
||||
|
||||
**Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_bundle_layout.py -v`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add requirements.txt requirements.lock.txt tests/test_bundle_layout.py
|
||||
git commit -m "build: lock offline bundle dependencies"
|
||||
```
|
||||
|
||||
### Task 4: Add bundle staging and archive generation
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/package_bundle.sh`
|
||||
- Modify: `README.md`
|
||||
- Test: `tests/test_bundle_layout.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests that assert:
|
||||
- `scripts/package_bundle.sh` exists
|
||||
- it references the expected bundle contents
|
||||
- it excludes host-specific config such as `config/108.local.yaml`
|
||||
|
||||
**Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_bundle_layout.py -v`
|
||||
|
||||
**Step 3: Write the minimal implementation**
|
||||
|
||||
Create `scripts/package_bundle.sh` to stage the bundle into `dist/store_dwell_alert_bundle/` and archive it as `dist/store_dwell_alert_bundle_<date>.tar.gz`.
|
||||
|
||||
**Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_bundle_layout.py -v`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/package_bundle.sh README.md tests/test_bundle_layout.py
|
||||
git commit -m "build: add offline bundle packaging script"
|
||||
```
|
||||
|
||||
### Task 5: Vendor weights and prepare runtime directories
|
||||
|
||||
**Files:**
|
||||
- Create: `weights/.gitkeep`
|
||||
- Create: `data/staff_gallery/.gitkeep`
|
||||
- Create: `data/runtime/.gitkeep`
|
||||
- Modify: `README.md`
|
||||
- Test: `tests/test_bundle_layout.py`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests that assert the bundle pipeline expects:
|
||||
- `weights/`
|
||||
- `data/staff_gallery/`
|
||||
- `data/runtime/`
|
||||
|
||||
**Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_bundle_layout.py -v`
|
||||
|
||||
**Step 3: Write the minimal implementation**
|
||||
|
||||
Create the tracked directories and document that `weights/yolo11n.pt` must exist before packaging.
|
||||
|
||||
**Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_bundle_layout.py -v`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add weights/.gitkeep data/staff_gallery/.gitkeep data/runtime/.gitkeep README.md tests/test_bundle_layout.py
|
||||
git commit -m "chore: track offline bundle asset directories"
|
||||
```
|
||||
|
||||
### Task 6: Document operator workflow
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Test: `tests/test_main_smoke.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
If practical, add a smoke-level documentation assertion or adjust an existing smoke test so the offline scripts are covered by a simple presence/usage check.
|
||||
|
||||
**Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_main_smoke.py -v`
|
||||
|
||||
**Step 3: Write the minimal implementation**
|
||||
|
||||
Document:
|
||||
- how to build the bundle
|
||||
- how to transfer it
|
||||
- how to run `scripts/install.sh`
|
||||
- how to edit `scripts/run.sh`
|
||||
- how to optionally install the service
|
||||
|
||||
**Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_main_smoke.py -v`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md tests/test_main_smoke.py
|
||||
git commit -m "docs: add offline bundle operator workflow"
|
||||
```
|
||||
|
||||
### Task 7: Produce and verify a real bundle locally
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/package_bundle.sh`
|
||||
- Modify: `README.md`
|
||||
|
||||
**Step 1: Run the packaging script**
|
||||
|
||||
Run: `bash scripts/package_bundle.sh`
|
||||
|
||||
Expected:
|
||||
- `dist/store_dwell_alert_bundle/` is created
|
||||
- `dist/store_dwell_alert_bundle_<date>.tar.gz` is created
|
||||
|
||||
**Step 2: Inspect the archive contents**
|
||||
|
||||
Run: `tar -tzf dist/store_dwell_alert_bundle_<date>.tar.gz`
|
||||
|
||||
Expected:
|
||||
- bundled scripts, config template, service template, app code, `requirements.lock.txt`, and tracked runtime directories are present
|
||||
|
||||
**Step 3: Fix any path or omission bugs**
|
||||
|
||||
Adjust the packaging script until the archive layout matches the design.
|
||||
|
||||
**Step 4: Run the full test suite**
|
||||
|
||||
Run: `pytest -q`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/package_bundle.sh README.md
|
||||
git commit -m "build: verify offline bundle artifact generation"
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
# Service Autostart Implementation Plan
|
||||
|
||||
**Goal:** Make both `store_dwell_alert_108` and `people_flow_project` install into a `systemd`-managed service that starts immediately after install and automatically starts on boot.
|
||||
|
||||
**Scope:**
|
||||
- Add dependency checks and auto-install for lightweight system packages.
|
||||
- Generate local runtime config from editable script variables.
|
||||
- Install and start `systemd` services from project-local templates.
|
||||
- Add Chinese README files to both projects.
|
||||
- Rebuild distributable archives after the changes.
|
||||
|
||||
**Execution Steps:**
|
||||
1. Update `store_dwell_alert_108` scripts to auto-install missing system packages, prepare config, install service, and `enable --now`.
|
||||
2. Add a dedicated Chinese deployment README to `store_dwell_alert_108`.
|
||||
3. Refactor `people_flow_project` to use a generated local config, a foreground `run.sh`, and a `systemd` service template.
|
||||
4. Add a dedicated Chinese deployment README to `people_flow_project`.
|
||||
5. Validate script and Python entrypoint behavior.
|
||||
6. Rebuild both project archives.
|
||||
50
managed/store_dwell_alert/requirements.lock.txt
Normal file
50
managed/store_dwell_alert/requirements.lock.txt
Normal file
@@ -0,0 +1,50 @@
|
||||
certifi==2026.2.25
|
||||
charset-normalizer==3.4.7
|
||||
contourpy==1.3.3
|
||||
cycler==0.12.1
|
||||
filelock==3.25.2
|
||||
fonttools==4.62.1
|
||||
fsspec==2026.2.0
|
||||
idna==3.11
|
||||
Jinja2==3.1.6
|
||||
kiwisolver==1.5.0
|
||||
lap==0.5.13
|
||||
MarkupSafe==3.0.3
|
||||
matplotlib==3.10.8
|
||||
mpmath==1.3.0
|
||||
networkx==3.6.1
|
||||
numpy==2.4.3
|
||||
nvidia-cublas-cu12==12.4.5.8
|
||||
nvidia-cuda-cupti-cu12==12.4.127
|
||||
nvidia-cuda-nvrtc-cu12==12.4.127
|
||||
nvidia-cuda-runtime-cu12==12.4.127
|
||||
nvidia-cudnn-cu12==9.1.0.70
|
||||
nvidia-cufft-cu12==11.2.1.3
|
||||
nvidia-curand-cu12==10.3.5.147
|
||||
nvidia-cusolver-cu12==11.6.1.9
|
||||
nvidia-cusparse-cu12==12.3.1.170
|
||||
nvidia-cusparselt-cu12==0.6.2
|
||||
nvidia-nccl-cu12==2.21.5
|
||||
nvidia-nvjitlink-cu12==12.4.127
|
||||
nvidia-nvtx-cu12==12.4.127
|
||||
opencv-python-headless==4.13.0.92
|
||||
packaging==26.1
|
||||
pillow==12.1.1
|
||||
polars==1.39.3
|
||||
polars-runtime-32==1.39.3
|
||||
psutil==7.2.2
|
||||
PyYAML==6.0.3
|
||||
pyparsing==3.3.2
|
||||
python-dateutil==2.9.0.post0
|
||||
requests==2.33.1
|
||||
scipy==1.17.1
|
||||
setuptools==70.2.0
|
||||
six==1.17.0
|
||||
sympy==1.13.1
|
||||
torch==2.6.0+cu124
|
||||
torchvision==0.21.0+cu124
|
||||
triton==3.2.0
|
||||
typing_extensions==4.15.0
|
||||
ultralytics==8.4.37
|
||||
ultralytics-thop==2.0.18
|
||||
urllib3==2.6.3
|
||||
5
managed/store_dwell_alert/requirements.txt
Normal file
5
managed/store_dwell_alert/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Flask>=3.1
|
||||
PyYAML>=6.0
|
||||
opencv-python-headless>=4.10
|
||||
requests>=2.32
|
||||
ultralytics>=8.3
|
||||
15
managed/store_dwell_alert/scripts/bootstrap_108.sh
Normal file
15
managed/store_dwell_alert/scripts/bootstrap_108.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="${PROJECT_DIR:-$HOME/store_dwell_alert}"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg python3-venv
|
||||
|
||||
"$PYTHON_BIN" -m venv "$PROJECT_DIR/.venv"
|
||||
"$PROJECT_DIR/.venv/bin/pip" install --upgrade pip
|
||||
"$PROJECT_DIR/.venv/bin/pip" install --no-cache-dir --index-url https://download.pytorch.org/whl/cu124 torch torchvision
|
||||
"$PROJECT_DIR/.venv/bin/pip" install -r "$PROJECT_DIR/requirements.txt"
|
||||
|
||||
echo "Bootstrap complete for $PROJECT_DIR"
|
||||
43
managed/store_dwell_alert/scripts/docker-entrypoint.sh
Executable file
43
managed/store_dwell_alert/scripts/docker-entrypoint.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
PROJECT_DIR="/app"
|
||||
CONFIG_TEMPLATE="${PROJECT_DIR}/config/config.example.yaml"
|
||||
CONFIG_PATH="${CONFIG_PATH:-${PROJECT_DIR}/config/local.yaml}"
|
||||
LOG_DIR="${PROJECT_DIR}/logs"
|
||||
CAMERA_ID="${CAMERA_ID:-store_cam_01}"
|
||||
RTSP_URL="${RTSP_URL:-}"
|
||||
EVENT_SINK_PATH="${EVENT_SINK_PATH:-logs/events.jsonl}"
|
||||
API_HOST="${API_HOST:-0.0.0.0}"
|
||||
API_PORT="${API_PORT:-18081}"
|
||||
|
||||
mkdir -p "${LOG_DIR}" "$(dirname "${CONFIG_PATH}")"
|
||||
|
||||
if [ ! -f "${CONFIG_PATH}" ]; then
|
||||
cp "${CONFIG_TEMPLATE}" "${CONFIG_PATH}"
|
||||
fi
|
||||
|
||||
python - "$CONFIG_PATH" "$CAMERA_ID" "$RTSP_URL" "$EVENT_SINK_PATH" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import yaml
|
||||
|
||||
config_path = Path(sys.argv[1])
|
||||
camera_id = sys.argv[2]
|
||||
rtsp_url = sys.argv[3]
|
||||
event_sink_path = sys.argv[4]
|
||||
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
raw["camera_id"] = camera_id
|
||||
stream = raw.setdefault("stream", {})
|
||||
if rtsp_url:
|
||||
stream["rtsp_url"] = rtsp_url
|
||||
event_sink = raw.setdefault("event_sink", {})
|
||||
event_sink["path"] = event_sink_path
|
||||
config_path.write_text(
|
||||
yaml.safe_dump(raw, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
|
||||
exec python -m app.manage_api --config "${CONFIG_PATH}" --host "${API_HOST}" --port "${API_PORT}"
|
||||
107
managed/store_dwell_alert/scripts/install.sh
Executable file
107
managed/store_dwell_alert/scripts/install.sh
Executable file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
|
||||
VENV_DIR="${PROJECT_DIR}/.venv"
|
||||
WHEELHOUSE_DIR="${PROJECT_DIR}/wheelhouse"
|
||||
LOCK_FILE="${PROJECT_DIR}/requirements.lock.txt"
|
||||
WEIGHTS_FILE="${PROJECT_DIR}/weights/yolo11n.pt"
|
||||
RUN_SCRIPT="${PROJECT_DIR}/scripts/run.sh"
|
||||
INSTALL_SERVICE_SCRIPT="${PROJECT_DIR}/scripts/install_service.sh"
|
||||
PROJECT_USER="${SUDO_USER:-$(id -un)}"
|
||||
|
||||
run_privileged() {
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
sudo "$@"
|
||||
}
|
||||
|
||||
run_project_user() {
|
||||
if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" ]]; then
|
||||
sudo -u "${PROJECT_USER}" -H "$@"
|
||||
return
|
||||
fi
|
||||
"$@"
|
||||
}
|
||||
|
||||
ensure_system_package() {
|
||||
local command_name="$1"
|
||||
local package_name="$2"
|
||||
if command -v "${command_name}" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Installing missing package: ${package_name}"
|
||||
run_privileged apt-get -o Acquire::ForceIPv4=true update
|
||||
run_privileged apt-get -o Acquire::ForceIPv4=true install -y "${package_name}"
|
||||
}
|
||||
|
||||
require_file() {
|
||||
local target="$1"
|
||||
if [[ ! -e "${target}" ]]; then
|
||||
echo "Missing required file: ${target}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
|
||||
echo "Python interpreter not found: ${PYTHON_BIN}" >&2
|
||||
echo "Set PYTHON_BIN=/path/to/python3.12 if needed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_system_package ffmpeg ffmpeg
|
||||
|
||||
if [[ ! -d "/usr/lib/python3.12/venv" && ! -d "/usr/lib/python3.12/ensurepip" ]]; then
|
||||
echo "Installing missing package: python3.12-venv"
|
||||
run_privileged apt-get -o Acquire::ForceIPv4=true update
|
||||
run_privileged apt-get -o Acquire::ForceIPv4=true install -y python3.12-venv
|
||||
fi
|
||||
|
||||
if ! command -v nvidia-smi >/dev/null 2>&1; then
|
||||
echo "nvidia-smi is required but not installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_file "${LOCK_FILE}"
|
||||
require_file "${WEIGHTS_FILE}"
|
||||
require_file "${RUN_SCRIPT}"
|
||||
require_file "${INSTALL_SERVICE_SCRIPT}"
|
||||
|
||||
if [[ ! -d "${WHEELHOUSE_DIR}" ]]; then
|
||||
echo "Missing wheelhouse directory: ${WHEELHOUSE_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_project_user "${PYTHON_BIN}" -m venv "${VENV_DIR}"
|
||||
run_project_user "${VENV_DIR}/bin/python" -m ensurepip --upgrade
|
||||
while IFS= read -r requirement; do
|
||||
if [[ -z "${requirement}" ]]; then
|
||||
continue
|
||||
fi
|
||||
run_project_user "${VENV_DIR}/bin/python" -m pip install \
|
||||
--no-index \
|
||||
--no-deps \
|
||||
--find-links "${WHEELHOUSE_DIR}" \
|
||||
"${requirement}"
|
||||
done < "${LOCK_FILE}"
|
||||
|
||||
mkdir -p \
|
||||
"${PROJECT_DIR}/config" \
|
||||
"${PROJECT_DIR}/data/runtime" \
|
||||
"${PROJECT_DIR}/data/staff_gallery" \
|
||||
"${PROJECT_DIR}/logs"
|
||||
|
||||
run_project_user bash "${RUN_SCRIPT}" --prepare-only
|
||||
bash "${INSTALL_SERVICE_SCRIPT}"
|
||||
run_privileged systemctl enable --now store-dwell-alert.service
|
||||
|
||||
cat <<EOF
|
||||
Offline install complete.
|
||||
Service started and enabled on boot: store-dwell-alert.service
|
||||
Runtime log: ${PROJECT_DIR}/logs/runtime.log
|
||||
Event sink: ${PROJECT_DIR}/logs/events.jsonl
|
||||
EOF
|
||||
33
managed/store_dwell_alert/scripts/install_service.sh
Executable file
33
managed/store_dwell_alert/scripts/install_service.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEMPLATE_PATH="${PROJECT_DIR}/deploy/store-dwell-alert.service.tpl"
|
||||
CONFIG_PATH="${CONFIG_PATH:-${PROJECT_DIR}/config/local.yaml}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-store-dwell-alert.service}"
|
||||
OUTPUT_PATH="${PROJECT_DIR}/deploy/${SERVICE_NAME}"
|
||||
RUN_USER="${RUN_USER:-${SUDO_USER:-$(id -un)}}"
|
||||
RUN_GROUP="${RUN_GROUP:-$(id -gn "${RUN_USER}")}"
|
||||
|
||||
if [[ ! -f "${TEMPLATE_PATH}" ]]; then
|
||||
echo "Missing service template: ${TEMPLATE_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${CONFIG_PATH}" ]]; then
|
||||
echo "Missing config file: ${CONFIG_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed \
|
||||
-e "s|__PROJECT_DIR__|${PROJECT_DIR}|g" \
|
||||
-e "s|__CONFIG_PATH__|${CONFIG_PATH}|g" \
|
||||
-e "s|__RUN_USER__|${RUN_USER}|g" \
|
||||
-e "s|__RUN_GROUP__|${RUN_GROUP}|g" \
|
||||
"${TEMPLATE_PATH}" > "${OUTPUT_PATH}"
|
||||
|
||||
sudo cp "${OUTPUT_PATH}" "/etc/systemd/system/${SERVICE_NAME}"
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
echo "Service installed to /etc/systemd/system/${SERVICE_NAME}"
|
||||
echo "Enable and start it with: sudo systemctl enable --now ${SERVICE_NAME}"
|
||||
64
managed/store_dwell_alert/scripts/package_bundle.sh
Executable file
64
managed/store_dwell_alert/scripts/package_bundle.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BUNDLE_NAME="${BUNDLE_NAME:-store_dwell_alert_bundle}"
|
||||
BUILD_DATE="${BUILD_DATE:-$(date +%F)}"
|
||||
DIST_DIR="${PROJECT_DIR}/dist"
|
||||
STAGE_DIR="${DIST_DIR}/${BUNDLE_NAME}"
|
||||
ARCHIVE_PATH="${DIST_DIR}/${BUNDLE_NAME}_${BUILD_DATE}.tar.gz"
|
||||
WHEELHOUSE_SOURCE="${WHEELHOUSE_SOURCE:-${PROJECT_DIR}/wheelhouse}"
|
||||
WEIGHTS_SOURCE="${WEIGHTS_SOURCE:-${PROJECT_DIR}/weights/yolo11n.pt}"
|
||||
|
||||
require_path() {
|
||||
local target="$1"
|
||||
if [[ ! -e "${target}" ]]; then
|
||||
echo "Missing required path: ${target}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_path "${PROJECT_DIR}/app"
|
||||
require_path "${PROJECT_DIR}/config/config.example.yaml"
|
||||
require_path "${PROJECT_DIR}/deploy/store-dwell-alert.service.tpl"
|
||||
require_path "${PROJECT_DIR}/requirements.txt"
|
||||
require_path "${PROJECT_DIR}/requirements.lock.txt"
|
||||
require_path "${PROJECT_DIR}/README.md"
|
||||
require_path "${PROJECT_DIR}/README_zh.md"
|
||||
require_path "${PROJECT_DIR}/scripts/install.sh"
|
||||
require_path "${PROJECT_DIR}/scripts/run.sh"
|
||||
require_path "${PROJECT_DIR}/scripts/install_service.sh"
|
||||
require_path "${WHEELHOUSE_SOURCE}"
|
||||
require_path "${WEIGHTS_SOURCE}"
|
||||
|
||||
rm -rf "${STAGE_DIR}"
|
||||
mkdir -p \
|
||||
"${STAGE_DIR}/config" \
|
||||
"${STAGE_DIR}/data/runtime" \
|
||||
"${STAGE_DIR}/data/staff_gallery" \
|
||||
"${STAGE_DIR}/deploy" \
|
||||
"${STAGE_DIR}/logs" \
|
||||
"${STAGE_DIR}/scripts" \
|
||||
"${STAGE_DIR}/weights"
|
||||
|
||||
cp -R "${PROJECT_DIR}/app" "${STAGE_DIR}/app"
|
||||
cp "${PROJECT_DIR}/README.md" "${STAGE_DIR}/README.md"
|
||||
cp "${PROJECT_DIR}/README_zh.md" "${STAGE_DIR}/README_zh.md"
|
||||
cp "${PROJECT_DIR}/requirements.txt" "${STAGE_DIR}/requirements.txt"
|
||||
cp "${PROJECT_DIR}/requirements.lock.txt" "${STAGE_DIR}/requirements.lock.txt"
|
||||
cp "${PROJECT_DIR}/config/config.example.yaml" "${STAGE_DIR}/config/config.example.yaml"
|
||||
cp "${PROJECT_DIR}/deploy/store-dwell-alert.service.tpl" "${STAGE_DIR}/deploy/store-dwell-alert.service.tpl"
|
||||
cp "${PROJECT_DIR}/scripts/install.sh" "${STAGE_DIR}/scripts/install.sh"
|
||||
cp "${PROJECT_DIR}/scripts/run.sh" "${STAGE_DIR}/scripts/run.sh"
|
||||
cp "${PROJECT_DIR}/scripts/install_service.sh" "${STAGE_DIR}/scripts/install_service.sh"
|
||||
cp "${WEIGHTS_SOURCE}" "${STAGE_DIR}/weights/yolo11n.pt"
|
||||
cp -R "${WHEELHOUSE_SOURCE}" "${STAGE_DIR}/wheelhouse"
|
||||
|
||||
chmod +x \
|
||||
"${STAGE_DIR}/scripts/install.sh" \
|
||||
"${STAGE_DIR}/scripts/run.sh" \
|
||||
"${STAGE_DIR}/scripts/install_service.sh"
|
||||
|
||||
rm -f "${ARCHIVE_PATH}"
|
||||
tar -czf "${ARCHIVE_PATH}" -C "${DIST_DIR}" "${BUNDLE_NAME}"
|
||||
echo "Bundle created: ${ARCHIVE_PATH}"
|
||||
44
managed/store_dwell_alert/scripts/run.sh
Executable file
44
managed/store_dwell_alert/scripts/run.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENV_PYTHON="${PROJECT_DIR}/.venv/bin/python"
|
||||
CONFIG_TEMPLATE="${PROJECT_DIR}/config/config.example.yaml"
|
||||
CONFIG_PATH="${PROJECT_DIR}/config/local.yaml"
|
||||
LOG_DIR="${PROJECT_DIR}/logs"
|
||||
CAMERA_ID="${CAMERA_ID:-store_cam_01}"
|
||||
RTSP_URL="${RTSP_URL:-rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream}"
|
||||
EVENT_SINK_PATH="${EVENT_SINK_PATH:-logs/events.jsonl}"
|
||||
PREPARE_ONLY=0
|
||||
|
||||
if [[ "${1:-}" == "--prepare-only" ]]; then
|
||||
PREPARE_ONLY=1
|
||||
shift
|
||||
fi
|
||||
|
||||
if [[ ! -x "${VENV_PYTHON}" ]]; then
|
||||
echo "Virtual environment is missing. Run scripts/install.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${RTSP_URL}" == "rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream" ]]; then
|
||||
echo "Please edit scripts/run.sh and set RTSP_URL before starting." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${LOG_DIR}"
|
||||
|
||||
cp "${CONFIG_TEMPLATE}" "${CONFIG_PATH}"
|
||||
sed -i.bak \
|
||||
-e "s|^camera_id: .*|camera_id: ${CAMERA_ID}|" \
|
||||
-e "s|^ rtsp_url: .*| rtsp_url: ${RTSP_URL}|" \
|
||||
-e "s|^ path: .*| path: ${EVENT_SINK_PATH}|" \
|
||||
"${CONFIG_PATH}"
|
||||
rm -f "${CONFIG_PATH}.bak"
|
||||
|
||||
if [[ "${PREPARE_ONLY}" -eq 1 ]]; then
|
||||
echo "Prepared config at ${CONFIG_PATH}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec "${VENV_PYTHON}" -m app.main --config "${CONFIG_PATH}" "$@"
|
||||
6
managed/store_dwell_alert/scripts/run_local.sh
Normal file
6
managed/store_dwell_alert/scripts/run_local.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
"$PROJECT_DIR/.venv/bin/python" -m app.main "$@"
|
||||
55
managed/store_dwell_alert/tests/test_bundle_layout.py
Normal file
55
managed/store_dwell_alert/tests/test_bundle_layout.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_offline_bundle_files_exist():
|
||||
assert (PROJECT_ROOT / "requirements.lock.txt").exists()
|
||||
assert (PROJECT_ROOT / "deploy" / "store-dwell-alert.service.tpl").exists()
|
||||
assert (PROJECT_ROOT / "scripts" / "install.sh").exists()
|
||||
assert (PROJECT_ROOT / "scripts" / "run.sh").exists()
|
||||
assert (PROJECT_ROOT / "scripts" / "install_service.sh").exists()
|
||||
assert (PROJECT_ROOT / "scripts" / "package_bundle.sh").exists()
|
||||
|
||||
|
||||
def test_run_script_contains_placeholder_rtsp_and_local_config():
|
||||
content = (PROJECT_ROOT / "scripts" / "run.sh").read_text(encoding="utf-8")
|
||||
assert 'RTSP_URL="${RTSP_URL:-rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream}"' in content
|
||||
assert 'CONFIG_PATH="${PROJECT_DIR}/config/local.yaml"' in content
|
||||
assert "Please edit scripts/run.sh and set RTSP_URL before starting." in content
|
||||
|
||||
|
||||
def test_service_template_uses_portable_tokens():
|
||||
content = (PROJECT_ROOT / "deploy" / "store-dwell-alert.service.tpl").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert "__PROJECT_DIR__" in content
|
||||
assert "__CONFIG_PATH__" in content
|
||||
assert "/home/xiaozheng/store_dwell_alert" not in content
|
||||
|
||||
|
||||
def test_locked_requirements_pin_runtime_versions():
|
||||
content = (PROJECT_ROOT / "requirements.lock.txt").read_text(encoding="utf-8")
|
||||
assert "torch==2.6.0+cu124" in content
|
||||
assert "torchvision==0.21.0+cu124" in content
|
||||
assert "ultralytics==8.4.37" in content
|
||||
assert "opencv-python-headless==4.13.0.92" in content
|
||||
assert "PyYAML==6.0.3" in content
|
||||
assert "requests==2.33.1" in content
|
||||
|
||||
|
||||
def test_packaging_script_stages_expected_bundle_inputs():
|
||||
content = (PROJECT_ROOT / "scripts" / "package_bundle.sh").read_text(encoding="utf-8")
|
||||
assert 'BUNDLE_NAME="${BUNDLE_NAME:-store_dwell_alert_bundle}"' in content
|
||||
assert 'WHEELHOUSE_SOURCE="${WHEELHOUSE_SOURCE:-${PROJECT_DIR}/wheelhouse}"' in content
|
||||
assert 'WEIGHTS_SOURCE="${WEIGHTS_SOURCE:-${PROJECT_DIR}/weights/yolo11n.pt}"' in content
|
||||
assert 'cp "${PROJECT_DIR}/config/config.example.yaml" "${STAGE_DIR}/config/config.example.yaml"' in content
|
||||
assert 'cp -R "${WHEELHOUSE_SOURCE}" "${STAGE_DIR}/wheelhouse"' in content
|
||||
assert "config/108.local.yaml" not in content
|
||||
|
||||
|
||||
def test_asset_directories_are_tracked():
|
||||
assert (PROJECT_ROOT / "data" / "runtime" / ".gitkeep").exists()
|
||||
assert (PROJECT_ROOT / "data" / "staff_gallery" / ".gitkeep").exists()
|
||||
assert (PROJECT_ROOT / "weights" / ".gitkeep").exists()
|
||||
32
managed/store_dwell_alert/tests/test_config.py
Normal file
32
managed/store_dwell_alert/tests/test_config.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import load_config
|
||||
|
||||
|
||||
def test_load_config_reads_thresholds(tmp_path: Path):
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"thresholds:\n"
|
||||
" min_people: 5\n"
|
||||
" min_dwell_seconds: 600\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
|
||||
|
||||
|
||||
def test_load_config_uses_defaults_for_optional_sections(tmp_path: Path):
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("camera_id: store_cam_01\n", encoding="utf-8")
|
||||
|
||||
data = load_config(cfg)
|
||||
|
||||
assert data.stream.sample_fps == 2.0
|
||||
assert data.staff.min_hits == 3
|
||||
assert data.event_sink.path == "logs/events.jsonl"
|
||||
assert data.webhook.timeout_seconds == 5.0
|
||||
64
managed/store_dwell_alert/tests/test_detector_tracker.py
Normal file
64
managed/store_dwell_alert/tests/test_detector_tracker.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from app.modules.detector_tracker import (
|
||||
attach_track_signatures,
|
||||
extract_tracked_people,
|
||||
filter_person_detections,
|
||||
)
|
||||
|
||||
|
||||
def test_filter_person_detections_keeps_only_person_class():
|
||||
detections = [
|
||||
{"class_name": "person", "confidence": 0.8},
|
||||
{"class_name": "chair", "confidence": 0.9},
|
||||
]
|
||||
|
||||
result = filter_person_detections(detections)
|
||||
|
||||
assert result == [{"class_name": "person", "confidence": 0.8}]
|
||||
|
||||
|
||||
def test_extract_tracked_people_keeps_track_metadata():
|
||||
class FakeBox:
|
||||
def __init__(self, cls_idx, confidence, track_id, xyxy):
|
||||
self.cls = [cls_idx]
|
||||
self.conf = [confidence]
|
||||
self.id = [track_id]
|
||||
self.xyxy = [xyxy]
|
||||
|
||||
class FakeResult:
|
||||
names = {0: "person", 56: "chair"}
|
||||
|
||||
def __init__(self):
|
||||
self.boxes = [
|
||||
FakeBox(0, 0.87, 11, [1, 2, 3, 4]),
|
||||
FakeBox(56, 0.99, 12, [9, 9, 9, 9]),
|
||||
]
|
||||
|
||||
tracked_people = extract_tracked_people([FakeResult()])
|
||||
|
||||
assert tracked_people == [
|
||||
{
|
||||
"track_id": 11,
|
||||
"class_name": "person",
|
||||
"confidence": 0.87,
|
||||
"xyxy": [1.0, 2.0, 3.0, 4.0],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_attach_track_signatures_adds_color_signature():
|
||||
frame = [
|
||||
[[10, 20, 30], [10, 20, 30]],
|
||||
[[10, 20, 30], [10, 20, 30]],
|
||||
]
|
||||
tracked_people = [
|
||||
{
|
||||
"track_id": 1,
|
||||
"class_name": "person",
|
||||
"confidence": 0.9,
|
||||
"xyxy": [0, 0, 2, 2],
|
||||
}
|
||||
]
|
||||
|
||||
enriched = attach_track_signatures(frame, tracked_people)
|
||||
|
||||
assert enriched[0]["signature"] == [0.0392, 0.0784, 0.1176]
|
||||
63
managed/store_dwell_alert/tests/test_dwell_engine.py
Normal file
63
managed/store_dwell_alert/tests/test_dwell_engine.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.modules.dwell_engine import DwellEngine, DwellSession, long_stay_count
|
||||
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def test_session_pauses_without_adding_absence_time():
|
||||
start = datetime(2026, 4, 15, 11, 0, tzinfo=TZ)
|
||||
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)
|
||||
assert session.state == "closed"
|
||||
assert session.dwell_seconds() == 130
|
||||
|
||||
|
||||
def test_engine_emits_alert_when_five_long_stays_are_active():
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
min_people=5,
|
||||
min_dwell_seconds=600,
|
||||
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)]
|
||||
|
||||
engine.process_observations(observations, now.replace(minute=9, second=0))
|
||||
events = engine.process_observations(observations, now)
|
||||
|
||||
assert [event["event"] for event in events] == ["long_stay_alert"]
|
||||
assert events[0]["active_long_stay_count"] == 5
|
||||
|
||||
|
||||
def test_engine_emits_half_hour_report_with_closed_customers():
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
min_people=5,
|
||||
min_dwell_seconds=600,
|
||||
pause_timeout_seconds=300,
|
||||
alert_cooldown_seconds=600,
|
||||
)
|
||||
seen_at = datetime(2026, 4, 15, 11, 10, tzinfo=TZ)
|
||||
engine.process_observations([{"person_id": "cust_1", "role": "customer"}], seen_at)
|
||||
engine.process_observations([], datetime(2026, 4, 15, 11, 12, tzinfo=TZ))
|
||||
engine.process_observations([], datetime(2026, 4, 15, 11, 18, tzinfo=TZ))
|
||||
|
||||
events = engine.process_observations([], datetime(2026, 4, 15, 11, 30, tzinfo=TZ))
|
||||
|
||||
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
|
||||
37
managed/store_dwell_alert/tests/test_identity_resolver.py
Normal file
37
managed/store_dwell_alert/tests/test_identity_resolver.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.modules.identity_resolver import IdentityResolver, choose_reentry_match
|
||||
|
||||
|
||||
def test_choose_reentry_match_prefers_recent_high_similarity():
|
||||
paused_people = [
|
||||
{"person_id": "cust_1", "paused_at": 100, "similarity": 0.91},
|
||||
{"person_id": "cust_2", "paused_at": 80, "similarity": 0.87},
|
||||
]
|
||||
|
||||
result = choose_reentry_match(
|
||||
paused_people=paused_people,
|
||||
now_ts=250,
|
||||
pause_timeout_seconds=300,
|
||||
min_similarity=0.90,
|
||||
)
|
||||
|
||||
assert result == "cust_1"
|
||||
|
||||
|
||||
def test_identity_resolver_reuses_person_after_short_pause():
|
||||
resolver = IdentityResolver(pause_timeout_seconds=300, reentry_similarity_threshold=0.95)
|
||||
now = datetime(2026, 4, 15, 11, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
|
||||
|
||||
first = resolver.resolve(
|
||||
[{"track_id": 1, "signature": [0.1, 0.2, 0.3], "role": "customer"}],
|
||||
now,
|
||||
)
|
||||
resolver.resolve([], now.replace(minute=1))
|
||||
second = resolver.resolve(
|
||||
[{"track_id": 2, "signature": [0.1, 0.2, 0.3], "role": "customer"}],
|
||||
now.replace(minute=2),
|
||||
)
|
||||
|
||||
assert first[0]["person_id"] == second[0]["person_id"]
|
||||
116
managed/store_dwell_alert/tests/test_main_smoke.py
Normal file
116
managed/store_dwell_alert/tests/test_main_smoke.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from app.main import build_app, process_frame, process_observations, run_forever
|
||||
|
||||
|
||||
def test_build_app_returns_named_components():
|
||||
config_path = Path(__file__).resolve().parent.parent / "config" / "config.example.yaml"
|
||||
app = build_app(config_path)
|
||||
assert "stream_reader" in app
|
||||
assert "dwell_engine" in app
|
||||
assert "notifier" in app
|
||||
assert "staff_matcher" in app
|
||||
|
||||
|
||||
def test_process_observations_writes_event_file(tmp_path):
|
||||
config = tmp_path / "config.yaml"
|
||||
config.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"thresholds:\n"
|
||||
" min_people: 1\n"
|
||||
" min_dwell_seconds: 1\n"
|
||||
" pause_timeout_seconds: 300\n"
|
||||
" alert_cooldown_seconds: 600\n"
|
||||
"event_sink:\n"
|
||||
f" path: {tmp_path / 'events.jsonl'}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = build_app(config)
|
||||
|
||||
events = process_observations(
|
||||
app,
|
||||
[{"person_id": "cust_1", "role": "customer"}],
|
||||
datetime.fromisoformat("2026-04-15T11:00:02+08:00"),
|
||||
)
|
||||
events = process_observations(
|
||||
app,
|
||||
[{"person_id": "cust_1", "role": "customer"}],
|
||||
datetime.fromisoformat("2026-04-15T11:00:05+08:00"),
|
||||
)
|
||||
|
||||
assert events[0]["event"] == "long_stay_alert"
|
||||
|
||||
|
||||
def test_run_forever_stops_at_max_frames(tmp_path):
|
||||
config = tmp_path / "config.yaml"
|
||||
config.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"stream:\n"
|
||||
" rtsp_url: rtsp://example\n"
|
||||
" sample_fps: 100\n"
|
||||
" reconnect_backoff_seconds: 0.01\n"
|
||||
"event_sink:\n"
|
||||
f" path: {tmp_path / 'events.jsonl'}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = build_app(config)
|
||||
|
||||
class FakeReader:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
self.closed = False
|
||||
|
||||
def read(self):
|
||||
self.calls += 1
|
||||
return [[0]]
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
class FakeTracker:
|
||||
def track(self, _frame):
|
||||
return [{"track_id": 1, "signature": [0.1, 0.2, 0.3], "role": "customer"}]
|
||||
|
||||
app["stream_reader"] = FakeReader()
|
||||
app["tracker"] = FakeTracker()
|
||||
|
||||
processed = run_forever(app, max_frames=2)
|
||||
|
||||
assert processed == 2
|
||||
assert app["stream_reader"].closed is True
|
||||
assert app["event_sink_path"].exists() is True
|
||||
|
||||
|
||||
def test_process_frame_uses_tracker_and_identity_resolver(tmp_path):
|
||||
config = tmp_path / "config.yaml"
|
||||
config.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"thresholds:\n"
|
||||
" min_people: 1\n"
|
||||
" min_dwell_seconds: 1\n"
|
||||
" pause_timeout_seconds: 300\n"
|
||||
" alert_cooldown_seconds: 600\n"
|
||||
"event_sink:\n"
|
||||
f" path: {tmp_path / 'events.jsonl'}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = build_app(config)
|
||||
|
||||
class FakeTracker:
|
||||
def track(self, _frame):
|
||||
return [{"track_id": 7, "signature": [0.1, 0.2, 0.3], "role": "customer"}]
|
||||
|
||||
app["tracker"] = FakeTracker()
|
||||
process_frame(
|
||||
app,
|
||||
frame=[[0]],
|
||||
when=datetime.fromisoformat("2026-04-15T11:00:03+08:00"),
|
||||
)
|
||||
events = process_frame(
|
||||
app,
|
||||
frame=[[0]],
|
||||
when=datetime.fromisoformat("2026-04-15T11:00:06+08:00"),
|
||||
)
|
||||
|
||||
assert events[0]["event"] == "long_stay_alert"
|
||||
178
managed/store_dwell_alert/tests/test_manage_api.py
Normal file
178
managed/store_dwell_alert/tests/test_manage_api.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from app.manage_api import create_app
|
||||
|
||||
|
||||
def build_client(project_root: Path):
|
||||
config_path = project_root / "config" / "local.yaml"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(
|
||||
"camera_id: store_cam_01\n"
|
||||
"timezone: Asia/Shanghai\n"
|
||||
"stream:\n"
|
||||
" rtsp_url: rtsp://before-update\n"
|
||||
" sample_fps: 2.0\n"
|
||||
" reconnect_backoff_seconds: 5.0\n"
|
||||
"event_sink:\n"
|
||||
" path: logs/events.jsonl\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
logs_dir = project_root / "logs"
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
(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",
|
||||
"camera_id": "store_cam_01",
|
||||
"window_start": "2026-04-16T09:00:00+08:00",
|
||||
"window_end": "2026-04-16T09:30:00+08:00",
|
||||
"active_customer_count": 2,
|
||||
"active_customers": [
|
||||
{"person_id": "cust_1", "dwell_seconds": 600},
|
||||
{"person_id": "cust_2", "dwell_seconds": 780},
|
||||
],
|
||||
"closed_customers": [
|
||||
{"person_id": "cust_3", "final_dwell_seconds": 450}
|
||||
],
|
||||
"staff_seen_count": 1,
|
||||
}
|
||||
),
|
||||
json.dumps(
|
||||
{
|
||||
"event": "half_hour_report",
|
||||
"camera_id": "store_cam_01",
|
||||
"window_start": "2026-04-16T09:30:00+08:00",
|
||||
"window_end": "2026-04-16T10:00:00+08:00",
|
||||
"active_customer_count": 1,
|
||||
"active_customers": [
|
||||
{"person_id": "cust_4", "dwell_seconds": 900}
|
||||
],
|
||||
"closed_customers": [
|
||||
{"person_id": "cust_5", "final_dwell_seconds": 300},
|
||||
{"person_id": "cust_6", "final_dwell_seconds": 120},
|
||||
],
|
||||
"staff_seen_count": 0,
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(logs_dir / "runtime.log").write_text("runtime ok\n", encoding="utf-8")
|
||||
|
||||
app = create_app(config_path)
|
||||
app.testing = True
|
||||
return app.test_client(), config_path
|
||||
|
||||
|
||||
def test_get_manage_health(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["status"] == "ok"
|
||||
assert response.json["project_type"] == "store_dwell_alert"
|
||||
assert response.json["runtime_status"] == "running"
|
||||
|
||||
|
||||
def test_get_manage_config(tmp_path: Path):
|
||||
client, config_path = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/config")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["camera_id"] == "store_cam_01"
|
||||
assert response.json["stream"]["rtsp_url"] == "rtsp://before-update"
|
||||
assert response.json["config_path"] == str(config_path)
|
||||
|
||||
|
||||
def test_put_manage_config_updates_rtsp_url(tmp_path: Path):
|
||||
client, config_path = build_client(tmp_path)
|
||||
|
||||
response = client.put(
|
||||
"/api/manage/config",
|
||||
json={"rtsp_url": "rtsp://after-update"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["stream"]["rtsp_url"] == "rtsp://after-update"
|
||||
|
||||
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["stream"]["rtsp_url"] == "rtsp://after-update"
|
||||
|
||||
|
||||
def test_get_manage_summary(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/summary")
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_get_manage_windows(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/windows?page=1&page_size=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["total"] == 2
|
||||
assert response.json["page"] == 1
|
||||
assert response.json["page_size"] == 1
|
||||
assert len(response.json["items"]) == 1
|
||||
assert response.json["items"][0]["window_end"] == "2026-04-16T10:00:00+08:00"
|
||||
assert response.json["items"][0]["active_wait_seconds"] == [900]
|
||||
|
||||
|
||||
def test_get_manage_files(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/files")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert {item["path"] for item in response.json["files"]} == {
|
||||
"logs/events.jsonl",
|
||||
"logs/runtime.log",
|
||||
}
|
||||
|
||||
|
||||
def test_get_manage_files_preview(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/files/preview?path=logs/events.jsonl&lines=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["path"] == "logs/events.jsonl"
|
||||
assert response.json["count"] == 2
|
||||
assert "2026-04-16T10:00:00+08:00" in response.json["lines"][-1]
|
||||
|
||||
|
||||
def test_get_manage_files_download(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
response = client.get("/api/manage/files/download?path=logs/runtime.log")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == b"runtime ok\n"
|
||||
15
managed/store_dwell_alert/tests/test_notifier.py
Normal file
15
managed/store_dwell_alert/tests/test_notifier.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import json
|
||||
|
||||
from app.modules.notifier import append_json_event
|
||||
|
||||
|
||||
def test_append_json_event_writes_jsonl(tmp_path):
|
||||
output = tmp_path / "logs" / "events.jsonl"
|
||||
|
||||
append_json_event(output, {"event": "long_stay_alert", "count": 5})
|
||||
append_json_event(output, {"event": "half_hour_report", "count": 3})
|
||||
|
||||
lines = output.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
assert json.loads(lines[0]) == {"event": "long_stay_alert", "count": 5}
|
||||
assert json.loads(lines[1]) == {"event": "half_hour_report", "count": 3}
|
||||
15
managed/store_dwell_alert/tests/test_reporter.py
Normal file
15
managed/store_dwell_alert/tests/test_reporter.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.modules.reporter import floor_half_hour, should_emit_half_hour_report
|
||||
|
||||
|
||||
def test_half_hour_report_emits_on_half_hour_boundaries():
|
||||
assert should_emit_half_hour_report("2026-04-15T11:00:00+08:00") is True
|
||||
assert should_emit_half_hour_report("2026-04-15T11:30:00+08:00") is True
|
||||
assert should_emit_half_hour_report("2026-04-15T11:17:00+08:00") is False
|
||||
|
||||
|
||||
def test_floor_half_hour_rounds_down():
|
||||
dt = datetime(2026, 4, 15, 11, 47, 13, tzinfo=ZoneInfo("Asia/Shanghai"))
|
||||
assert floor_half_hour(dt).isoformat() == "2026-04-15T11:30:00+08:00"
|
||||
54
managed/store_dwell_alert/tests/test_staff_filter.py
Normal file
54
managed/store_dwell_alert/tests/test_staff_filter.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import json
|
||||
|
||||
from app.modules.staff_filter import StaffEmbedding, StaffMatcher, load_staff_gallery, staff_vote
|
||||
|
||||
|
||||
def test_staff_vote_requires_multiple_hits():
|
||||
assert staff_vote([True, False, True], min_hits=2) is True
|
||||
assert staff_vote([True, False, False], min_hits=2) is False
|
||||
|
||||
|
||||
def test_load_staff_gallery_reads_json_signatures(tmp_path):
|
||||
gallery_dir = tmp_path / "staff_gallery"
|
||||
gallery_dir.mkdir()
|
||||
(gallery_dir / "alice.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"staff_id": "alice",
|
||||
"signature": [0.2, 0.3, 0.4],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
embeddings = load_staff_gallery(gallery_dir)
|
||||
|
||||
assert len(embeddings) == 1
|
||||
assert embeddings[0].staff_id == "alice"
|
||||
assert embeddings[0].signature == [0.2, 0.3, 0.4]
|
||||
|
||||
|
||||
def test_staff_matcher_promotes_person_to_staff_after_enough_hits():
|
||||
matcher = StaffMatcher(
|
||||
gallery=[
|
||||
StaffEmbedding(
|
||||
staff_id="staff_1",
|
||||
signature=[0.1, 0.2, 0.3],
|
||||
source="inline",
|
||||
)
|
||||
],
|
||||
similarity_threshold=0.95,
|
||||
min_hits=2,
|
||||
vote_window=3,
|
||||
)
|
||||
|
||||
first = matcher.classify(
|
||||
[{"person_id": "cust_1", "signature": [0.1, 0.2, 0.3], "role": "customer"}]
|
||||
)
|
||||
second = matcher.classify(
|
||||
[{"person_id": "cust_1", "signature": [0.1, 0.2, 0.3], "role": "customer"}]
|
||||
)
|
||||
|
||||
assert first[0]["role"] == "customer"
|
||||
assert second[0]["role"] == "staff"
|
||||
assert second[0]["staff_id"] == "staff_1"
|
||||
37
managed/store_dwell_alert/tests/test_stream_reader.py
Normal file
37
managed/store_dwell_alert/tests/test_stream_reader.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from app.modules.stream_reader import StreamHealth
|
||||
from app.modules.stream_reader import RTSPFrameReader
|
||||
|
||||
|
||||
def test_stream_health_marks_disconnect_after_failures():
|
||||
health = StreamHealth(max_failures=3)
|
||||
health.record_failure()
|
||||
health.record_failure()
|
||||
health.record_failure()
|
||||
assert health.is_disconnected is True
|
||||
|
||||
|
||||
def test_stream_health_reset_clears_disconnect():
|
||||
health = StreamHealth(max_failures=2, failures=2)
|
||||
health.reset()
|
||||
assert health.is_disconnected is False
|
||||
|
||||
|
||||
def test_rtsp_frame_reader_uses_capture_factory():
|
||||
class FakeCapture:
|
||||
def __init__(self, _url):
|
||||
self.frames = [(True, "frame-1")]
|
||||
|
||||
def read(self):
|
||||
return self.frames.pop(0)
|
||||
|
||||
def release(self):
|
||||
return None
|
||||
|
||||
reader = RTSPFrameReader(
|
||||
rtsp_url="rtsp://example",
|
||||
sample_fps=2.0,
|
||||
reconnect_backoff_seconds=5.0,
|
||||
capture_factory=FakeCapture,
|
||||
)
|
||||
|
||||
assert reader.read() == "frame-1"
|
||||
1
managed/store_dwell_alert/weights/.gitkeep
Normal file
1
managed/store_dwell_alert/weights/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user