feat: initialize managed portal

This commit is contained in:
Yoilun
2026-04-27 10:04:36 +08:00
commit d4e351df71
145 changed files with 13425 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
.git
.venv
__pycache__
*.pyc
outputs
people_flow_project_backup_2026-04-08
docs/plans

View File

@@ -0,0 +1,8 @@
.DS_Store
.venv/
__pycache__/
config/local.yaml
outputs/
wheelhouse/
weights/*.pt
weights/deepface/*.h5

View File

@@ -0,0 +1,44 @@
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
DEEPFACE_HOME=/root/.deepface \
TF_CPP_MIN_LOG_LEVEL=2
WORKDIR /opt/people-flow
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libglib2.0-0 \
libgl1 \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements-docker.txt ./requirements-docker.txt
RUN python -m pip install --upgrade pip setuptools wheel && \
pip install "numpy<2" && \
pip install --extra-index-url https://download.pytorch.org/whl/cpu \
"torch==2.6.0+cpu" "torchvision==0.21.0+cpu" && \
pip install "tensorflow==2.16.1" "tf-keras==2.16.0" && \
pip install -r requirements-docker.txt
COPY . .
COPY scripts/docker-entrypoint.sh /opt/people-flow/scripts/docker-entrypoint.sh
RUN test -f /opt/people-flow/weights/yolo11n.pt && \
test -f /opt/people-flow/weights/deepface/age_model_weights.h5 && \
test -f /opt/people-flow/weights/deepface/gender_model_weights.h5 && \
test -f /opt/people-flow/weights/deepface/retinaface.h5 && \
mkdir -p /root/.deepface/weights /opt/people-flow/outputs && \
cp /opt/people-flow/weights/deepface/*.h5 /root/.deepface/weights/ && \
chmod +x /opt/people-flow/scripts/docker-entrypoint.sh
EXPOSE 18082
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18082/api/manage/health', timeout=3).read()" || exit 1
ENTRYPOINT ["/opt/people-flow/scripts/docker-entrypoint.sh"]

View File

@@ -0,0 +1,144 @@
# People Flow Project
People flow analysis for street videos using YOLO tracking and face-based demographic estimation.
## What it does
- Counts unique people when they cross a configured line
- Estimates one age bucket per counted track: `minor`, `adult`, or `senior`
- Estimates one gender bucket per counted track: `male` or `female`
- Writes an annotated output video, per-video JSON, and batch summary CSV
## Pipeline
1. Detect and track `person` objects with Ultralytics YOLO.
2. Assign a stable `track_id` with BoT-SORT or ByteTrack.
3. Count each track once when it crosses the configured line.
4. Sample person crops for each track and run DeepFace age/gender analysis.
5. Use track-level voting so each counted person lands in only one age bucket and one gender bucket.
## Project Layout
- `main.py`: CLI entrypoint
- `src/people_flow/`: application modules
- `configs/default_config.yaml`: default runtime settings
- `outputs/`: generated result files
- `docs/plans/`: design and implementation notes
## Recommended Environment
- Linux
- NVIDIA GPU with CUDA
- Python `3.10` or `3.11`
`deepface` and its transitive dependencies are not a good fit for Python `3.14`, so do not build this environment on the current local interpreter version.
## Install
```bash
python3.11 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
```
## Single Video
```bash
python main.py video \
--input "/path/to/video.mp4" \
--line "0.1,0.55,0.9,0.55"
```
## Batch Directory
```bash
python main.py batch \
--input-dir "/path/to/videos" \
--line "0.1,0.55,0.9,0.55"
```
## RTSP Stream
```bash
python main.py --output-dir outputs rtsp \
--input "rtsp://user:password@host:554/stream"
```
RTSP mode behaves differently from offline video mode:
- The stream is sampled at one processed frame per second
- Statistics are isolated into 30-minute windows
- Each completed window writes one JSON file
- `latest.json` is overwritten on every completed window
- RTSP mode does not save annotated video by default
## Output Files
Each processed video produces:
- `outputs/<video_stem>/<video_stem>.annotated.mp4`
- `outputs/<video_stem>/<video_stem>.json`
Batch mode also produces:
- `outputs/batch_summary.csv`
RTSP mode produces:
- `outputs/rtsp_stream/latest.json`
- `outputs/rtsp_stream/windows/stats_YYYY-MM-DD_HH-MM-SS.json`
## Docker On Ubuntu 24.04 x86_64
The project can be packaged for an x86_64 NVIDIA host with Docker. The expected weight layout is:
- `weights/yolo11n.pt`
- `weights/deepface/age_model_weights.h5`
- `weights/deepface/gender_model_weights.h5`
- `weights/deepface/retinaface.h5`
Build the image:
```bash
docker build -t people-flow-project:test .
```
The Docker image uses [`requirements-docker.txt`](/Users/zxmacmini1/Documents/人流检测/people_flow_project/requirements-docker.txt) so the container installs `opencv-python-headless` instead of the desktop OpenCV wheel.
The image bakes in all runtime weights and copies the DeepFace `.h5` files into `~/.deepface/weights` during build.
Run the management API container:
```bash
docker run -d \
--name people-flow-project \
--restart unless-stopped \
--gpus all \
--shm-size 1g \
-p 18082:18082 \
-e RTSP_URL="rtsp://user:password@host:554/stream" \
-v /path/to/config:/opt/people-flow/config \
-v /path/to/outputs:/opt/people-flow/outputs \
people-flow-project:test
```
Or use Compose:
```bash
docker compose up --build people-flow-project
```
Container behavior:
- Seeds `config/local.yaml` from `config/config.example.yaml` when needed
- Writes RTSP updates through the child API to `runtime.rtsp_url`
- Exposes `GET /api/manage/health` on `http://127.0.0.1:18082`
- Persists config and outputs through mounted `./config` and `./outputs`
## Notes
- `minor` means age `< 18`
- `adult` means age `18-59`
- `senior` means age `>= 60`
- Tracks without a reliable face result are counted only in `total_people` and `unknown_attributes`

View File

@@ -0,0 +1,65 @@
# Native RTSP Bundle
This bundle is for lightweight native deployment on an x86_64 Ubuntu host.
## What To Edit
Open [`scripts/run.sh`](/Users/zxmacmini1/Documents/人流检测/people_flow_project/scripts/run.sh) and edit only these two lines:
```bash
RTSP_URL="rtsp://..."
OUTPUT_DIR="/home/x/people/output"
```
## First-Time Setup
From the project root:
```bash
sudo bash scripts/install.sh
```
This creates `.venv`, installs Python dependencies, copies the bundled DeepFace weights into `~/.deepface/weights`, installs the `systemd` unit, starts the service, and enables it on boot.
## Build An Offline Dependency Pack
If you want future installs to avoid re-downloading Python packages:
```bash
./build_wheelhouse.sh
```
This creates a local `wheelhouse/` directory for Ubuntu 24.04 x86_64 + Python 3.12. After that, `./setup_native_venv.sh` will automatically prefer local wheels.
## Start The RTSP Task
```bash
sudo systemctl status people-flow.service
```
The service runs in the foreground under `systemd`.
## Outputs
- Latest half-hour summary: `OUTPUT_DIR/rtsp_stream/latest.json`
- Historical half-hour summaries: `OUTPUT_DIR/rtsp_stream/windows/`
- Runtime log: `OUTPUT_DIR/rtsp_run.log`
## Chinese Guide
- `README_zh.md`
## Weights
The project expects these local files:
- `weights/yolo11n.pt`
- `weights/deepface/age_model_weights.h5`
- `weights/deepface/gender_model_weights.h5`
- `weights/deepface/retinaface.h5`
At setup time and each RTSP launch, those `.h5` files are copied into the current user's default DeepFace directory:
- `~/.deepface/weights/`
That keeps the bundle portable across different unpack paths such as `/home/x/people` and `/home/xiaozheng/people`.

View File

@@ -0,0 +1,72 @@
# 人流检测项目中文说明
这个项目用于基于 `YOLO + DeepFace` 的视频/RTSP 人流检测与属性统计。
## 当前交付方式
这个版本已经改成:
- 使用 `config/local.yaml` 作为本地运行配置
- 使用 `scripts/run.sh` 生成本地配置并前台运行
- 使用 `systemd` 托管长期运行
- 安装完成后自动启动
- 开机自动启动
## 目标机器
- `Ubuntu 24.04`
- `Python 3.12`
- NVIDIA 显卡可用
- `nvidia-smi` 可正常执行
## 安装前需要修改
先编辑 `scripts/run.sh`,至少改:
- `RTSP_URL`
- `OUTPUT_DIR`
## 安装
在项目根目录执行:
```bash
sudo bash scripts/install.sh
```
安装脚本会自动:
- 检查并安装 `ffmpeg`
- 检查并安装 `python3.12-venv`
- 创建 `.venv`
- 安装 Python 依赖
- 复制 DeepFace 权重到 `~/.deepface/weights`
- 生成 `config/local.yaml`
- 安装 `systemd` 服务
- 自动启动服务
- 设置开机自启
## 服务管理
服务名:
```bash
people-flow.service
```
常用命令:
```bash
sudo systemctl status people-flow.service
sudo systemctl restart people-flow.service
sudo systemctl stop people-flow.service
sudo systemctl start people-flow.service
sudo systemctl disable people-flow.service
```
## 输出位置
- 运行日志:`outputs/rtsp_run.log`
- 最新半小时汇总:`OUTPUT_DIR/rtsp_stream/latest.json`
- 历史窗口汇总:`OUTPUT_DIR/rtsp_stream/windows/`
- 本地配置:`config/local.yaml`

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR"
WHEELHOUSE_DIR="$PROJECT_ROOT/wheelhouse"
mkdir -p "$WHEELHOUSE_DIR"
python3 -m venv "$PROJECT_ROOT/.wheelhouse-venv"
source "$PROJECT_ROOT/.wheelhouse-venv/bin/activate"
python -m pip install --upgrade pip setuptools wheel
pip download -d "$WHEELHOUSE_DIR" pip setuptools wheel
pip download -d "$WHEELHOUSE_DIR" "numpy<2"
pip download -d "$WHEELHOUSE_DIR" \
--index-url https://download.pytorch.org/whl/cu126 \
--extra-index-url https://pypi.nvidia.com \
torch torchvision
pip download -d "$WHEELHOUSE_DIR" \
--extra-index-url https://pypi.nvidia.com \
"tensorflow[and-cuda]==2.16.1" "tf-keras==2.16.0"
pip download -d "$WHEELHOUSE_DIR" \
--find-links "$WHEELHOUSE_DIR" \
-c "$PROJECT_ROOT/constraints-wheelhouse.txt" \
-r "$PROJECT_ROOT/requirements-native.txt"
deactivate
echo "wheelhouse_ready=$WHEELHOUSE_DIR"

View File

@@ -0,0 +1,41 @@
runtime:
rtsp_url: "rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream"
output_dir: "outputs"
yolo:
model_path: "weights/yolo11n.pt"
tracker: "botsort.yaml"
conf: 0.35
iou: 0.5
imgsz: 1280
device: "cuda:0"
counting:
line: [0.1, 0.55, 0.9, 0.55]
line_mode: "normalized"
crossing_tolerance: 12.0
attributes:
enabled: false
sample_every_n_frames: 12
max_samples_per_track: 5
min_person_box_width: 80
min_person_box_height: 160
person_crop_padding: 0.15
detector_backend: "retinaface"
enforce_detection: false
output:
save_video: false
save_json: true
save_csv: true
draw_boxes: false
draw_labels: false
rtsp:
sample_interval_seconds: 1.0
window_seconds: 1800
reconnect_delay_seconds: 5.0
stream_open_timeout_seconds: 10.0
idle_sleep_seconds: 0.05
output_subdir: "rtsp_stream"

View File

@@ -0,0 +1,37 @@
yolo:
model_path: "yolo11n.pt"
tracker: "botsort.yaml"
conf: 0.35
iou: 0.5
imgsz: 1280
device: "cuda:0"
counting:
line: [0.1, 0.55, 0.9, 0.55]
line_mode: "normalized"
crossing_tolerance: 12.0
attributes:
enabled: true
sample_every_n_frames: 12
max_samples_per_track: 5
min_person_box_width: 80
min_person_box_height: 160
person_crop_padding: 0.15
detector_backend: "retinaface"
enforce_detection: false
output:
save_video: true
save_json: true
save_csv: true
draw_boxes: true
draw_labels: true
rtsp:
sample_interval_seconds: 1.0
window_seconds: 1800
reconnect_delay_seconds: 5.0
stream_open_timeout_seconds: 10.0
idle_sleep_seconds: 0.05
output_subdir: "rtsp_stream"

View File

@@ -0,0 +1,37 @@
yolo:
model_path: "/opt/people-flow/weights/yolo11n.pt"
tracker: "botsort.yaml"
conf: 0.35
iou: 0.5
imgsz: 1280
device: "cuda:0"
counting:
line: [0.1, 0.55, 0.9, 0.55]
line_mode: "normalized"
crossing_tolerance: 12.0
attributes:
enabled: true
sample_every_n_frames: 12
max_samples_per_track: 5
min_person_box_width: 80
min_person_box_height: 160
person_crop_padding: 0.15
detector_backend: "retinaface"
enforce_detection: false
output:
save_video: false
save_json: true
save_csv: true
draw_boxes: false
draw_labels: false
rtsp:
sample_interval_seconds: 1.0
window_seconds: 1800
reconnect_delay_seconds: 5.0
stream_open_timeout_seconds: 10.0
idle_sleep_seconds: 0.05
output_subdir: "rtsp_stream"

View File

@@ -0,0 +1,37 @@
yolo:
model_path: "weights/yolo11n.pt"
tracker: "botsort.yaml"
conf: 0.35
iou: 0.5
imgsz: 1280
device: "cuda:0"
counting:
line: [0.1, 0.55, 0.9, 0.55]
line_mode: "normalized"
crossing_tolerance: 12.0
attributes:
enabled: true
sample_every_n_frames: 12
max_samples_per_track: 5
min_person_box_width: 80
min_person_box_height: 160
person_crop_padding: 0.15
detector_backend: "retinaface"
enforce_detection: false
output:
save_video: false
save_json: true
save_csv: true
draw_boxes: false
draw_labels: false
rtsp:
sample_interval_seconds: 1.0
window_seconds: 1800
reconnect_delay_seconds: 5.0
stream_open_timeout_seconds: 10.0
idle_sleep_seconds: 0.05
output_subdir: "rtsp_stream"

View File

@@ -0,0 +1,5 @@
numpy<2
tensorflow==2.16.1
tf-keras==2.16.0
torch==2.11.0+cu126
torchvision==0.26.0+cu126

View File

@@ -0,0 +1,19 @@
[Unit]
Description=People Flow RTSP Service
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 __PROJECT_DIR__/main.py --config __CONFIG_PATH__ rtsp
Restart=always
RestartSec=5
StandardOutput=append:__PROJECT_DIR__/outputs/rtsp_run.log
StandardError=append:__PROJECT_DIR__/outputs/rtsp_run.log
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,22 @@
services:
people-flow-project:
build:
context: .
dockerfile: Dockerfile
image: people-flow-project:local
container_name: people-flow-project
restart: unless-stopped
gpus: all
shm_size: "1gb"
ports:
- "18082:18082"
environment:
CONFIG_PATH: /opt/people-flow/config/local.yaml
RTSP_URL: ${RTSP_URL:-}
OUTPUT_DIR: /opt/people-flow/outputs
API_HOST: 0.0.0.0
API_PORT: 18082
DEVICE: ${DEVICE:-cuda:0}
volumes:
- ./config:/opt/people-flow/config
- ./outputs:/opt/people-flow/outputs

View File

@@ -0,0 +1,66 @@
# People Flow Design
## Goal
Build a standalone project under `Documents/人流检测/people_flow_project` that analyzes street videos and produces:
- unique people-flow counts
- one mutually exclusive age bucket per counted person
- one mutually exclusive gender bucket per counted person
- annotated videos plus machine-readable summaries
## Approved Decisions
- Runtime target: Linux with NVIDIA GPU
- Entry points: both single-video mode and batch-directory mode
- Count logic: one `track_id` is counted once when it crosses a configured line
- Age buckets:
- `minor`: age `< 18`
- `adult`: age `18-59`
- `senior`: age `>= 60`
- Gender buckets:
- `male`
- `female`
- Unknown face attributes:
- If a counted person does not yield a reliable face result, count that person only in `total_people`
- Also increment `unknown_attributes`
## Architecture
The pipeline uses Ultralytics YOLO for person detection and tracking, then DeepFace for face attribute analysis. Person tracking and counting stay separate from attribute inference so the demographic model can be replaced later without touching the counting core.
The application stores votes per `track_id`. When the video finishes, each counted track is resolved to at most one final age bucket and one final gender bucket by majority voting.
## Modules
- `main.py`: CLI parsing and mode dispatch
- `src/people_flow/config.py`: config loading and overrides
- `src/people_flow/tracking.py`: track extraction from YOLO results
- `src/people_flow/counting.py`: line-crossing logic and unique counting
- `src/people_flow/attributes.py`: DeepFace integration and voting
- `src/people_flow/io_utils.py`: video, JSON, and CSV output helpers
- `src/people_flow/pipeline.py`: process orchestration
## Outputs
For each video:
- annotated MP4
- JSON summary
For batch runs:
- one CSV summary with one row per video
## Error Handling
- Missing dependencies should raise clear installation guidance.
- If a video cannot be opened, fail that video with a readable error.
- If face inference fails for a sample, continue processing and treat that sample as unavailable.
- If no video files are found in batch mode, fail fast with a clear message.
## Limitations
- Age and gender quality depend on clear, sufficiently large faces.
- Street scenes with strong occlusion, side views, masks, or low light will increase `unknown_attributes` and lower reliability.
- The default line is a placeholder and should be adjusted per camera view.

View File

@@ -0,0 +1,128 @@
# People Flow Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a standalone Python project that counts unique line crossings in street videos and adds track-level age/gender summaries.
**Architecture:** Use Ultralytics YOLO to detect and track persons frame by frame, then run DeepFace on sampled person crops to infer face attributes. Keep counting, tracking, and attribute voting in separate modules so the demographic backend can be swapped later.
**Tech Stack:** Python, Ultralytics YOLO, OpenCV, DeepFace, PyYAML, pandas
---
### Task 1: Scaffold the project
**Files:**
- Create: `README.md`
- Create: `requirements.txt`
- Create: `pyproject.toml`
- Create: `configs/default_config.yaml`
- Create: `docs/plans/2026-04-07-people-flow-design.md`
**Step 1: Write the initial files**
Add installation instructions, runtime expectations, and default settings.
**Step 2: Verify structure**
Run: `find . -maxdepth 3 | sed -n '1,120p'`
Expected: project files and directories exist.
**Step 3: Commit**
This workspace is not a git repository. Skip the commit step unless the user later initializes git here.
### Task 2: Build the CLI and config loader
**Files:**
- Create: `main.py`
- Create: `src/people_flow/__init__.py`
- Create: `src/people_flow/config.py`
- Create: `src/people_flow/models.py`
**Step 1: Implement argument parsing**
Support `video` and `batch` subcommands, config overrides, output directory selection, and line overrides.
**Step 2: Implement config loading**
Load YAML defaults and merge CLI overrides into typed dataclasses.
**Step 3: Verify**
Run: `python3 -m compileall main.py src`
Expected: compile succeeds without syntax errors.
### Task 3: Implement tracking and counting
**Files:**
- Create: `src/people_flow/tracking.py`
- Create: `src/people_flow/counting.py`
**Step 1: Extract tracked `person` detections**
Convert YOLO result objects into simple track observations with `track_id`, bounding box, confidence, and center point.
**Step 2: Implement line-cross counting**
Count one crossing per track by monitoring the sign change of the track center relative to the configured line.
**Step 3: Verify**
Run: `python3 -m compileall src`
Expected: compile succeeds.
### Task 4: Implement attribute voting and output helpers
**Files:**
- Create: `src/people_flow/attributes.py`
- Create: `src/people_flow/io_utils.py`
**Step 1: Integrate DeepFace**
Sample person crops, run `age` and `gender` analysis, normalize labels, and store per-track votes.
**Step 2: Implement output helpers**
Write JSON summaries, CSV summaries, and draw overlays onto frames.
**Step 3: Verify**
Run: `python3 -m compileall src`
Expected: compile succeeds.
### Task 5: Implement the processing pipeline
**Files:**
- Create: `src/people_flow/pipeline.py`
**Step 1: Build the main loop**
Open the video, run YOLO tracking on frames, update counters, sample attributes, draw overlays, and save artifacts.
**Step 2: Build batch mode**
Discover supported video files recursively and run the same pipeline per file, then write `outputs/batch_summary.csv`.
**Step 3: Verify**
Run: `python3 -m compileall main.py src`
Expected: compile succeeds.
### Task 6: Final verification
**Files:**
- Modify: `README.md`
**Step 1: Smoke-check the CLI**
Run: `python3 main.py --help`
Expected: help text shows the `video` and `batch` commands.
**Step 2: Document limitations**
Make sure README notes Python version constraints and face-quality limitations.
**Step 3: Commit**
Skip commit because this workspace is not a git repository.

View File

@@ -0,0 +1,17 @@
# Portable DeepFace Weights Design
**Goal:** Make DeepFace reuse bundled project weights regardless of where the project directory is unpacked.
**Problem:** The current native launcher sets `DEEPFACE_HOME` to a project-local `.deepface` directory. DeepFace then appends its own `.deepface/weights` segment, so runtime lookup becomes `PROJECT_ROOT/.deepface/.deepface/weights`, which bypasses the bundled `weights/deepface` directory and triggers redundant downloads.
**Approach Options:**
1. Copy bundled weights into the current user's default `~/.deepface/weights` directory before startup.
This matches DeepFace's default lookup behavior and avoids hard-coded absolute paths. It works whether the project lives under `/home/x/people`, `/home/xiaozheng/people`, or any other directory.
2. Keep using `DEEPFACE_HOME` and reshape the project-local directory tree to match DeepFace's nested expectations.
This avoids duplicating files but is more fragile and easier to break when DeepFace internals change.
**Recommendation:** Use option 1. Update the native setup and launcher scripts to sync `weights/deepface/*.h5` into `~/.deepface/weights` and stop overriding `DEEPFACE_HOME`.
**Validation:** Confirm the RTSP process starts without downloading `retinaface.h5`, `age_model_weights.h5`, or `gender_model_weights.h5`, and verify the launcher still works after changing only the project root path.

View File

@@ -0,0 +1,51 @@
# Portable DeepFace Weights Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make the native RTSP bundle reuse bundled DeepFace weights from any unpack location without extra downloads.
**Architecture:** Remove the custom `DEEPFACE_HOME` override from the native runtime path. Before setup and launch, copy the bundled DeepFace weight files from `weights/deepface/` into the current user's default `~/.deepface/weights/` directory so DeepFace resolves them through its own standard path logic.
**Tech Stack:** Bash, DeepFace, native Python virtual environment, offline wheelhouse bundle
---
### Task 1: Fix native setup and launcher paths
**Files:**
- Modify: `run_rtsp.sh`
- Modify: `setup_native_venv.sh`
- Modify: `README_NATIVE.md`
**Step 1: Update `run_rtsp.sh`**
Remove the `DEEPFACE_HOME` override. Create `"$HOME/.deepface/weights"` and copy bundled `.h5` files from `"$PROJECT_ROOT/weights/deepface"` into that directory before starting the Python process.
**Step 2: Update `setup_native_venv.sh`**
After dependency installation, create `"$HOME/.deepface/weights"` and copy bundled `.h5` files into it so the environment is ready before the first run.
**Step 3: Update native documentation**
Explain that bundled weights are staged into `~/.deepface/weights` automatically and that the project path itself can move without breaking the weight lookup.
### Task 2: Sync and verify on the Ubuntu target
**Files:**
- Modify: remote copies of the files above under `/home/x/people/people_flow_project`
**Step 1: Sync the changed files to `192.168.5.154`**
Copy the updated launcher, setup script, and documentation.
**Step 2: Stage bundled weights into the target user's home directory**
Run the updated setup logic or equivalent copy command and verify `~/.deepface/weights` contains the expected `.h5` files.
**Step 3: Restart RTSP and inspect logs**
Restart the RTSP job and confirm the log no longer shows downloads from `deepface_models/releases`.
**Step 4: Commit**
Skip commit unless explicitly requested by the user.

View File

@@ -0,0 +1,81 @@
# Lightweight Native Bundle Design
**Date:** 2026-04-08
**Goal:** Deliver a lightweight native deployment bundle for Ubuntu 24.04 x86_64 that includes project code, required weights, a single editable RTSP run script, and a small setup path on the target host without bundling a full Python environment in the archive.
## Scope
- Target host: `xiaozheng@192.168.5.154`
- Target path: `/home/x/people/people_flow_project`
- Bundle contents:
- project code
- YOLO weight
- DeepFace weights
- one editable run script
- setup and usage documentation
- Exclude the virtual environment from the compressed bundle to keep size down.
## Deployment Model
The target host already has:
- Ubuntu 24.04 x86_64
- Python 3.12
- Docker available, but Docker is intentionally not used here
- NVIDIA driver and CUDA-capable GPU
The bundle will therefore rely on:
1. a project-local `.venv` created on the target host
2. host driver compatibility for GPU wheels
3. project-relative weight paths so no external downloads are needed
## User Editing Surface
The main operator interface is a single shell script:
- `run_rtsp.sh`
The user edits only:
- `RTSP_URL`
- `OUTPUT_DIR`
The script activates `.venv`, points to the native x86 config, and runs the RTSP pipeline.
## Config Strategy
Add a dedicated native x86 config file with:
- `yolo.model_path` pointing to the local `weights/yolo11n.pt`
- RTSP timing settings
- output defaults for RTSP mode
This avoids modifying the existing Jetson-oriented config and keeps host deployment deterministic.
## Setup Strategy
Provide a small setup script that:
- creates `.venv`
- upgrades pip/setuptools/wheel
- installs CUDA-enabled PyTorch wheels
- installs TensorFlow, `tf-keras`, and application dependencies
The setup script keeps the archive light while still making the target directory self-contained after one install step.
## Bundle Output
On the target host, create a compressed archive such as:
- `/home/x/people/people_flow_project_native_bundle_2026-04-08.tar.gz`
The archive will exclude `.venv` so it stays close to the size of code plus weights.
## Success Criteria
- The target host contains a runnable native project directory
- `run_rtsp.sh` is the only file the operator needs to edit for RTSP URL and output directory
- All required weights are present locally
- The lightweight tarball is created successfully

View File

@@ -0,0 +1,66 @@
# Lightweight Native Bundle Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Produce a lightweight native deployment bundle for Ubuntu 24.04 x86_64 with code, weights, one editable RTSP run script, and a local venv setup path.
**Architecture:** Keep all code and weights inside the project directory, add one native config and two helper scripts, then create the venv on the target host instead of bundling it into the archive.
**Tech Stack:** Python 3.12, venv, PyTorch GPU wheels, TensorFlow, DeepFace, shell scripts
---
### Task 1: Add native deployment files
**Files:**
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/configs/native_x86_config.yaml`
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/run_rtsp.sh`
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/setup_native_venv.sh`
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/README_NATIVE.md`
**Step 1: Add a native x86 config**
- Point YOLO to the local project weight path.
- Keep RTSP behavior aligned with the current project.
**Step 2: Add a single editable RTSP launcher**
- Put `RTSP_URL` and `OUTPUT_DIR` at the top of the file.
- Run the project with `.venv/bin/python`.
**Step 3: Add a setup script**
- Create `.venv`
- Install GPU-enabled PyTorch
- Install TensorFlow and project requirements
### Task 2: Deploy to the target host
**Files:**
- No code changes required
**Step 1: Sync the updated project**
- Replace the target project directory while preserving weights if needed.
**Step 2: Ensure weights are in project-relative paths**
- Verify YOLO and DeepFace weights under `weights/`.
### Task 3: Validate and bundle
**Files:**
- No code changes required
**Step 1: Run setup on the target host**
- Execute the setup script.
**Step 2: Validate the RTSP CLI**
- Run `./.venv/bin/python main.py rtsp --help`.
**Step 3: Create the lightweight tarball**
- Exclude `.venv`
- Keep code, scripts, configs, docs, and weights

View File

@@ -0,0 +1,46 @@
# Offline Wheelhouse Design
**Date:** 2026-04-08
**Goal:** Add an offline Python dependency bundle for Ubuntu 24.04 x86_64 with Python 3.12 and NVIDIA GPU support so the project can be installed on similar machines without re-downloading PyTorch, TensorFlow, and application wheels.
## Scope
- Target platform: Ubuntu 24.04 x86_64
- Python version: 3.12
- GPU runtime: NVIDIA, using CUDA-enabled PyTorch wheels
- Bundle type: project code + weights + `wheelhouse/`
- Setup behavior: prefer offline wheels when present, fall back to network otherwise
## Approach
Add a dedicated wheelhouse build script that downloads:
- `pip`, `setuptools`, `wheel`
- `numpy<2`
- CUDA-enabled `torch` and `torchvision`
- `tensorflow[and-cuda]==2.16.1`
- `tf-keras==2.16.0`
- project requirements and their transitive dependencies
Store the wheels inside `wheelhouse/` under the project root.
Update the native setup script so it:
1. creates `.venv`
2. upgrades installer tooling from `wheelhouse/` when available
3. installs PyTorch and TensorFlow from local wheels when available
4. installs project requirements from local wheels when available
5. falls back to online indexes only if the wheelhouse is missing
## Bundle Layout
- `weights/`
- `wheelhouse/`
- `setup_native_venv.sh`
- `build_wheelhouse.sh`
- `run_rtsp.sh`
## Tradeoff
This increases the lightweight bundle size, but it removes repeat dependency downloads on future hosts. The user explicitly asked for an offline dependency pack, so this is the right tradeoff now.

View File

@@ -0,0 +1,51 @@
# Offline Wheelhouse Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a reusable offline wheelhouse for the native x86 bundle and make setup prefer local wheels.
**Architecture:** Keep the native bundle layout, add one build script that downloads all required wheels into `wheelhouse/`, and update the setup script to install from `wheelhouse/` first.
**Tech Stack:** Python 3.12, pip download, wheelhouse, shell scripts
---
### Task 1: Add offline dependency metadata and scripts
**Files:**
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/requirements-native.txt`
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/build_wheelhouse.sh`
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/setup_native_venv.sh`
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/README_NATIVE.md`
**Step 1: Add a native requirements file**
- Pin `numpy<2`
- Include app-level dependencies used by native setup
**Step 2: Add a wheelhouse build script**
- Download installer tools, PyTorch CUDA wheels, TensorFlow wheels, and project wheels
- Write everything into `wheelhouse/`
**Step 3: Make setup prefer offline wheels**
- Use `--no-index --find-links wheelhouse` when local wheels are available
- Fall back to online install otherwise
### Task 2: Sync and build the wheelhouse on the target host
**Files:**
- No code changes required
**Step 1: Sync project changes to `192.168.5.154`**
- Preserve existing weights
**Step 2: Run `build_wheelhouse.sh`**
- Populate `/home/x/people/people_flow_project/wheelhouse`
**Step 3: Validate setup behavior**
- Confirm `setup_native_venv.sh` recognizes local wheelhouse

View File

@@ -0,0 +1,31 @@
# RTSP Heartbeat Logging Design
**Date:** 2026-04-08
**Goal:** Add periodic heartbeat logs to the RTSP pipeline so operators can confirm the stream is still being processed during long 30-minute windows.
## Scope
- Keep the existing RTSP counting behavior unchanged.
- Print one heartbeat line every 60 seconds while the RTSP loop is running.
- Include the current demographic counts in the heartbeat output.
- Do not change JSON payload structure or window timing.
## Heartbeat Format
Each heartbeat line should report:
- runtime seconds
- current window index
- current window frame count
- total people in the active window
- age counts
- gender counts
- unknown attributes
- last processed timestamp
This output is intended for `tail -f` style monitoring and should remain single-line and compact.
## Approach
Reuse the existing live stats helper to avoid recomputing counting rules in a second place. The RTSP loop already knows when each sampled frame is processed, so it can track the last successful processing timestamp and emit a heartbeat when 60 seconds have elapsed since the last log.

View File

@@ -0,0 +1,47 @@
# RTSP Heartbeat Logging Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add one-line RTSP heartbeat logs every 60 seconds so operators can monitor progress during long windows.
**Architecture:** Extend the RTSP loop with lightweight heartbeat state. Reuse the existing live stats builder and print one compact log line every 60 seconds after sampled frames are processed.
**Tech Stack:** Python, dataclasses, OpenCV, existing people-flow pipeline
---
### Task 1: Add heartbeat state and log output
**Files:**
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/src/people_flow/pipeline.py`
**Step 1: Track heartbeat timing**
- Store the process start time.
- Store the next heartbeat deadline.
- Store the last successful processed timestamp.
**Step 2: Print one-line heartbeat logs**
- Reuse current live stats.
- Include runtime, window index, frame count, totals, demographics, unknown count, and last processed timestamp.
**Step 3: Keep the logging cadence stable**
- Emit at most one heartbeat per 60 seconds.
- Do not log on every frame.
### Task 2: Validate and synchronize
**Files:**
- No additional files required
**Step 1: Run compile checks**
Run: `python3 -m compileall main.py src`
Expected: PASS
**Step 2: Sync to remote host**
- Replace the remote project with the updated local copy.
- Keep the existing remote backup intact.

View File

@@ -0,0 +1,116 @@
# RTSP Windowed People Flow Design
**Date:** 2026-04-08
**Goal:** Extend the existing people-flow project with an RTSP mode that samples one frame per second from a live stream, computes people-flow and demographics, and writes a JSON summary every 30 minutes while preserving the existing offline video and batch modes.
## Scope
- Keep the existing `video` and `batch` commands unchanged.
- Add a new `rtsp` command for continuous live-stream processing.
- Sample one frame per second based on wall-clock time instead of processing every decoded frame.
- Maintain a 30-minute independent counting window.
- Write one timestamped JSON file per finished window.
- Refresh a `latest.json` file on every window flush.
- Do not save annotated RTSP video by default.
- Back up the current project before implementation.
## Approach
The current codebase already has reusable counting and attribute aggregation logic. The least risky change is to keep the offline pipeline as-is and add a dedicated RTSP processing path that reuses the same `LineCrossCounter` and `AttributeAggregator` components.
The RTSP path will:
1. Open an RTSP stream with OpenCV.
2. Read frames continuously.
3. Run inference only when at least one second has elapsed since the last processed frame.
4. Accumulate counts inside the current 30-minute window.
5. Flush a window summary to JSON when the window boundary is reached.
6. Reset all per-window state and continue into the next window.
7. Retry the stream connection when the RTSP source drops.
## Data Flow
### Command Layer
- `main.py` adds an `rtsp` subcommand with an `--input` RTSP URL.
- Existing global arguments such as `--config`, `--output-dir`, `--line`, and `--device` remain shared.
- RTSP mode disables video writing by default unless explicitly enabled in config later.
### Configuration
Add a new RTSP config section with:
- `sample_interval_seconds`
- `window_seconds`
- `reconnect_delay_seconds`
- `stream_open_timeout_seconds`
- `idle_sleep_seconds`
- `output_subdir`
This keeps timing and output behavior configurable without changing code.
### Processing Loop
Each processed frame will:
1. Pass through YOLO tracking.
2. Extract `person` track observations.
3. Optionally run DeepFace sampling on eligible tracks.
4. Update the line-cross counter.
5. Check whether the active 30-minute window should be flushed.
Skipped frames are decoded only to keep the stream current; they do not go through YOLO or DeepFace.
### Window Boundaries
Each window starts when the RTSP pipeline starts or right after the previous flush. The summary payload includes:
- `source_type`
- `source`
- `window_index`
- `window_start`
- `window_end`
- `window_duration_seconds`
- `total_people`
- `age_counts`
- `gender_counts`
- `unknown_attributes`
- `tracks`
After flushing:
- The timestamped JSON is written under `windows/`.
- `latest.json` is overwritten with the same payload.
- The counting and attribute state is reset.
## Output Layout
For `--output-dir /path/output`, the RTSP outputs live under:
- `/path/output/rtsp_stream/`
- `/path/output/rtsp_stream/latest.json`
- `/path/output/rtsp_stream/windows/stats_YYYY-MM-DD_HH-MM-SS.json`
The timestamp in the filename is the window end time.
## Error Handling
- If the RTSP stream cannot be opened, retry after a configurable delay.
- If frame reads fail mid-stream, release the capture and reconnect.
- If DeepFace analysis fails on a crop, treat that sample as unknown and keep running.
- If a window has zero crossings, still write a valid JSON payload with zero counts so downstream consumers can distinguish inactivity from pipeline failure.
## Compatibility
- `video` mode still writes annotated video and a final JSON after full processing.
- `batch` mode still writes a final CSV summary.
- Existing config keys remain valid.
## Testing Strategy
- Validate CLI parsing for the new `rtsp` command.
- Validate config loading with the new RTSP section.
- Validate that RTSP mode writes windowed JSON payloads and refreshes `latest.json`.
- Validate that 30-minute windows reset counts instead of accumulating indefinitely.
- Keep offline mode behavior intact by running `--help` and Python compile checks.

View File

@@ -0,0 +1,131 @@
# RTSP Windowed People Flow Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add an RTSP mode that samples one frame per second, emits independent 30-minute JSON summaries, and preserves the existing offline video and batch workflows.
**Architecture:** Keep the existing offline pipeline untouched and add a dedicated RTSP pipeline path that reuses the counting and attribute aggregation components. Introduce a small RTSP configuration model and window-summary writer so the stream loop can reconnect, flush windowed JSON files, and reset state cleanly.
**Tech Stack:** Python, OpenCV, Ultralytics YOLO, DeepFace, PyYAML, dataclasses
---
### Task 1: Add RTSP configuration models
**Files:**
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/src/people_flow/models.py`
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/src/people_flow/config.py`
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/configs/default_config.yaml`
**Step 1: Add an RTSP config dataclass**
- Add a dataclass with interval, window duration, reconnect delay, idle sleep, and output subdirectory fields.
- Attach it to `AppConfig`.
**Step 2: Load RTSP config from YAML**
- Update config loading to parse the new section.
- Keep backward compatibility when the section is absent.
**Step 3: Set sensible defaults in YAML**
- Add `sample_interval_seconds: 1`
- Add `window_seconds: 1800`
- Add reconnect and idle sleep defaults.
**Step 4: Run a compile check**
Run: `python3 -m compileall main.py src`
Expected: PASS
### Task 2: Add the RTSP CLI entrypoint
**Files:**
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/main.py`
**Step 1: Add a new `rtsp` subcommand**
- Accept `--input` as the RTSP URL.
- Reuse global config and output arguments.
**Step 2: Wire the command to the pipeline**
- Call a new `process_rtsp()` method.
- Print the output directory and latest JSON path once the command starts.
**Step 3: Verify CLI help**
Run: `python3 main.py rtsp --help`
Expected: PASS and shows the RTSP input argument.
### Task 3: Implement the RTSP processing loop
**Files:**
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/src/people_flow/pipeline.py`
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/src/people_flow/io_utils.py`
**Step 1: Add RTSP output helpers**
- Add a helper that creates `/rtsp_stream/windows`.
- Add a helper that writes a timestamped JSON file and refreshes `latest.json`.
**Step 2: Add RTSP window summary generation**
- Reuse the existing summary-building logic, but parameterize it with `source`, `window_start`, and `window_end`.
- Keep the same count keys and track payload structure.
**Step 3: Add `process_rtsp()`**
- Open the RTSP stream with OpenCV.
- Reconnect on open/read failures after a delay.
- Sample one frame per second based on wall-clock time.
- Reuse YOLO tracking, crossing detection, and DeepFace aggregation on sampled frames only.
- Flush a JSON summary every 30 minutes.
- Reset counting and attribute state after each flush.
**Step 4: Keep long-running behavior explicit**
- Do not save annotated RTSP video by default.
- Ensure zero-count windows still emit JSON.
### Task 4: Preserve offline behavior
**Files:**
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/src/people_flow/pipeline.py`
**Step 1: Refactor only shared summary code**
- Extract helper methods where useful.
- Do not change the existing `video`/`batch` outputs or file naming.
**Step 2: Re-run offline CLI smoke tests**
Run: `python3 main.py --help`
Expected: PASS
Run: `python3 main.py video --help`
Expected: PASS
Run: `python3 main.py batch --help`
Expected: PASS
### Task 5: Update docs and validate
**Files:**
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/README.md`
**Step 1: Document the new RTSP mode**
- Add example commands.
- Explain the 1 FPS sampling and 30-minute window JSON behavior.
**Step 2: Run final validation**
Run: `python3 -m compileall main.py src`
Expected: PASS
Run: `python3 main.py rtsp --help`
Expected: PASS
Run: `python3 main.py --help`
Expected: PASS

View File

@@ -0,0 +1,79 @@
# x86 Docker Migration Design
**Date:** 2026-04-08
**Goal:** Package the RTSP people-flow project for direct use on an Ubuntu 24.04 x86_64 host with an NVIDIA RTX 3080 by using Docker, bundled project files, and host-side model weights.
## Scope
- Target host: `xiaozheng@192.168.5.154`
- Target path: `/home/x/people`
- Runtime model: Docker with NVIDIA runtime
- Input source: RTSP
- Output: JSON window summaries under a mounted host directory
- Include required model weights on the target host
## Why Docker
The existing remote runtime was built on Jetson ARM64 and cannot be reused on an x86_64 RTX 3080 machine. The target host only has Python 3.12 installed, and a native port would need additional interpreter and CUDA-specific package work. Docker is the most reliable path because it isolates Python dependencies, preserves a reproducible runtime, and matches the user requirement of direct use on a new CUDA-capable machine.
## Packaging Strategy
### Host Layout
The target host will contain:
- `/home/x/people/people_flow_project/`
- `/home/x/people/people_flow_project/weights/yolo11n.pt`
- `/home/x/people/people_flow_project/weights/deepface/age_model_weights.h5`
- `/home/x/people/people_flow_project/weights/deepface/gender_model_weights.h5`
- `/home/x/people/people_flow_project/weights/deepface/retinaface.h5`
- `/home/x/people/output/`
### Container Layout
The container will:
- run on Python 3.12
- install GPU-enabled PyTorch wheels
- install the application dependencies
- read YOLO and DeepFace weights from deterministic in-container paths
- write outputs to a mounted host output directory
The project source will be copied into the image at build time. The host-side `weights/` directory will also be part of the build context so the final image does not need to download weights on first start.
## Runtime Contract
The image is intended to be built once on the target host and then started with a single `docker run` command using `--gpus all`.
The container command will remain the existing CLI:
`python main.py --config ... --output-dir ... --device cuda:0 rtsp --input ...`
## System Adaptation
The target host already has:
- Ubuntu 24.04
- Docker installed
- NVIDIA runtime registered in Docker
The adaptation work is therefore limited to:
- adding the projects Docker packaging files
- transferring project code and model weights
- building the image on the target host
- validating the container entrypoint and GPU runtime path
## Risks
- The target GPU is currently heavily occupied by another process, so a full inference validation may need to avoid competing for memory.
- DeepFace and TensorFlow increase image size and build time.
- Network access is required during image build unless a wheel cache is prepared separately.
## Success Criteria
- The target host contains the project and all required weights under `/home/x/people`
- `docker build` completes successfully
- The container can run `main.py rtsp --help`
- The final run command is documented for direct RTSP use

View File

@@ -0,0 +1,77 @@
# x86 Docker Migration Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make the RTSP people-flow project directly usable on Ubuntu 24.04 x86_64 with an RTX 3080 by transferring code and weights and building a Docker image on the target host.
**Architecture:** Use a Docker-based runtime for Python 3.12, GPU-enabled PyTorch, DeepFace, and the existing project CLI. Keep weights in a deterministic project directory and bake them into the image during build so runtime startup does not trigger downloads.
**Tech Stack:** Docker, NVIDIA Container Runtime, Python 3.12, PyTorch, Ultralytics, DeepFace
---
### Task 1: Add Docker packaging files
**Files:**
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/Dockerfile`
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/docker-compose.yml`
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/.dockerignore`
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/scripts/run_rtsp_docker.sh`
- Modify: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/README.md`
**Step 1: Define the image build**
- Base the image on Python 3.12.
- Install required OS packages for OpenCV and ffmpeg.
- Install GPU-enabled PyTorch and project dependencies.
- Copy project source and weights into the image.
**Step 2: Add a Docker run wrapper**
- Provide a shell script that accepts RTSP URL and output directory.
- Use `--gpus all`.
**Step 3: Update the README**
- Document the Docker build and run commands.
- Document where weights must live if the host directory is rebuilt.
### Task 2: Prepare the project tree for weights
**Files:**
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/weights/.gitkeep`
- Create: `/Users/zxmacmini1/Documents/人流检测/people_flow_project/weights/deepface/.gitkeep`
**Step 1: Create weight directories**
- Reserve stable paths for YOLO and DeepFace weights.
### Task 3: Transfer the project and weights to the target host
**Files:**
- No code changes required
**Step 1: Copy the project**
- Transfer the project directory to `/home/x/people/people_flow_project`.
**Step 2: Copy YOLO and DeepFace weights**
- Place YOLO and DeepFace weights into the target project `weights/` tree.
### Task 4: Build and validate on the target host
**Files:**
- No code changes required
**Step 1: Build the image**
- Run `docker build` under `/home/x/people/people_flow_project`.
**Step 2: Validate the CLI**
- Run the container with `python main.py rtsp --help`.
**Step 3: Provide the final RTSP run command**
- Document the exact `docker run` invocation for the target host.

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
import argparse
from pathlib import Path
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="People-flow counting with YOLO tracking and DeepFace demographics."
)
parser.add_argument(
"--config",
default="configs/default_config.yaml",
help="Path to the YAML config file.",
)
parser.add_argument(
"--output-dir",
default=None,
help="Directory for generated artifacts.",
)
parser.add_argument(
"--line",
help="Override counting line as x1,y1,x2,y2.",
)
parser.add_argument(
"--line-mode",
choices=["normalized", "pixel"],
help="Coordinate mode for --line.",
)
parser.add_argument(
"--device",
help="Override inference device, for example cuda:0 or cpu.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
video_parser = subparsers.add_parser("video", help="Process one video.")
video_parser.add_argument("--input", required=True, help="Path to the video file.")
video_parser.add_argument(
"--skip-video-save",
action="store_true",
help="Do not write the annotated video.",
)
batch_parser = subparsers.add_parser("batch", help="Process a directory of videos.")
batch_parser.add_argument(
"--input-dir",
required=True,
help="Directory scanned recursively for videos.",
)
batch_parser.add_argument(
"--pattern",
default="*.mp4",
help="Glob pattern used during recursive discovery.",
)
batch_parser.add_argument(
"--skip-video-save",
action="store_true",
help="Do not write annotated videos.",
)
rtsp_parser = subparsers.add_parser("rtsp", help="Process a live RTSP stream.")
rtsp_parser.add_argument("--input", help="RTSP URL.")
manage_api_parser = subparsers.add_parser("manage-api", help="Start the management API.")
manage_api_parser.add_argument("--host", default="0.0.0.0", help="Host for the management API.")
manage_api_parser.add_argument("--port", type=int, default=18082, help="Port for the management API.")
return parser
def build_config(args: argparse.Namespace):
from src.people_flow.config import load_config, merge_cli_overrides
save_video = None
if hasattr(args, "skip_video_save"):
save_video = not args.skip_video_save
config = load_config(Path(args.config))
return merge_cli_overrides(
config=config,
line=args.line,
line_mode=args.line_mode,
device=args.device,
save_video=save_video,
)
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.command == "manage-api":
from src.people_flow.manage_api import run_manage_api
run_manage_api(args.config, host=args.host, port=args.port)
return 0
config = build_config(args)
from src.people_flow.pipeline import PeopleFlowPipeline, discover_videos
output_root = Path(args.output_dir or config.runtime.output_dir)
pipeline = PeopleFlowPipeline(config=config, output_root=output_root)
if args.command == "rtsp":
paths = pipeline.get_rtsp_output_paths()
print(f"rtsp_output_dir={paths['root']}", flush=True)
print(f"latest_json={paths['latest_json']}", flush=True)
source = args.input or config.runtime.rtsp_url
if not source:
raise SystemExit("RTSP source is required. Pass --input or set runtime.rtsp_url in the config.")
pipeline.process_rtsp(source)
return 0
if args.command == "video":
result = pipeline.process_video(Path(args.input))
print(f"processed_video={result['video_name']}")
print(f"total_people={result['total_people']}")
print(f"unknown_attributes={result['unknown_attributes']}")
print(f"json={result['json_path']}")
if result.get("video_output_path"):
print(f"annotated_video={result['video_output_path']}")
return 0
videos = discover_videos(Path(args.input_dir), pattern=args.pattern)
if not videos:
raise SystemExit(f"No videos found under {args.input_dir} with pattern {args.pattern}")
summary = pipeline.process_batch(videos)
print(f"videos_processed={len(summary['videos'])}")
print(f"csv={summary['csv_path']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,11 @@
[project]
name = "people-flow-project"
version = "0.1.0"
description = "Street video people-flow counting with YOLO tracking and face-based age/gender estimation"
readme = "README.md"
requires-python = ">=3.10,<3.13"
dependencies = []
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

View File

@@ -0,0 +1,7 @@
flask>=3.1.0
ultralytics>=8.3.0
opencv-python-headless>=4.10.0
deepface>=0.0.93
pyyaml>=6.0.2
pandas>=2.2.3
numpy<2

View File

@@ -0,0 +1,8 @@
flask>=3.1.0
numpy<2
ultralytics==8.4.35
lap>=0.5.12
opencv-python==4.11.0.86
deepface==0.0.99
pyyaml==6.0.3
pandas==3.0.2

View File

@@ -0,0 +1,6 @@
ultralytics>=8.3.0
opencv-python>=4.10.0
deepface>=0.0.93
pyyaml>=6.0.2
pandas>=2.2.3
numpy>=1.26.0

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/scripts/run.sh" "$@"

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env sh
set -eu
PROJECT_DIR="/opt/people-flow"
CONFIG_TEMPLATE="${PROJECT_DIR}/config/config.example.yaml"
CONFIG_PATH="${CONFIG_PATH:-${PROJECT_DIR}/config/local.yaml}"
OUTPUT_DIR="${OUTPUT_DIR:-${PROJECT_DIR}/outputs}"
RTSP_URL="${RTSP_URL:-}"
API_HOST="${API_HOST:-0.0.0.0}"
API_PORT="${API_PORT:-18082}"
mkdir -p "${OUTPUT_DIR}" "$(dirname "${CONFIG_PATH}")"
if [ ! -f "${CONFIG_PATH}" ]; then
cp "${CONFIG_TEMPLATE}" "${CONFIG_PATH}"
fi
python - "$CONFIG_PATH" "$RTSP_URL" "$OUTPUT_DIR" <<'PY'
from pathlib import Path
import sys
import yaml
config_path = Path(sys.argv[1])
rtsp_url = sys.argv[2]
output_dir = sys.argv[3]
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
runtime = raw.setdefault("runtime", {})
if rtsp_url:
runtime["rtsp_url"] = rtsp_url
runtime["output_dir"] = output_dir
yolo = raw.setdefault("yolo", {})
yolo.setdefault("model_path", "weights/yolo11n.pt")
config_path.write_text(
yaml.safe_dump(raw, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
PY
exec python main.py --config "${CONFIG_PATH}" manage-api --host "${API_HOST}" --port "${API_PORT}"

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SETUP_SCRIPT="${PROJECT_DIR}/setup_native_venv.sh"
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}"
}
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
run_project_user env PYTHON_BIN="${PYTHON_BIN:-python3.12}" bash "${SETUP_SCRIPT}"
run_project_user bash "${RUN_SCRIPT}" --prepare-only
bash "${INSTALL_SERVICE_SCRIPT}"
run_privileged systemctl enable --now people-flow.service
cat <<EOF
Offline install complete.
Service started and enabled on boot: people-flow.service
Runtime log: ${PROJECT_DIR}/outputs/rtsp_run.log
EOF

View 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/people-flow.service.tpl"
CONFIG_PATH="${CONFIG_PATH:-${PROJECT_DIR}/config/local.yaml}"
SERVICE_NAME="${SERVICE_NAME:-people-flow.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}"

View File

@@ -0,0 +1,41 @@
#!/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"
RTSP_URL="${RTSP_URL:-rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream}"
OUTPUT_DIR="${OUTPUT_DIR:-${PROJECT_DIR}/outputs}"
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 "${OUTPUT_DIR}" "${PROJECT_DIR}/config"
cp "${CONFIG_TEMPLATE}" "${CONFIG_PATH}"
sed -i.bak \
-e "s|^ rtsp_url: .*| rtsp_url: \"${RTSP_URL}\"|" \
-e "s|^ output_dir: .*| output_dir: \"${OUTPUT_DIR}\"|" \
"${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}" "${PROJECT_DIR}/main.py" --config "${CONFIG_PATH}" rtsp "$@"

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <rtsp_url> <host_output_dir>"
exit 1
fi
RTSP_URL="$1"
HOST_OUTPUT_DIR="$2"
mkdir -p "$HOST_OUTPUT_DIR"
docker run -d \
--name people-flow-rtsp \
--restart unless-stopped \
--network host \
--gpus all \
--shm-size 1g \
-v "$HOST_OUTPUT_DIR:/opt/people-flow/output" \
people-flow-rtsp:x86-cuda \
--config /opt/people-flow/configs/docker_x86_config.yaml \
--output-dir /opt/people-flow/output \
--device cuda:0 \
rtsp \
--input "$RTSP_URL"

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR"
WHEELHOUSE_DIR="$PROJECT_ROOT/wheelhouse"
DEEPFACE_SOURCE_DIR="$PROJECT_ROOT/weights/deepface"
DEEPFACE_TARGET_DIR="${HOME}/.deepface/weights"
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
cd "$PROJECT_ROOT"
"$PYTHON_BIN" -m venv .venv
source .venv/bin/activate
if [[ -d "$WHEELHOUSE_DIR" ]] && find "$WHEELHOUSE_DIR" -maxdepth 1 -name '*.whl' | grep -q .; then
python -m pip install --no-index --find-links "$WHEELHOUSE_DIR" --upgrade pip setuptools wheel
pip install --no-index --find-links "$WHEELHOUSE_DIR" "numpy<2"
pip install --no-index --find-links "$WHEELHOUSE_DIR" torch torchvision
pip install --no-index --find-links "$WHEELHOUSE_DIR" "tensorflow[and-cuda]==2.16.1" "tf-keras==2.16.0"
pip install --no-index --find-links "$WHEELHOUSE_DIR" -r requirements-native.txt
else
python -m pip install --upgrade pip setuptools wheel
pip install "numpy<2"
pip install --index-url https://download.pytorch.org/whl/cu126 torch torchvision
pip install "tensorflow[and-cuda]==2.16.1" "tf-keras==2.16.0"
pip install -r requirements-native.txt
fi
mkdir -p "$DEEPFACE_TARGET_DIR"
if find "$DEEPFACE_SOURCE_DIR" -maxdepth 1 -name '*.h5' | grep -q .; then
cp -f "$DEEPFACE_SOURCE_DIR"/*.h5 "$DEEPFACE_TARGET_DIR"/
else
echo "Warning: missing bundled DeepFace weights under $DEEPFACE_SOURCE_DIR"
echo "Attribute analysis will stay unavailable until the .h5 files are provided."
fi
echo "venv_ready=$PROJECT_ROOT/.venv"

View File

@@ -0,0 +1 @@
"""People flow analysis package."""

View File

@@ -0,0 +1,141 @@
from __future__ import annotations
from collections import Counter, defaultdict
from statistics import median
from typing import Any
import cv2
import numpy as np
from .models import AttributeConfig, AttributeVote, TrackAttributeSummary, TrackObservation
def age_to_bucket(age: int) -> str:
if age < 18:
return "minor"
if age < 60:
return "adult"
return "senior"
def normalize_gender(raw_gender: str | None) -> str | None:
if not raw_gender:
return None
lowered = raw_gender.strip().lower()
if lowered in {"man", "male"}:
return "male"
if lowered in {"woman", "female"}:
return "female"
return None
class AttributeAggregator:
def __init__(self, config: AttributeConfig) -> None:
self.config = config
self.votes: dict[int, list[AttributeVote]] = defaultdict(list)
self.samples_taken: dict[int, int] = defaultdict(int)
self.last_sampled_frame: dict[int, int] = {}
self._deepface = self._load_deepface() if config.enabled else None
def _load_deepface(self) -> Any:
try:
from deepface import DeepFace
except ImportError as exc:
raise RuntimeError(
"DeepFace is not installed. Install dependencies with `pip install -r requirements.txt`."
) from exc
return DeepFace
def maybe_collect(self, frame: np.ndarray, frame_index: int, track: TrackObservation) -> None:
if self._deepface is None:
return
if self.samples_taken[track.track_id] >= self.config.max_samples_per_track:
return
last_frame = self.last_sampled_frame.get(track.track_id)
if last_frame is not None and frame_index - last_frame < self.config.sample_every_n_frames:
return
x1, y1, x2, y2 = track.bbox
width = x2 - x1
height = y2 - y1
if width < self.config.min_person_box_width or height < self.config.min_person_box_height:
return
crop = self._crop_person(frame, track.bbox)
if crop.size == 0:
return
vote = self._analyze_crop(crop)
self.last_sampled_frame[track.track_id] = frame_index
if vote is None:
return
self.samples_taken[track.track_id] += 1
self.votes[track.track_id].append(vote)
def reset(self) -> None:
self.votes.clear()
self.samples_taken.clear()
self.last_sampled_frame.clear()
def _crop_person(self, frame: np.ndarray, bbox: tuple[int, int, int, int]) -> np.ndarray:
x1, y1, x2, y2 = bbox
height, width = frame.shape[:2]
pad_x = int((x2 - x1) * self.config.person_crop_padding)
pad_y = int((y2 - y1) * self.config.person_crop_padding)
left = max(0, x1 - pad_x)
top = max(0, y1 - pad_y)
right = min(width, x2 + pad_x)
bottom = min(height, y2 + pad_y)
return frame[top:bottom, left:right]
def _analyze_crop(self, crop: np.ndarray) -> AttributeVote | None:
rgb_crop = cv2.cvtColor(crop, cv2.COLOR_BGR2RGB)
try:
analysis = self._deepface.analyze(
img_path=rgb_crop,
actions=["age", "gender"],
detector_backend=self.config.detector_backend,
enforce_detection=self.config.enforce_detection,
silent=True,
)
except Exception:
return None
if isinstance(analysis, list):
if not analysis:
return None
analysis = analysis[0]
age_value = analysis.get("age")
gender_value = normalize_gender(analysis.get("dominant_gender"))
if age_value is None or gender_value is None:
return None
age_int = int(round(float(age_value)))
return AttributeVote(
age=age_int,
age_bucket=age_to_bucket(age_int),
gender=gender_value,
)
def summarize_track(self, track_id: int) -> TrackAttributeSummary | None:
votes = self.votes.get(track_id, [])
if not votes:
return None
age_bucket_counts = Counter(vote.age_bucket for vote in votes)
gender_counts = Counter(vote.gender for vote in votes)
if not age_bucket_counts or not gender_counts:
return None
age_bucket = age_bucket_counts.most_common(1)[0][0]
gender = gender_counts.most_common(1)[0][0]
age_value = int(round(median(vote.age for vote in votes)))
return TrackAttributeSummary(
track_id=track_id,
age=age_value,
age_bucket=age_bucket,
gender=gender,
samples_used=len(votes),
)

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
import yaml
from .models import (
AppConfig,
AttributeConfig,
CountingConfig,
OutputConfig,
RtspConfig,
RuntimeConfig,
YoloConfig,
)
def _read_yaml(config_path: Path) -> dict:
if not config_path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
with config_path.open("r", encoding="utf-8") as handle:
loaded = yaml.safe_load(handle) or {}
if not isinstance(loaded, dict):
raise ValueError(f"Config file must contain a mapping: {config_path}")
return loaded
def load_config_document(config_path: Path) -> dict:
return _read_yaml(config_path)
def save_config_document(config_path: Path, payload: dict) -> None:
config_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = config_path.with_suffix(config_path.suffix + ".tmp")
temp_path.write_text(
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
temp_path.replace(config_path)
def resolve_project_root(config_path: Path) -> Path:
return config_path.expanduser().resolve().parent.parent
def resolve_project_path(project_root: Path, raw_path: str | Path) -> Path:
path = Path(raw_path)
if path.is_absolute():
return path.resolve()
return (project_root.resolve() / path).resolve()
def load_config(config_path: Path) -> AppConfig:
data = _read_yaml(config_path)
config = AppConfig(
yolo=YoloConfig(**data.get("yolo", {})),
counting=CountingConfig(**_normalize_counting_config(data.get("counting", {}))),
attributes=AttributeConfig(**data.get("attributes", {})),
output=OutputConfig(**data.get("output", {})),
rtsp=RtspConfig(**data.get("rtsp", {})),
runtime=RuntimeConfig(**data.get("runtime", {})),
config_path=config_path.resolve(),
)
return config
def _normalize_counting_config(data: dict) -> dict:
normalized = dict(data)
line = normalized.get("line")
if line is not None:
normalized["line"] = tuple(float(value) for value in line)
return normalized
def parse_line_override(raw_line: str) -> tuple[float, float, float, float]:
parts = [part.strip() for part in raw_line.split(",")]
if len(parts) != 4:
raise ValueError("--line must contain exactly four comma-separated values")
return tuple(float(part) for part in parts) # type: ignore[return-value]
def merge_cli_overrides(
config: AppConfig,
line: str | None,
line_mode: str | None,
device: str | None,
save_video: bool | None,
) -> AppConfig:
updated = config
if line:
updated.counting = replace(updated.counting, line=parse_line_override(line))
if line_mode:
updated.counting = replace(updated.counting, line_mode=line_mode)
if device:
updated.yolo = replace(updated.yolo, device=device)
if save_video is not None:
updated.output = replace(updated.output, save_video=save_video)
return updated

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from .models import CountingConfig, CrossingEvent, TrackObservation
def _line_side(
point: tuple[float, float], line: tuple[float, float, float, float]
) -> float:
px, py = point
x1, y1, x2, y2 = line
return (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1)
class LineCrossCounter:
def __init__(self, line: tuple[float, float, float, float], config: CountingConfig) -> None:
self.line = line
self.config = config
self.previous_side: dict[int, float] = {}
self.counted_ids: set[int] = set()
self.crossings: list[CrossingEvent] = []
def update(self, observations: list[TrackObservation]) -> list[CrossingEvent]:
events: list[CrossingEvent] = []
for observation in observations:
side = _line_side(observation.center, self.line)
previous = self.previous_side.get(observation.track_id)
self.previous_side[observation.track_id] = side
if observation.track_id in self.counted_ids:
continue
if previous is None:
continue
if abs(previous) <= self.config.crossing_tolerance or abs(side) <= self.config.crossing_tolerance:
continue
if previous * side >= 0:
continue
direction = "negative_to_positive" if previous < 0 < side else "positive_to_negative"
event = CrossingEvent(track_id=observation.track_id, direction=direction)
self.counted_ids.add(observation.track_id)
self.crossings.append(event)
events.append(event)
return events
def reset(self) -> None:
self.previous_side.clear()
self.counted_ids.clear()
self.crossings.clear()
@property
def total_people(self) -> int:
return len(self.counted_ids)

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
import cv2
from .models import TrackObservation
def ensure_dir(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
def make_video_writer(path: Path, width: int, height: int, fps: float) -> cv2.VideoWriter:
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
return cv2.VideoWriter(str(path), fourcc, fps if fps > 0 else 25.0, (width, height))
def write_json(path: Path, payload: dict) -> None:
with path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=True, indent=2)
def write_csv(path: Path, rows: list[dict]) -> None:
import pandas as pd
dataframe = pd.DataFrame(rows)
dataframe.to_csv(path, index=False)
def write_window_json(windows_dir: Path, latest_path: Path, payload: dict, window_end: datetime) -> Path:
ensure_dir(windows_dir)
ensure_dir(latest_path.parent)
target = windows_dir / f"stats_{window_end.strftime('%Y-%m-%d_%H-%M-%S')}.json"
write_json(target, payload)
write_json(latest_path, payload)
return target
def draw_line(frame, line: tuple[float, float, float, float]) -> None:
x1, y1, x2, y2 = (int(value) for value in line)
cv2.line(frame, (x1, y1), (x2, y2), (0, 255, 255), 2)
def draw_tracks(
frame,
observations: list[TrackObservation],
counted_ids: set[int],
draw_labels: bool,
) -> None:
for observation in observations:
x1, y1, x2, y2 = observation.bbox
color = (0, 200, 0) if observation.track_id in counted_ids else (255, 140, 0)
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
if draw_labels:
label = f"id={observation.track_id} conf={observation.confidence:.2f}"
cv2.putText(
frame,
label,
(x1, max(20, y1 - 6)),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
color,
1,
cv2.LINE_AA,
)
def draw_stats(frame, stats: dict) -> None:
lines = [
f"total_people: {stats['total_people']}",
f"minor: {stats['age_counts']['minor']}",
f"adult: {stats['age_counts']['adult']}",
f"senior: {stats['age_counts']['senior']}",
f"male: {stats['gender_counts']['male']}",
f"female: {stats['gender_counts']['female']}",
f"unknown_attributes: {stats['unknown_attributes']}",
]
x = 12
y = 24
for text in lines:
cv2.putText(
frame,
text,
(x, y),
cv2.FONT_HERSHEY_SIMPLEX,
0.65,
(255, 255, 255),
2,
cv2.LINE_AA,
)
y += 24

View File

@@ -0,0 +1,389 @@
from __future__ import annotations
import json
from argparse import ArgumentParser
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from flask import Flask, jsonify, request, send_file
from .config import (
load_config,
load_config_document,
resolve_project_path,
resolve_project_root,
save_config_document,
)
PROJECT_TYPE = "people_flow_project"
DEFAULT_MANAGE_PORT = 18082
MAX_PREVIEW_LINES = 2000
@dataclass(slots=True)
class ManageContext:
config_path: Path
project_root: Path
def create_app(config_path: str | Path) -> Flask:
resolved_config = Path(config_path).expanduser().resolve()
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)
runtime = raw.setdefault("runtime", {})
runtime["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,
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="People flow management API")
parser.add_argument("--config", required=True, 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)
output_root = resolve_project_path(ctx.project_root, config.runtime.output_dir)
return {
"project_type": PROJECT_TYPE,
"config_path": str(ctx.config_path),
"runtime": {
"rtsp_url": config.runtime.rtsp_url,
"output_dir": str(output_root),
},
"rtsp": {
"output_subdir": config.rtsp.output_subdir,
"window_seconds": config.rtsp.window_seconds,
},
}
def _build_summary(ctx: ManageContext) -> dict:
summary_path, payload = _load_summary_payload(ctx)
all_window_stats = _load_window_stats(ctx)
if payload is None:
latest_json = _latest_json_path(ctx)
return {
"result_type": PROJECT_TYPE,
"headline": "No RTSP summary output yet",
"metrics": {
"latest_path": str(latest_json),
"recent_window_stats": all_window_stats[:24],
"all_window_stats": all_window_stats,
},
}
tracks = payload.get("tracks", [])
direction_counts: dict[str, int] = {}
if isinstance(tracks, list):
for item in tracks:
if not isinstance(item, dict):
continue
direction = _string_value(item.get("direction"))
if not direction:
continue
direction_counts[direction] = direction_counts.get(direction, 0) + 1
total_people = _int_value(payload.get("total_people"))
window_end = _string_value(payload.get("window_end"))
return {
"result_type": PROJECT_TYPE,
"headline": f"Latest window counted {total_people} people",
"last_result_time": window_end,
"metrics": {
"summary_path": str(summary_path) if summary_path else "",
"window_start": _string_value(payload.get("window_start")),
"window_end": window_end,
"total_people": total_people,
"direction_counts": direction_counts,
"age_counts": _map_string_int(payload.get("age_counts")),
"gender_counts": _map_string_int(payload.get("gender_counts")),
"unknown_attributes": _int_value(payload.get("unknown_attributes")),
"recent_window_stats": all_window_stats[:24],
"all_window_stats": all_window_stats,
},
}
def _load_summary_payload(ctx: ManageContext) -> tuple[Path | None, dict | None]:
candidates: list[Path] = []
latest_json = _latest_json_path(ctx)
if latest_json.exists():
candidates.append(latest_json)
window_files = _window_files(ctx)
if window_files:
candidates.extend(window_files[:1])
for candidate in candidates:
try:
payload = json.loads(candidate.read_text(encoding="utf-8"))
except FileNotFoundError:
continue
except json.JSONDecodeError as exc:
raise ValueError(f"invalid summary json: {candidate}") from exc
if isinstance(payload, dict):
return candidate, payload
return None, None
def _load_window_stats(ctx: ManageContext) -> list[dict]:
stats: list[dict] = []
for path in _window_files(ctx):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError):
continue
if not isinstance(payload, dict):
continue
stats.append(
{
"window_start": _string_value(payload.get("window_start")),
"window_end": _string_value(payload.get("window_end")),
"total_people": _int_value(payload.get("total_people")),
"age_counts": _map_string_int(payload.get("age_counts")),
"gender_counts": _map_string_int(payload.get("gender_counts")),
"unknown_attributes": _int_value(payload.get("unknown_attributes")),
}
)
stats.sort(key=lambda item: item["window_end"], reverse=True)
return stats
def _list_result_files(ctx: ManageContext) -> list[dict]:
files: list[dict] = []
for path, label in (
(_latest_json_path(ctx), "Latest Summary"),
(_runtime_log_path(ctx), "Runtime Log"),
):
if path.exists() and path.is_file():
files.append(_build_result_file(ctx, path, label))
for path in _window_files(ctx):
if path.exists() and path.is_file():
files.append(_build_result_file(ctx, path, "Window Summary"))
return files
def _build_result_file(ctx: ManageContext, path: Path, label: str) -> dict:
info = path.stat()
return {
"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(),
}
def _output_root(ctx: ManageContext) -> Path:
config = load_config(ctx.config_path)
return resolve_project_path(ctx.project_root, config.runtime.output_dir)
def _rtsp_output_root(ctx: ManageContext) -> Path:
config = load_config(ctx.config_path)
return _output_root(ctx) / config.rtsp.output_subdir
def _latest_json_path(ctx: ManageContext) -> Path:
return _rtsp_output_root(ctx) / "latest.json"
def _windows_dir(ctx: ManageContext) -> Path:
return _rtsp_output_root(ctx) / "windows"
def _runtime_log_path(ctx: ManageContext) -> Path:
return _output_root(ctx) / "rtsp_run.log"
def _window_files(ctx: ManageContext) -> list[Path]:
windows_dir = _windows_dir(ctx)
if not windows_dir.exists():
return []
return sorted(
[path for path in windows_dir.iterdir() if path.is_file()],
key=lambda path: path.name,
reverse=True,
)
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 _map_string_int(value) -> dict[str, int]:
if not isinstance(value, dict):
return {}
return {str(key): _int_value(raw) for key, raw in value.items()}
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,105 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class YoloConfig:
model_path: str = "yolo11n.pt"
tracker: str = "botsort.yaml"
conf: float = 0.35
iou: float = 0.5
imgsz: int = 1280
device: str = "cuda:0"
@dataclass
class CountingConfig:
line: tuple[float, float, float, float] = (0.1, 0.55, 0.9, 0.55)
line_mode: str = "normalized"
crossing_tolerance: float = 12.0
def to_pixel_line(self, width: int, height: int) -> tuple[float, float, float, float]:
x1, y1, x2, y2 = self.line
if self.line_mode == "pixel":
return x1, y1, x2, y2
return x1 * width, y1 * height, x2 * width, y2 * height
@dataclass
class AttributeConfig:
enabled: bool = True
sample_every_n_frames: int = 12
max_samples_per_track: int = 5
min_person_box_width: int = 80
min_person_box_height: int = 160
person_crop_padding: float = 0.15
detector_backend: str = "retinaface"
enforce_detection: bool = False
@dataclass
class OutputConfig:
save_video: bool = True
save_json: bool = True
save_csv: bool = True
draw_boxes: bool = True
draw_labels: bool = True
@dataclass
class RtspConfig:
sample_interval_seconds: float = 1.0
window_seconds: int = 1800
reconnect_delay_seconds: float = 5.0
stream_open_timeout_seconds: float = 10.0
idle_sleep_seconds: float = 0.05
output_subdir: str = "rtsp_stream"
@dataclass
class RuntimeConfig:
rtsp_url: str = "rtsp://user:password@camera-ip:554/h264/ch1/main/av_stream"
output_dir: str = "outputs"
@dataclass
class AppConfig:
yolo: YoloConfig = field(default_factory=YoloConfig)
counting: CountingConfig = field(default_factory=CountingConfig)
attributes: AttributeConfig = field(default_factory=AttributeConfig)
output: OutputConfig = field(default_factory=OutputConfig)
rtsp: RtspConfig = field(default_factory=RtspConfig)
runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
config_path: Path | None = None
@dataclass
class TrackObservation:
track_id: int
bbox: tuple[int, int, int, int]
confidence: float
center: tuple[float, float]
@dataclass
class CrossingEvent:
track_id: int
direction: str
@dataclass
class AttributeVote:
age: int
age_bucket: str
gender: str
@dataclass
class TrackAttributeSummary:
track_id: int
age: int
age_bucket: str
gender: str
samples_used: int

View File

@@ -0,0 +1,445 @@
from __future__ import annotations
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
import cv2
from .attributes import AttributeAggregator
from .counting import LineCrossCounter
from .io_utils import (
draw_line,
draw_stats,
draw_tracks,
ensure_dir,
make_video_writer,
write_csv,
write_json,
write_window_json,
)
from .models import AppConfig
from .tracking import extract_person_tracks
SUPPORTED_EXTENSIONS = {".mp4", ".mov", ".mkv", ".avi"}
def discover_videos(root: Path, pattern: str = "*.mp4") -> list[Path]:
if not root.exists():
raise FileNotFoundError(f"Input directory not found: {root}")
videos = [
path
for path in root.rglob(pattern)
if path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS
]
return sorted(videos)
class PeopleFlowPipeline:
def __init__(self, config: AppConfig, output_root: Path) -> None:
self.config = config
self.output_root = ensure_dir(output_root)
self.model = self._load_model()
def _load_model(self) -> Any:
try:
from ultralytics import YOLO
except ImportError as exc:
raise RuntimeError(
"Ultralytics is not installed. Install dependencies with `pip install -r requirements.txt`."
) from exc
return YOLO(self.config.yolo.model_path)
def get_rtsp_output_paths(self) -> dict[str, Path]:
root = ensure_dir(self.output_root / self.config.rtsp.output_subdir)
windows = ensure_dir(root / "windows")
latest_json = root / "latest.json"
return {"root": root, "windows": windows, "latest_json": latest_json}
def process_batch(self, videos: list[Path]) -> dict:
rows: list[dict] = []
for video_path in videos:
rows.append(self.process_video(video_path))
csv_path = self.output_root / "batch_summary.csv"
if self.config.output.save_csv:
csv_rows = [
{
"video_name": row["video_name"],
"video_path": row["video_path"],
"total_people": row["total_people"],
"minor": row["age_counts"]["minor"],
"adult": row["age_counts"]["adult"],
"senior": row["age_counts"]["senior"],
"male": row["gender_counts"]["male"],
"female": row["gender_counts"]["female"],
"unknown_attributes": row["unknown_attributes"],
"json_path": row["json_path"],
"video_output_path": row.get("video_output_path"),
}
for row in rows
]
write_csv(csv_path, csv_rows)
return {"videos": rows, "csv_path": str(csv_path)}
def process_video(self, video_path: Path) -> dict:
if not video_path.exists():
raise FileNotFoundError(f"Video file not found: {video_path}")
capture = cv2.VideoCapture(str(video_path))
if not capture.isOpened():
raise RuntimeError(f"Failed to open video: {video_path}")
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
fps = float(capture.get(cv2.CAP_PROP_FPS) or 25.0)
pixel_line = self.config.counting.to_pixel_line(width=width, height=height)
video_output_dir = ensure_dir(self.output_root / video_path.stem)
video_output_path = video_output_dir / f"{video_path.stem}.annotated.mp4"
json_path = video_output_dir / f"{video_path.stem}.json"
writer = None
if self.config.output.save_video:
writer = make_video_writer(video_output_path, width=width, height=height, fps=fps)
counter = LineCrossCounter(pixel_line, self.config.counting)
attributes = AttributeAggregator(self.config.attributes)
frame_index = 0
while True:
ok, frame = capture.read()
if not ok:
break
observations = self._track_frame(frame)
for observation in observations:
attributes.maybe_collect(frame=frame, frame_index=frame_index, track=observation)
counter.update(observations)
if writer is not None:
frame_stats = self._build_live_stats(counter, attributes)
annotated = frame.copy()
draw_line(annotated, pixel_line)
if self.config.output.draw_boxes:
draw_tracks(
annotated,
observations=observations,
counted_ids=counter.counted_ids,
draw_labels=self.config.output.draw_labels,
)
draw_stats(annotated, frame_stats)
writer.write(annotated)
frame_index += 1
capture.release()
if writer is not None:
writer.release()
summary = self._finalize_summary(video_path, counter, attributes, json_path)
if not self.config.output.save_video:
summary["video_output_path"] = None
else:
summary["video_output_path"] = str(video_output_path)
return summary
def process_rtsp(self, source: str) -> dict:
rtsp_paths = self.get_rtsp_output_paths()
sample_interval = max(float(self.config.rtsp.sample_interval_seconds), 0.01)
window_seconds = max(int(self.config.rtsp.window_seconds), 1)
reconnect_delay = max(float(self.config.rtsp.reconnect_delay_seconds), 0.1)
open_timeout_seconds = max(float(self.config.rtsp.stream_open_timeout_seconds), 1.0)
idle_sleep = max(float(self.config.rtsp.idle_sleep_seconds), 0.0)
window_index = 0
process_started_at = datetime.now().astimezone()
window_start = datetime.now().astimezone()
window_end = window_start + timedelta(seconds=window_seconds)
last_processed_at = 0.0
last_processed_wall_time: datetime | None = None
next_heartbeat_at = time.monotonic() + 60.0
frame_index = 0
capture = None
pixel_line = None
counter = None
attributes = AttributeAggregator(self.config.attributes)
try:
while True:
now = datetime.now().astimezone()
while now >= window_end:
payload = self._build_rtsp_summary(
source=source,
window_index=window_index,
window_start=window_start,
window_end=window_end,
counter=counter,
attributes=attributes,
)
json_path = write_window_json(
rtsp_paths["windows"],
rtsp_paths["latest_json"],
payload,
window_end,
)
print(f"window_json={json_path}", flush=True)
print(f"window_total_people={payload['total_people']}", flush=True)
window_index += 1
window_start = window_end
window_end = window_start + timedelta(seconds=window_seconds)
if counter is not None:
counter.reset()
attributes.reset()
now = datetime.now().astimezone()
if capture is None or not capture.isOpened():
capture = self._open_rtsp_capture(source, open_timeout_seconds)
if capture is None:
time.sleep(reconnect_delay)
continue
ok, frame = capture.read()
if not ok or frame is None:
capture.release()
capture = None
time.sleep(reconnect_delay)
continue
if pixel_line is None:
height, width = frame.shape[:2]
pixel_line = self.config.counting.to_pixel_line(width=width, height=height)
counter = LineCrossCounter(pixel_line, self.config.counting)
current_time = time.monotonic()
if current_time - last_processed_at < sample_interval:
if idle_sleep > 0:
time.sleep(idle_sleep)
continue
last_processed_at = current_time
observations = self._track_frame(frame)
for observation in observations:
attributes.maybe_collect(frame=frame, frame_index=frame_index, track=observation)
if counter is not None:
counter.update(observations)
if current_time >= next_heartbeat_at:
self._print_rtsp_heartbeat(
process_started_at=process_started_at,
window_index=window_index,
frame_index=frame_index + 1,
counter=counter,
attributes=attributes,
last_processed_wall_time=now,
)
next_heartbeat_at = current_time + 60.0
last_processed_wall_time = now
frame_index += 1
except KeyboardInterrupt:
pass
finally:
if capture is not None:
capture.release()
return {
"rtsp_output_dir": str(rtsp_paths["root"]),
"latest_json": str(rtsp_paths["latest_json"]),
}
def _track_frame(self, frame) -> list:
results = self.model.track(
frame,
persist=True,
tracker=self.config.yolo.tracker,
conf=self.config.yolo.conf,
iou=self.config.yolo.iou,
imgsz=self.config.yolo.imgsz,
device=self.config.yolo.device,
verbose=False,
classes=[0],
)
result = results[0] if isinstance(results, list) else results
return extract_person_tracks(result)
def _open_rtsp_capture(self, source: str, timeout_seconds: float):
capture = cv2.VideoCapture()
open_timeout = getattr(cv2, "CAP_PROP_OPEN_TIMEOUT_MSEC", None)
read_timeout = getattr(cv2, "CAP_PROP_READ_TIMEOUT_MSEC", None)
if open_timeout is not None:
capture.set(open_timeout, timeout_seconds * 1000.0)
if read_timeout is not None:
capture.set(read_timeout, timeout_seconds * 1000.0)
buffersize = getattr(cv2, "CAP_PROP_BUFFERSIZE", None)
if buffersize is not None:
capture.set(buffersize, 1)
capture.open(source)
if capture.isOpened():
return capture
capture.release()
return None
def _build_live_stats(self, counter: LineCrossCounter, attributes: AttributeAggregator) -> dict:
age_counts = {"minor": 0, "adult": 0, "senior": 0}
gender_counts = {"male": 0, "female": 0}
unknown_attributes = 0
for track_id in counter.counted_ids:
summary = attributes.summarize_track(track_id)
if summary is None:
unknown_attributes += 1
continue
age_counts[summary.age_bucket] += 1
gender_counts[summary.gender] += 1
return {
"total_people": counter.total_people,
"age_counts": age_counts,
"gender_counts": gender_counts,
"unknown_attributes": unknown_attributes,
}
def _print_rtsp_heartbeat(
self,
process_started_at: datetime,
window_index: int,
frame_index: int,
counter: LineCrossCounter,
attributes: AttributeAggregator,
last_processed_wall_time: datetime | None,
) -> None:
stats = self._build_live_stats(counter, attributes)
runtime_seconds = int((datetime.now().astimezone() - process_started_at).total_seconds())
last_processed = (
last_processed_wall_time.isoformat(timespec="seconds")
if last_processed_wall_time is not None
else None
)
print(
"heartbeat "
f"runtime_seconds={runtime_seconds} "
f"window_index={window_index} "
f"window_frames={frame_index} "
f"total_people={stats['total_people']} "
f"minor={stats['age_counts']['minor']} "
f"adult={stats['age_counts']['adult']} "
f"senior={stats['age_counts']['senior']} "
f"male={stats['gender_counts']['male']} "
f"female={stats['gender_counts']['female']} "
f"unknown_attributes={stats['unknown_attributes']} "
f"last_processed_at={last_processed}",
flush=True,
)
def _collect_track_summaries(
self,
counter: LineCrossCounter | None,
attributes: AttributeAggregator,
) -> tuple[dict[str, int], dict[str, int], int, list[dict]]:
age_counts = {"minor": 0, "adult": 0, "senior": 0}
gender_counts = {"male": 0, "female": 0}
unknown_attributes = 0
track_summaries: list[dict] = []
if counter is None:
return age_counts, gender_counts, unknown_attributes, track_summaries
for event in counter.crossings:
summary = attributes.summarize_track(event.track_id)
if summary is None:
unknown_attributes += 1
track_summaries.append(
{
"track_id": event.track_id,
"direction": event.direction,
"age": None,
"age_bucket": None,
"gender": None,
"samples_used": 0,
}
)
continue
age_counts[summary.age_bucket] += 1
gender_counts[summary.gender] += 1
track_summaries.append(
{
"track_id": summary.track_id,
"direction": event.direction,
"age": summary.age,
"age_bucket": summary.age_bucket,
"gender": summary.gender,
"samples_used": summary.samples_used,
}
)
return age_counts, gender_counts, unknown_attributes, track_summaries
def _build_rtsp_summary(
self,
source: str,
window_index: int,
window_start: datetime,
window_end: datetime,
counter: LineCrossCounter | None,
attributes: AttributeAggregator,
) -> dict:
age_counts, gender_counts, unknown_attributes, track_summaries = self._collect_track_summaries(
counter,
attributes,
)
total_people = 0 if counter is None else counter.total_people
return {
"source_type": "rtsp",
"source": source,
"window_index": window_index,
"window_start": window_start.isoformat(),
"window_end": window_end.isoformat(),
"window_duration_seconds": int((window_end - window_start).total_seconds()),
"config_path": str(self.config.config_path) if self.config.config_path else None,
"line": {
"coordinates": list(self.config.counting.line),
"mode": self.config.counting.line_mode,
},
"total_people": total_people,
"age_counts": age_counts,
"gender_counts": gender_counts,
"unknown_attributes": unknown_attributes,
"tracks": track_summaries,
}
def _finalize_summary(
self,
video_path: Path,
counter: LineCrossCounter,
attributes: AttributeAggregator,
json_path: Path,
) -> dict:
age_counts, gender_counts, unknown_attributes, track_summaries = self._collect_track_summaries(
counter,
attributes,
)
payload = {
"video_name": video_path.name,
"video_path": str(video_path),
"config_path": str(self.config.config_path) if self.config.config_path else None,
"line": {
"coordinates": list(self.config.counting.line),
"mode": self.config.counting.line_mode,
},
"total_people": counter.total_people,
"age_counts": age_counts,
"gender_counts": gender_counts,
"unknown_attributes": unknown_attributes,
"tracks": track_summaries,
}
if self.config.output.save_json:
write_json(json_path, payload)
payload["json_path"] = str(json_path)
return payload

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Any
from .models import TrackObservation
def extract_person_tracks(result: Any) -> list[TrackObservation]:
boxes = getattr(result, "boxes", None)
if boxes is None:
return []
if getattr(boxes, "id", None) is None:
return []
xyxy = boxes.xyxy.int().cpu().tolist()
ids = boxes.id.int().cpu().tolist()
confs = boxes.conf.cpu().tolist()
classes = boxes.cls.int().cpu().tolist()
observations: list[TrackObservation] = []
for bbox, track_id, confidence, class_id in zip(xyxy, ids, confs, classes, strict=False):
if int(class_id) != 0:
continue
x1, y1, x2, y2 = bbox
center_x = (x1 + x2) / 2.0
center_y = (y1 + y2) / 2.0
observations.append(
TrackObservation(
track_id=int(track_id),
bbox=(int(x1), int(y1), int(x2), int(y2)),
confidence=float(confidence),
center=(center_x, center_y),
)
)
return observations

View File

@@ -0,0 +1,164 @@
from __future__ import annotations
import json
from pathlib import Path
import yaml
from src.people_flow.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(
"runtime:\n"
" rtsp_url: rtsp://before-update\n"
" output_dir: outputs\n"
"rtsp:\n"
" output_subdir: rtsp_stream\n",
encoding="utf-8",
)
rtsp_dir = project_root / "outputs" / "rtsp_stream"
windows_dir = rtsp_dir / "windows"
windows_dir.mkdir(parents=True, exist_ok=True)
latest_payload = {
"source_type": "rtsp",
"window_start": "2026-04-16T09:30:00+08:00",
"window_end": "2026-04-16T10:00:00+08:00",
"total_people": 7,
"age_counts": {"minor": 1, "adult": 5, "senior": 1},
"gender_counts": {"male": 4, "female": 3},
"unknown_attributes": 2,
"tracks": [
{"track_id": 1, "direction": "in"},
{"track_id": 2, "direction": "out"},
{"track_id": 3, "direction": "in"},
],
}
(rtsp_dir / "latest.json").write_text(
json.dumps(latest_payload),
encoding="utf-8",
)
(windows_dir / "stats_2026-04-16_09-00-00.json").write_text(
json.dumps(
{
"window_start": "2026-04-16T09:00:00+08:00",
"window_end": "2026-04-16T09:30:00+08:00",
"total_people": 5,
"age_counts": {"minor": 0, "adult": 4, "senior": 1},
"gender_counts": {"male": 2, "female": 3},
"unknown_attributes": 1,
}
),
encoding="utf-8",
)
(windows_dir / "stats_2026-04-16_09-30-00.json").write_text(
json.dumps(latest_payload),
encoding="utf-8",
)
(project_root / "outputs" / "rtsp_run.log").write_text("rtsp 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"] == "people_flow_project"
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["config_path"] == str(config_path)
assert response.json["runtime"]["rtsp_url"] == "rtsp://before-update"
assert response.json["rtsp"]["output_subdir"] == "rtsp_stream"
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["runtime"]["rtsp_url"] == "rtsp://after-update"
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert saved["runtime"]["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"] == "people_flow_project"
assert response.json["last_result_time"] == "2026-04-16T10:00:00+08:00"
assert response.json["metrics"]["total_people"] == 7
assert response.json["metrics"]["direction_counts"] == {"in": 2, "out": 1}
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 response.json["items"][0]["window_end"] == "2026-04-16T10:00:00+08:00"
assert response.json["items"][0]["total_people"] == 7
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"]} == {
"outputs/rtsp_run.log",
"outputs/rtsp_stream/latest.json",
"outputs/rtsp_stream/windows/stats_2026-04-16_09-00-00.json",
"outputs/rtsp_stream/windows/stats_2026-04-16_09-30-00.json",
}
def test_get_manage_files_preview(tmp_path: Path):
client, _ = build_client(tmp_path)
response = client.get(
"/api/manage/files/preview?path=outputs/rtsp_stream/latest.json&lines=1"
)
assert response.status_code == 200
assert response.json["path"] == "outputs/rtsp_stream/latest.json"
assert response.json["count"] == 1
assert "total_people" in response.json["lines"][0]
def test_get_manage_files_download(tmp_path: Path):
client, _ = build_client(tmp_path)
response = client.get("/api/manage/files/download?path=outputs/rtsp_run.log")
assert response.status_code == 200
assert response.data == b"rtsp ok\n"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

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

View 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"]

View 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/` 是否有有效样本

View 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/`

View File

@@ -0,0 +1 @@
"""Store dwell alert application package."""

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

View 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())

View 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())

View File

@@ -0,0 +1 @@
"""Runtime modules for the store dwell alert service."""

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

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

View 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

View 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")

View 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

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

View 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

View 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

View 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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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

View File

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

View 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

View File

@@ -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 users required behavior without adding unnecessary operational complexity.

View File

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

View File

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

View 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"
```

View File

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

View 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

View File

@@ -0,0 +1,5 @@
Flask>=3.1
PyYAML>=6.0
opencv-python-headless>=4.10
requests>=2.32
ultralytics>=8.3

View 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"

View 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}"

View 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

View 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}"

View 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}"

View 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}" "$@"

View 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 "$@"

View 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()

View 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

View 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]

View 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

View 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"]

View 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"

View 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"

View 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}

View 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"

View 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"

Some files were not shown because too many files have changed in this diff Show More