feat: improve ota packaging and people-flow runtime
This commit is contained in:
35
README.md
35
README.md
@@ -82,6 +82,41 @@ docker compose --env-file managed-portal.env up -d --build
|
||||
http://<服务器IP>:13000/
|
||||
```
|
||||
|
||||
## OTA 发布包
|
||||
|
||||
OTA 安装脚本默认从发布目录下载一个主 ZIP,再用 `deploy/docker-compose.ota-release.yml` 启动服务。主 ZIP 现在只需要包含安装端真正依赖的内容:
|
||||
|
||||
- `deploy/docker-compose.ota-release.yml`
|
||||
- `deploy/Dockerfile.runtime-overlay`
|
||||
- `deploy/managed-portal.release.env`
|
||||
- `release-manifest.env`
|
||||
- `managed/store_dwell_alert/config/`
|
||||
- `managed/people_flow_project/config/`
|
||||
|
||||
仓库提供了一个最小打包脚本:`deploy/package-managed-portal-ota.sh`。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
RELEASE_VERSION=20260518-7b32b21-11 \
|
||||
RELEASE_MANIFEST_SOURCE=/path/to/release-manifest.env \
|
||||
RELEASE_ENV_SOURCE=deploy/managed-portal.10.8.0.12.env \
|
||||
sh deploy/package-managed-portal-ota.sh
|
||||
```
|
||||
|
||||
默认情况下,主 ZIP 不包含 `managed/people_flow_project/weights/`。OTA installer 会优先复用主机上的共享权重目录,避免每次只改安装脚本或配置时都重复打包、上传大体积权重。
|
||||
|
||||
只有两种场景才建议把权重重新打进 ZIP:
|
||||
|
||||
- 首次在一台没有预置权重的新主机上安装
|
||||
- `people_flow_project` 的权重文件本身发生变更
|
||||
|
||||
这两种场景可以临时打开:
|
||||
|
||||
```bash
|
||||
INCLUDE_WEIGHTS=1 sh deploy/package-managed-portal-ota.sh
|
||||
```
|
||||
|
||||
## 模型权重
|
||||
|
||||
子服务镜像构建前需要以下权重文件:
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
RELEASE_VERSION="${RELEASE_VERSION:-20260513-330373b-11}"
|
||||
RELEASE_VERSION="${RELEASE_VERSION:-20260518-7b32b21-11}"
|
||||
BASE_URL="${BASE_URL:-http://10.8.0.1/ai_deploy}"
|
||||
BUNDLE_NAME="${BUNDLE_NAME:-managed-portal-${RELEASE_VERSION}.zip}"
|
||||
INSTALL_ROOT="${INSTALL_ROOT:-/opt/managed-portal-releases}"
|
||||
TARGET_DIR="${TARGET_DIR:-${INSTALL_ROOT}/managed-portal-${RELEASE_VERSION}}"
|
||||
SHARED_ROOT="${SHARED_ROOT:-${INSTALL_ROOT}/shared}"
|
||||
DEFAULT_PEOPLE_FLOW_WEIGHTS_DIR="${SHARED_ROOT}/people_flow_project/weights"
|
||||
|
||||
require_command() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
@@ -14,6 +16,23 @@ require_command() {
|
||||
fi
|
||||
}
|
||||
|
||||
pull_or_use_local() {
|
||||
image="$1"
|
||||
|
||||
if docker pull "$image"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "docker pull failed for $image, checking local image cache" >&2
|
||||
if docker image inspect "$image" >/dev/null 2>&1; then
|
||||
echo "using existing local image $image" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "image unavailable locally after pull failure: $image" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
run_compose() {
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
docker-compose "$@"
|
||||
@@ -22,6 +41,19 @@ run_compose() {
|
||||
docker compose "$@"
|
||||
}
|
||||
|
||||
dir_has_files() {
|
||||
directory="$1"
|
||||
[ -d "$directory" ] && [ -n "$(find "$directory" -mindepth 1 -print -quit 2>/dev/null)" ]
|
||||
}
|
||||
|
||||
copy_dir_contents() {
|
||||
source_dir="$1"
|
||||
target_dir="$2"
|
||||
|
||||
mkdir -p "$target_dir"
|
||||
cp -R "$source_dir"/. "$target_dir"/
|
||||
}
|
||||
|
||||
download_bundle() {
|
||||
tmp_dir="$1"
|
||||
bundle_zip="$tmp_dir/$BUNDLE_NAME"
|
||||
@@ -58,6 +90,66 @@ build_overlay_image() {
|
||||
printf '%s\n' "$overlay_image"
|
||||
}
|
||||
|
||||
clear_stale_runtime_state() {
|
||||
people_flow_status="$TARGET_DIR/managed/people_flow_project/outputs/rtsp_stream/worker_status.json"
|
||||
|
||||
if [ -f "$people_flow_status" ]; then
|
||||
rm -f "$people_flow_status"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_runtime_directories() {
|
||||
mkdir -p \
|
||||
"$TARGET_DIR/managed/store_dwell_alert/config" \
|
||||
"$TARGET_DIR/managed/store_dwell_alert/data" \
|
||||
"$TARGET_DIR/managed/people_flow_project/config" \
|
||||
"$TARGET_DIR/managed/people_flow_project/outputs/rtsp_stream"
|
||||
}
|
||||
|
||||
find_existing_people_flow_weights() {
|
||||
if dir_has_files "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"; then
|
||||
printf '%s\n' "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for candidate in "$INSTALL_ROOT"/managed-portal-*/managed/people_flow_project/weights; do
|
||||
if [ "$candidate" = "$TARGET_DIR/managed/people_flow_project/weights" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if dir_has_files "$candidate"; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
prepare_people_flow_weights() {
|
||||
bundle_weights_dir="$TARGET_DIR/managed/people_flow_project/weights"
|
||||
source_weights_dir=""
|
||||
|
||||
if dir_has_files "$bundle_weights_dir"; then
|
||||
source_weights_dir="$bundle_weights_dir"
|
||||
echo "seeding shared people-flow weights from bundle" >&2
|
||||
elif source_weights_dir="$(find_existing_people_flow_weights 2>/dev/null)"; then
|
||||
echo "reusing existing people-flow weights from $source_weights_dir" >&2
|
||||
else
|
||||
echo "people-flow weights not found; seed $MANAGED_PEOPLE_FLOW_WEIGHTS_DIR or include managed/people_flow_project/weights in the release zip" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$source_weights_dir" != "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" ]; then
|
||||
rm -rf "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"
|
||||
copy_dir_contents "$source_weights_dir" "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"
|
||||
fi
|
||||
|
||||
rm -rf "$bundle_weights_dir"
|
||||
mkdir -p "$(dirname "$bundle_weights_dir")"
|
||||
ln -s "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" "$bundle_weights_dir"
|
||||
}
|
||||
|
||||
require_command curl
|
||||
require_command unzip
|
||||
require_command docker
|
||||
@@ -79,11 +171,18 @@ set -a
|
||||
. "$TARGET_DIR/release-manifest.env"
|
||||
set +a
|
||||
|
||||
MANAGED_PEOPLE_FLOW_WEIGHTS_DIR="${MANAGED_PEOPLE_FLOW_WEIGHTS_DIR:-$DEFAULT_PEOPLE_FLOW_WEIGHTS_DIR}"
|
||||
|
||||
ensure_runtime_directories
|
||||
prepare_people_flow_weights
|
||||
|
||||
clear_stale_runtime_state
|
||||
|
||||
echo "pulling release images"
|
||||
docker pull "$MANAGED_PORTAL_IMAGE"
|
||||
docker pull "$MANAGED_PORTAL_WEB_IMAGE"
|
||||
docker pull "$PEOPLE_FLOW_PROJECT_IMAGE"
|
||||
docker pull "$STORE_DWELL_ALERT_IMAGE"
|
||||
pull_or_use_local "$MANAGED_PORTAL_IMAGE"
|
||||
pull_or_use_local "$MANAGED_PORTAL_WEB_IMAGE"
|
||||
pull_or_use_local "$PEOPLE_FLOW_PROJECT_IMAGE"
|
||||
pull_or_use_local "$STORE_DWELL_ALERT_IMAGE"
|
||||
|
||||
PEOPLE_FLOW_PROJECT_IMAGE="$(build_overlay_image \
|
||||
people-flow-project \
|
||||
@@ -101,6 +200,7 @@ export MANAGED_PORTAL_IMAGE
|
||||
export MANAGED_PORTAL_WEB_IMAGE
|
||||
export PEOPLE_FLOW_PROJECT_IMAGE
|
||||
export STORE_DWELL_ALERT_IMAGE
|
||||
export MANAGED_PEOPLE_FLOW_WEIGHTS_DIR
|
||||
|
||||
cd "$TARGET_DIR/deploy"
|
||||
run_compose \
|
||||
|
||||
98
deploy/package-managed-portal-ota.sh
Normal file
98
deploy/package-managed-portal-ota.sh
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
RELEASE_VERSION="${RELEASE_VERSION:?RELEASE_VERSION is required}"
|
||||
RELEASE_MANIFEST_SOURCE="${RELEASE_MANIFEST_SOURCE:?RELEASE_MANIFEST_SOURCE is required}"
|
||||
RELEASE_ENV_SOURCE="${RELEASE_ENV_SOURCE:-$SCRIPT_DIR/managed-portal.env}"
|
||||
OUTPUT_DIR="${OUTPUT_DIR:-$REPO_ROOT/dist/ota}"
|
||||
STAGE_DIR="${OUTPUT_DIR}/managed-portal-${RELEASE_VERSION}"
|
||||
BUNDLE_PATH="${OUTPUT_DIR}/managed-portal-${RELEASE_VERSION}.zip"
|
||||
INSTALLER_PATH="${OUTPUT_DIR}/install-managed-portal-${RELEASE_VERSION}.sh"
|
||||
INCLUDE_WEIGHTS="${INCLUDE_WEIGHTS:-0}"
|
||||
PEOPLE_FLOW_WEIGHTS_SOURCE="${PEOPLE_FLOW_WEIGHTS_SOURCE:-$REPO_ROOT/managed/people_flow_project/weights}"
|
||||
|
||||
require_path() {
|
||||
target="$1"
|
||||
|
||||
if [ ! -e "$target" ]; then
|
||||
echo "missing required path: $target" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
dir_has_files() {
|
||||
directory="$1"
|
||||
[ -d "$directory" ] && [ -n "$(find "$directory" -mindepth 1 -print -quit 2>/dev/null)" ]
|
||||
}
|
||||
|
||||
copy_dir() {
|
||||
source_dir="$1"
|
||||
target_dir="$2"
|
||||
|
||||
mkdir -p "$(dirname "$target_dir")"
|
||||
cp -R "$source_dir" "$target_dir"
|
||||
}
|
||||
|
||||
require_path "$RELEASE_MANIFEST_SOURCE"
|
||||
require_path "$RELEASE_ENV_SOURCE"
|
||||
require_path "$SCRIPT_DIR/docker-compose.ota-release.yml"
|
||||
require_path "$SCRIPT_DIR/Dockerfile.runtime-overlay"
|
||||
require_path "$SCRIPT_DIR/install-managed-portal-ota.sh"
|
||||
require_path "$REPO_ROOT/managed/store_dwell_alert/config"
|
||||
require_path "$REPO_ROOT/managed/people_flow_project/config"
|
||||
|
||||
rm -rf "$STAGE_DIR"
|
||||
mkdir -p "$STAGE_DIR/deploy" "$OUTPUT_DIR"
|
||||
|
||||
cp "$SCRIPT_DIR/docker-compose.ota-release.yml" "$STAGE_DIR/deploy/docker-compose.ota-release.yml"
|
||||
cp "$SCRIPT_DIR/Dockerfile.runtime-overlay" "$STAGE_DIR/deploy/Dockerfile.runtime-overlay"
|
||||
cp "$RELEASE_ENV_SOURCE" "$STAGE_DIR/deploy/managed-portal.release.env"
|
||||
cp "$RELEASE_MANIFEST_SOURCE" "$STAGE_DIR/release-manifest.env"
|
||||
|
||||
copy_dir "$REPO_ROOT/managed/store_dwell_alert/config" "$STAGE_DIR/managed/store_dwell_alert/config"
|
||||
copy_dir "$REPO_ROOT/managed/people_flow_project/config" "$STAGE_DIR/managed/people_flow_project/config"
|
||||
|
||||
if [ "$INCLUDE_WEIGHTS" = "1" ]; then
|
||||
if ! dir_has_files "$PEOPLE_FLOW_WEIGHTS_SOURCE"; then
|
||||
echo "people-flow weights requested but missing under $PEOPLE_FLOW_WEIGHTS_SOURCE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
copy_dir "$PEOPLE_FLOW_WEIGHTS_SOURCE" "$STAGE_DIR/managed/people_flow_project/weights"
|
||||
fi
|
||||
|
||||
python3 - "$OUTPUT_DIR" "$STAGE_DIR" "$BUNDLE_PATH" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
output_dir = Path(sys.argv[1])
|
||||
stage_dir = Path(sys.argv[2])
|
||||
bundle_path = Path(sys.argv[3])
|
||||
|
||||
if bundle_path.exists():
|
||||
bundle_path.unlink()
|
||||
|
||||
with zipfile.ZipFile(bundle_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(stage_dir.rglob("*")):
|
||||
arcname = path.relative_to(output_dir)
|
||||
if path.is_dir():
|
||||
if not any(path.iterdir()):
|
||||
archive.writestr(f"{arcname.as_posix().rstrip('/')}/", "")
|
||||
continue
|
||||
archive.write(path, arcname.as_posix())
|
||||
PY
|
||||
|
||||
cp "$SCRIPT_DIR/install-managed-portal-ota.sh" "$INSTALLER_PATH"
|
||||
chmod +x "$INSTALLER_PATH"
|
||||
|
||||
echo "OTA bundle created: $BUNDLE_PATH"
|
||||
echo "Versioned installer created: $INSTALLER_PATH"
|
||||
if [ "$INCLUDE_WEIGHTS" = "1" ]; then
|
||||
echo "Bundle includes managed/people_flow_project/weights"
|
||||
else
|
||||
echo "Bundle excludes managed/people_flow_project/weights; the installer will reuse the shared host weights directory if available"
|
||||
fi
|
||||
@@ -31,7 +31,7 @@ class CountingConfig:
|
||||
|
||||
@dataclass
|
||||
class AttributeConfig:
|
||||
enabled: bool = True
|
||||
enabled: bool = False
|
||||
sample_every_n_frames: int = 12
|
||||
max_samples_per_track: int = 5
|
||||
min_person_box_width: int = 80
|
||||
|
||||
@@ -27,6 +27,7 @@ from .webhook import dispatch_json_event
|
||||
from .worker_status import write_worker_status
|
||||
|
||||
SUPPORTED_EXTENSIONS = {".mp4", ".mov", ".mkv", ".avi"}
|
||||
LIVE_SUMMARY_INTERVAL_SECONDS = 15.0
|
||||
|
||||
|
||||
def discover_videos(root: Path, pattern: str = "*.mp4") -> list[Path]:
|
||||
@@ -40,10 +41,40 @@ def discover_videos(root: Path, pattern: str = "*.mp4") -> list[Path]:
|
||||
return sorted(videos)
|
||||
|
||||
|
||||
def resolve_inference_device(requested_device: str | None) -> str:
|
||||
device = (requested_device or "").strip()
|
||||
if not device:
|
||||
return "cpu"
|
||||
|
||||
normalized = device.lower()
|
||||
if normalized == "cpu":
|
||||
return "cpu"
|
||||
|
||||
if normalized.startswith("cuda") or normalized.replace(",", "").isdigit():
|
||||
try:
|
||||
import torch
|
||||
except ImportError:
|
||||
print(
|
||||
f"Requested inference device {device!r} but torch is unavailable; falling back to cpu.",
|
||||
flush=True,
|
||||
)
|
||||
return "cpu"
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
print(
|
||||
f"Requested inference device {device!r} but torch.cuda.is_available() is False; falling back to cpu.",
|
||||
flush=True,
|
||||
)
|
||||
return "cpu"
|
||||
|
||||
return device
|
||||
|
||||
|
||||
class PeopleFlowPipeline:
|
||||
def __init__(self, config: AppConfig, output_root: Path) -> None:
|
||||
self.config = config
|
||||
self.output_root = ensure_dir(output_root)
|
||||
self.inference_device = resolve_inference_device(self.config.yolo.device)
|
||||
self.model = self._load_model()
|
||||
|
||||
def _load_model(self) -> Any:
|
||||
@@ -174,6 +205,7 @@ class PeopleFlowPipeline:
|
||||
last_processed_at = 0.0
|
||||
last_processed_wall_time: datetime | None = None
|
||||
next_heartbeat_at = time.monotonic() + 60.0
|
||||
next_live_summary_at = time.monotonic()
|
||||
frame_index = 0
|
||||
capture = None
|
||||
pixel_line = None
|
||||
@@ -254,6 +286,7 @@ class PeopleFlowPipeline:
|
||||
window_index += 1
|
||||
window_start = window_end
|
||||
window_end = window_start + timedelta(seconds=window_seconds)
|
||||
next_live_summary_at = time.monotonic()
|
||||
if counter is not None:
|
||||
counter.reset()
|
||||
if queue_tracker is not None:
|
||||
@@ -329,6 +362,18 @@ class PeopleFlowPipeline:
|
||||
next_heartbeat_at = current_time + 60.0
|
||||
last_processed_wall_time = now
|
||||
frame_index += 1
|
||||
if current_time >= next_live_summary_at:
|
||||
self._write_live_rtsp_summary(
|
||||
latest_path=rtsp_paths["latest_json"],
|
||||
source=source,
|
||||
window_index=window_index,
|
||||
window_start=window_start,
|
||||
observed_at=now,
|
||||
counter=counter,
|
||||
attributes=attributes,
|
||||
queue_tracker=queue_tracker,
|
||||
)
|
||||
next_live_summary_at = current_time + LIVE_SUMMARY_INTERVAL_SECONDS
|
||||
update_status("processed_frame", force=True)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -350,7 +395,7 @@ class PeopleFlowPipeline:
|
||||
conf=self.config.yolo.conf,
|
||||
iou=self.config.yolo.iou,
|
||||
imgsz=self.config.yolo.imgsz,
|
||||
device=self.config.yolo.device,
|
||||
device=self.inference_device,
|
||||
verbose=False,
|
||||
classes=[0],
|
||||
)
|
||||
@@ -483,6 +528,8 @@ class PeopleFlowPipeline:
|
||||
counter: LineCrossCounter | None,
|
||||
attributes: AttributeAggregator,
|
||||
queue_tracker: QueueWindowTracker | None,
|
||||
*,
|
||||
commit_queue_level: bool = True,
|
||||
) -> dict:
|
||||
age_counts, gender_counts, unknown_attributes, track_summaries = (
|
||||
self._collect_track_summaries(
|
||||
@@ -492,7 +539,11 @@ class PeopleFlowPipeline:
|
||||
)
|
||||
total_people = 0 if counter is None else counter.total_people
|
||||
queue_metrics = (
|
||||
queue_tracker.build_queue_metrics(window_start, window_end)
|
||||
queue_tracker.build_queue_metrics(
|
||||
window_start,
|
||||
window_end,
|
||||
commit_queue_level=commit_queue_level,
|
||||
)
|
||||
if queue_tracker is not None and self.config.queue.enabled
|
||||
else {
|
||||
"queue_time_threshold_seconds": self.config.queue.queue_time_threshold_seconds,
|
||||
@@ -529,6 +580,31 @@ class PeopleFlowPipeline:
|
||||
"queue_metrics": queue_metrics,
|
||||
}
|
||||
|
||||
def _write_live_rtsp_summary(
|
||||
self,
|
||||
latest_path: Path,
|
||||
source: str,
|
||||
window_index: int,
|
||||
window_start: datetime,
|
||||
observed_at: datetime,
|
||||
counter: LineCrossCounter | None,
|
||||
attributes: AttributeAggregator,
|
||||
queue_tracker: QueueWindowTracker | None,
|
||||
) -> None:
|
||||
payload = self._build_rtsp_summary(
|
||||
source=source,
|
||||
window_index=window_index,
|
||||
window_start=window_start,
|
||||
window_end=observed_at,
|
||||
counter=counter,
|
||||
attributes=attributes,
|
||||
queue_tracker=queue_tracker,
|
||||
commit_queue_level=False,
|
||||
)
|
||||
payload["event"] = "rtsp_live_summary"
|
||||
payload["window_status"] = "in_progress"
|
||||
write_json(latest_path, payload)
|
||||
|
||||
def _finalize_summary(
|
||||
self,
|
||||
video_path: Path,
|
||||
|
||||
104
managed/people_flow_project/tests/test_pipeline.py
Normal file
104
managed/people_flow_project/tests/test_pipeline.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.people_flow.models import AttributeConfig
|
||||
from src.people_flow.pipeline import PeopleFlowPipeline, resolve_inference_device
|
||||
|
||||
|
||||
def test_resolve_inference_device_keeps_cpu():
|
||||
assert resolve_inference_device("cpu") == "cpu"
|
||||
|
||||
|
||||
def test_resolve_inference_device_falls_back_when_cuda_unavailable(monkeypatch):
|
||||
fake_torch = SimpleNamespace(
|
||||
cuda=SimpleNamespace(is_available=lambda: False),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
||||
|
||||
assert resolve_inference_device("cuda:0") == "cpu"
|
||||
|
||||
|
||||
def test_resolve_inference_device_keeps_cuda_when_available(monkeypatch):
|
||||
fake_torch = SimpleNamespace(
|
||||
cuda=SimpleNamespace(is_available=lambda: True),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
||||
|
||||
assert resolve_inference_device("cuda:0") == "cuda:0"
|
||||
|
||||
|
||||
def test_attribute_config_defaults_to_disabled():
|
||||
assert AttributeConfig().enabled is False
|
||||
|
||||
|
||||
def test_write_live_rtsp_summary_updates_latest_json(tmp_path: Path):
|
||||
pipeline = PeopleFlowPipeline.__new__(PeopleFlowPipeline)
|
||||
observed_at = datetime.fromisoformat("2026-05-18T10:45:18+08:00")
|
||||
window_start = datetime.fromisoformat("2026-05-18T10:30:00+08:00")
|
||||
|
||||
def fake_build_rtsp_summary(**kwargs):
|
||||
return {
|
||||
"event": "half_hour_report",
|
||||
"window_start": kwargs["window_start"].isoformat(),
|
||||
"window_end": kwargs["window_end"].isoformat(),
|
||||
"total_people": 4,
|
||||
"queue_metrics": {"queue_level": "normal"},
|
||||
}
|
||||
|
||||
pipeline._build_rtsp_summary = fake_build_rtsp_summary # type: ignore[method-assign]
|
||||
latest_path = tmp_path / "latest.json"
|
||||
|
||||
pipeline._write_live_rtsp_summary(
|
||||
latest_path=latest_path,
|
||||
source="rtsp://example",
|
||||
window_index=0,
|
||||
window_start=window_start,
|
||||
observed_at=observed_at,
|
||||
counter=None,
|
||||
attributes=SimpleNamespace(),
|
||||
queue_tracker=None,
|
||||
)
|
||||
|
||||
payload = json.loads(latest_path.read_text(encoding="utf-8"))
|
||||
assert payload["event"] == "rtsp_live_summary"
|
||||
assert payload["window_status"] == "in_progress"
|
||||
assert payload["window_start"] == window_start.isoformat()
|
||||
assert payload["window_end"] == observed_at.isoformat()
|
||||
|
||||
|
||||
def test_write_live_rtsp_summary_does_not_commit_queue_level(tmp_path: Path):
|
||||
pipeline = PeopleFlowPipeline.__new__(PeopleFlowPipeline)
|
||||
observed_at = datetime.fromisoformat("2026-05-18T10:45:18+08:00")
|
||||
window_start = datetime.fromisoformat("2026-05-18T10:30:00+08:00")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_build_rtsp_summary(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"event": "half_hour_report",
|
||||
"window_start": kwargs["window_start"].isoformat(),
|
||||
"window_end": kwargs["window_end"].isoformat(),
|
||||
"total_people": 4,
|
||||
"queue_metrics": {"queue_level": "normal"},
|
||||
}
|
||||
|
||||
pipeline._build_rtsp_summary = fake_build_rtsp_summary # type: ignore[method-assign]
|
||||
latest_path = tmp_path / "latest.json"
|
||||
|
||||
pipeline._write_live_rtsp_summary(
|
||||
latest_path=latest_path,
|
||||
source="rtsp://example",
|
||||
window_index=0,
|
||||
window_start=window_start,
|
||||
observed_at=observed_at,
|
||||
counter=None,
|
||||
attributes=SimpleNamespace(),
|
||||
queue_tracker=SimpleNamespace(),
|
||||
)
|
||||
|
||||
assert captured["commit_queue_level"] is False
|
||||
@@ -17,3 +17,15 @@
|
||||
- Trigger: the user required deployment to use `docker compose` only and explicitly disallowed host environment changes.
|
||||
- Rule: for remote rollout tasks in this repo, prefer repository-contained `docker compose` changes and do not install packages, edit host configs, or mutate global environment state unless the user explicitly approves it.
|
||||
- Preventive action: when a deployment is blocked, first fix Dockerfiles, compose files, env files, and mounted paths inside the repo before considering any host-level workaround.
|
||||
|
||||
## 2026-05-15
|
||||
|
||||
- Trigger: the `.11` OTA bundle host did not have a `zip` executable, and the current Python containers no longer exposed the historical `lap` overlay paths.
|
||||
- Rule: OTA bundle publication must not assume host archive tools or historical runtime overlay paths are present.
|
||||
- Preventive action: when cutting a release, package the ZIP with Python stdlib if `zip` is unavailable, treat overlay extraction as optional unless the paths are verified live in containers, and validate the final archive contents before upload.
|
||||
|
||||
## 2026-05-18
|
||||
|
||||
- Trigger: the user clarified that OTA installer updates should not keep repackaging and uploading the whole repository tree or fixed `people_flow_project` weights.
|
||||
- Rule: managed-portal OTA releases should ship a minimal ZIP with deploy metadata and managed config only; `people_flow_project` weights should be reused from a stable host location unless the weights themselves changed or the host is new.
|
||||
- Preventive action: when preparing OTA artifacts, use the minimal packaging script, exclude `managed/people_flow_project/weights` by default, and only publish a weights-bearing bundle for first-time installs or actual weight updates.
|
||||
|
||||
@@ -2,33 +2,31 @@
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Verify the local managed config files already match the requested webhook, 30-minute window, 5-minute queue threshold, and `2-5 normal / >5 crowded` defaults.
|
||||
- [x] Restore any queue-level code or tests that still reflect the temporary `>= threshold` crowded behavior.
|
||||
- [x] Run targeted validation for the touched queue-analytics logic.
|
||||
- [x] Update `tasks/todo.md` review evidence and create a scoped git commit containing only the intended files.
|
||||
- [x] Classify the remaining modified and untracked files into non-config code changes to commit versus local config/artifact files to exclude.
|
||||
- [x] Run targeted validation for the code and script changes that will be committed.
|
||||
- [x] Create a scoped git commit containing the non-config code changes only.
|
||||
- [ ] Push the scoped commit to `origin/main`.
|
||||
|
||||
## Scope And Risks
|
||||
|
||||
- Scope: restore repository-local managed-service defaults so webhook delivery remains enabled, people-flow reporting flushes every 30 minutes, queue threshold is 5 minutes, fewer than 2 qualifying people means `人少`, `2-5` means `人数正常`, and more than `5` means `人多`, then commit the relevant changes.
|
||||
- Expected touch points: `managed/people_flow_project/src/people_flow/queue_analytics.py`, `managed/people_flow_project/tests/test_queue_analytics.py`, and verification of local config files under `managed/*/config/` plus any generated OTA test output that should remain aligned.
|
||||
- Risk: the worktree already contains unrelated modified and untracked files, so the commit must be scoped carefully to avoid pulling in unrelated work.
|
||||
- Risk: `store_dwell_alert` still has a separate queue-level implementation with the same `>` crowded comparison, but the user asked specifically to restore local config defaults plus "the code"; if we only revert people-flow logic, we should be explicit about any remaining asymmetry.
|
||||
- Scope: commit and push the remaining local non-config code changes in this repository while excluding local configuration files and generated artifacts.
|
||||
- Expected touch points: currently modified code/docs/scripts such as `README.md`, `deploy/install-managed-portal-ota.sh`, `deploy/package-managed-portal-ota.sh`, `managed/people_flow_project/src/people_flow/{models.py,pipeline.py}`, `managed/people_flow_project/tests/test_pipeline.py`, and `tasks/lessons.md`.
|
||||
- Risk: the worktree also contains local artifacts and config-adjacent outputs such as `test_output/`, `sim_workspace/`, `release-manifest.env`, `api_response.json`, and `output.txt`; these must not be swept into the commit by accident.
|
||||
- Risk: some modified files, especially docs and deployment scripts, are related to earlier OTA work and may need only narrow validation rather than full end-to-end execution.
|
||||
|
||||
## Validation Intent
|
||||
|
||||
- Prove the local config files already encode `window_seconds: 1800`, `queue_time_threshold_seconds: 300`, `crowded_count_threshold: 5`, `normal_count_threshold: 2`, and webhook URLs.
|
||||
- Prove the restored queue-level comparison classifies `over_threshold_count == 5` as `normal` and only `>5` as `crowded`.
|
||||
- Prove the selected commit excludes local config files and generated artifacts.
|
||||
- Run the narrowest meaningful checks for the people-flow pipeline/test changes and at least a syntax-level check for the OTA scripts.
|
||||
|
||||
## Review
|
||||
|
||||
- Status: completed.
|
||||
- Config verification: local repository configs already matched the requested defaults before code edits.
|
||||
- `managed/people_flow_project/config/local.yaml`: `webhook.url` points to managed queue, `rtsp.window_seconds=1800`, `queue_time_threshold_seconds=300`, `crowded_count_threshold=5`, `normal_count_threshold=2`.
|
||||
- `managed/store_dwell_alert/config/local.yaml`: `webhook.url` points to managed queue, `queue_time_threshold_seconds=300`, `crowded_count_threshold=5`, `normal_count_threshold=2`.
|
||||
- Generated OTA test configs under `test_output/managed-portal-test-ota/managed/.../config/local.yaml` were already aligned with the same values.
|
||||
- Code restoration: reverted `managed/people_flow_project/src/people_flow/queue_analytics.py` so `queue_level="crowded"` now requires `over_threshold_count > crowded_count_threshold`, which restores the requested `2-5 normal / >5 crowded` behavior.
|
||||
- Test restoration: updated `managed/people_flow_project/tests/test_queue_analytics.py` so the preview-state coverage remains intact while the boundary assertion now proves `over_threshold_count == crowded_count_threshold` stays `normal`.
|
||||
- Validation:
|
||||
- `PYTHONPATH=. pytest tests/test_queue_analytics.py` in `managed/people_flow_project`
|
||||
- Result: `3 passed in 0.03s`
|
||||
- Commit scope note: only `managed/people_flow_project/src/people_flow/queue_analytics.py`, `managed/people_flow_project/tests/test_queue_analytics.py`, and `tasks/todo.md` should be committed for this task because the worktree contains unrelated modified and untracked files.
|
||||
- Status: in progress.
|
||||
- Commit set selected:
|
||||
- Included: `README.md`, `deploy/install-managed-portal-ota.sh`, `deploy/package-managed-portal-ota.sh`, `managed/people_flow_project/src/people_flow/models.py`, `managed/people_flow_project/src/people_flow/pipeline.py`, `managed/people_flow_project/tests/test_pipeline.py`, `tasks/lessons.md`, `tasks/todo.md`.
|
||||
- Excluded as local config or generated artifacts: `test_output/`, `sim_workspace/`, `release-manifest.env`, `api_response.json`, `output.txt`.
|
||||
- Validation completed:
|
||||
- `PYTHONPATH=. pytest tests/test_pipeline.py tests/test_queue_analytics.py` in `managed/people_flow_project`
|
||||
- Result: `9 passed in 0.66s`
|
||||
- `sh -n deploy/install-managed-portal-ota.sh`
|
||||
- `sh -n deploy/package-managed-portal-ota.sh`
|
||||
|
||||
Reference in New Issue
Block a user