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