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

View File

@@ -0,0 +1,37 @@
from app.modules.stream_reader import StreamHealth
from app.modules.stream_reader import RTSPFrameReader
def test_stream_health_marks_disconnect_after_failures():
health = StreamHealth(max_failures=3)
health.record_failure()
health.record_failure()
health.record_failure()
assert health.is_disconnected is True
def test_stream_health_reset_clears_disconnect():
health = StreamHealth(max_failures=2, failures=2)
health.reset()
assert health.is_disconnected is False
def test_rtsp_frame_reader_uses_capture_factory():
class FakeCapture:
def __init__(self, _url):
self.frames = [(True, "frame-1")]
def read(self):
return self.frames.pop(0)
def release(self):
return None
reader = RTSPFrameReader(
rtsp_url="rtsp://example",
sample_fps=2.0,
reconnect_backoff_seconds=5.0,
capture_factory=FakeCapture,
)
assert reader.read() == "frame-1"