diff --git a/README.md b/README.md index 5da3664..8d1cf9a 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ RELEASE_ENV_SOURCE=deploy/managed-portal.10.8.0.12.env \ sh deploy/package-managed-portal-ota.sh ``` -默认情况下,主 ZIP 不包含 `managed/people_flow_project/weights/`。打包脚本会额外生成一个独立的 `people-flow-weights-.tar.gz`,用于 Ubuntu 新机器首次安装;已有机器升级时,OTA installer 会优先复用主机上的共享权重目录,避免每次只改安装脚本或配置时都重复打包、上传大体积权重。 +默认情况下,主 ZIP 不包含 `managed/people_flow_project/weights/`。打包脚本会额外生成一个独立的 `people-flow-weights-.tar.gz`,用于 Ubuntu 新机器首次安装;如果权重目录里只有 `yolo11n.pt` 缺失,还会使用小体积的 `people-flow-yolo11n-.tar.gz` 单独补齐,避免为了一个小模型重新下载 1GB+ 的完整权重包。已有机器升级时,OTA installer 会优先复用主机上的共享权重目录,避免每次只改安装脚本或配置时都重复打包、上传大体积权重。 只有两种场景才建议重新发布这个独立权重包: @@ -117,7 +117,7 @@ sh deploy/package-managed-portal-ota.sh INCLUDE_WEIGHTS=1 sh deploy/package-managed-portal-ota.sh ``` -Ubuntu 新机器首次安装时,如果系统没有 `unzip`,OTA installer 会自动用 `apt-get` 安装;然后在共享权重目录不存在时自动下载 `people-flow-weights-.tar.gz`。 +Ubuntu 新机器首次安装时,如果系统没有 `unzip`,OTA installer 会自动用 `apt-get` 安装;然后在共享权重目录不存在时自动下载 `people-flow-weights-.tar.gz`。如果 DeepFace 权重已经存在但缺少 `yolo11n.pt`,installer 只下载 `people-flow-yolo11n-.tar.gz`。 ## 模型权重 diff --git a/deploy/install-managed-portal-ota.sh b/deploy/install-managed-portal-ota.sh index 5a99cae..54a466a 100644 --- a/deploy/install-managed-portal-ota.sh +++ b/deploy/install-managed-portal-ota.sh @@ -5,6 +5,7 @@ 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}" WEIGHTS_ARCHIVE_NAME="${WEIGHTS_ARCHIVE_NAME:-people-flow-weights-${RELEASE_VERSION}.tar.gz}" +YOLO_ARCHIVE_NAME="${YOLO_ARCHIVE_NAME:-people-flow-yolo11n-${RELEASE_VERSION}.tar.gz}" 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}" @@ -92,6 +93,27 @@ dir_has_payload_files() { [ -d "$directory" ] && [ -n "$(find "$directory" -type f ! -name '.gitkeep' -print -quit 2>/dev/null)" ] } +people_flow_weights_complete() { + directory="$1" + + people_flow_deepface_weights_complete "$directory" && + people_flow_yolo_weight_present "$directory" +} + +people_flow_deepface_weights_complete() { + directory="$1" + + [ -f "$directory/deepface/age_model_weights.h5" ] && + [ -f "$directory/deepface/gender_model_weights.h5" ] && + [ -f "$directory/deepface/retinaface.h5" ] +} + +people_flow_yolo_weight_present() { + directory="$1" + + [ -f "$directory/yolo11n.pt" ] +} + copy_dir_contents() { source_dir="$1" target_dir="$2" @@ -120,6 +142,16 @@ download_weights_archive() { echo "$weights_archive" } +download_yolo_archive() { + tmp_dir="$1" + yolo_archive="$tmp_dir/$YOLO_ARCHIVE_NAME" + yolo_url="${BASE_URL%/}/$YOLO_ARCHIVE_NAME" + + echo "downloading $yolo_url" >&2 + curl -fL "$yolo_url" -o "$yolo_archive" + echo "$yolo_archive" +} + build_overlay_image() { overlay_name="$1" base_image="$2" @@ -163,7 +195,7 @@ ensure_runtime_directories() { } find_existing_people_flow_weights() { - if dir_has_files "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"; then + if people_flow_weights_complete "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"; then printf '%s\n' "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" return 0 fi @@ -173,7 +205,32 @@ find_existing_people_flow_weights() { continue fi - if dir_has_files "$candidate"; then + if people_flow_weights_complete "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +find_existing_people_flow_deepface_weights() { + if people_flow_deepface_weights_complete "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"; then + printf '%s\n' "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" + return 0 + fi + + if people_flow_deepface_weights_complete "$TARGET_DIR/managed/people_flow_project/weights"; then + printf '%s\n' "$TARGET_DIR/managed/people_flow_project/weights" + 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 people_flow_deepface_weights_complete "$candidate"; then printf '%s\n' "$candidate" return 0 fi @@ -190,28 +247,63 @@ extract_people_flow_weights_archive() { mkdir -p "$TARGET_DIR/managed" tar -xzf "$weights_archive" -C "$TARGET_DIR/managed" - if ! dir_has_payload_files "$bundle_weights_dir"; then - echo "downloaded weights archive did not populate $bundle_weights_dir" >&2 + if ! people_flow_weights_complete "$bundle_weights_dir"; then + echo "downloaded weights archive did not populate all required people-flow weights under $bundle_weights_dir" >&2 exit 1 fi printf '%s\n' "$bundle_weights_dir" } +ensure_people_flow_yolo_weight() { + tmp_dir="$1" + target_weights_dir="$2" + + if people_flow_yolo_weight_present "$target_weights_dir"; then + return 0 + fi + + yolo_extract_dir="$tmp_dir/yolo" + yolo_archive="$(download_yolo_archive "$tmp_dir")" + rm -rf "$yolo_extract_dir" + mkdir -p "$yolo_extract_dir" + tar -xzf "$yolo_archive" -C "$yolo_extract_dir" + + yolo_file="$yolo_extract_dir/people_flow_project/weights/yolo11n.pt" + if [ ! -f "$yolo_file" ]; then + echo "downloaded yolo archive did not populate people_flow_project/weights/yolo11n.pt" >&2 + exit 1 + fi + + mkdir -p "$target_weights_dir" + cp "$yolo_file" "$target_weights_dir/yolo11n.pt" +} + prepare_people_flow_weights() { tmp_dir="$1" bundle_weights_dir="$TARGET_DIR/managed/people_flow_project/weights" source_weights_dir="" + deepface_weights_dir="" - if dir_has_payload_files "$bundle_weights_dir"; then + if people_flow_weights_complete "$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 + elif deepface_weights_dir="$(find_existing_people_flow_deepface_weights 2>/dev/null)"; then + echo "reusing existing people-flow DeepFace weights from $deepface_weights_dir" >&2 + + if [ "$deepface_weights_dir" != "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" ]; then + rm -rf "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" + copy_dir_contents "$deepface_weights_dir" "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" + fi + + ensure_people_flow_yolo_weight "$tmp_dir" "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" + source_weights_dir="$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" elif source_weights_dir="$(extract_people_flow_weights_archive "$tmp_dir" 2>/dev/null)"; then echo "seeding shared people-flow weights from downloaded archive" >&2 else - echo "people-flow weights not found; seed $MANAGED_PEOPLE_FLOW_WEIGHTS_DIR, publish $WEIGHTS_ARCHIVE_NAME, or include managed/people_flow_project/weights in the release zip" >&2 + echo "people-flow weights not found; seed $MANAGED_PEOPLE_FLOW_WEIGHTS_DIR, publish $WEIGHTS_ARCHIVE_NAME and $YOLO_ARCHIVE_NAME, or include managed/people_flow_project/weights in the release zip" >&2 exit 1 fi @@ -220,6 +312,11 @@ prepare_people_flow_weights() { copy_dir_contents "$source_weights_dir" "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" fi + if ! people_flow_weights_complete "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR"; then + echo "people-flow weights are incomplete under $MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" >&2 + exit 1 + fi + rm -rf "$bundle_weights_dir" mkdir -p "$(dirname "$bundle_weights_dir")" ln -s "$MANAGED_PEOPLE_FLOW_WEIGHTS_DIR" "$bundle_weights_dir" diff --git a/deploy/package-managed-portal-ota.sh b/deploy/package-managed-portal-ota.sh index 2bff84a..9768095 100644 --- a/deploy/package-managed-portal-ota.sh +++ b/deploy/package-managed-portal-ota.sh @@ -11,10 +11,13 @@ 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" +LATEST_INSTALLER_PATH="${OUTPUT_DIR}/install-managed-portal-Latest.sh" +LOWERCASE_LATEST_INSTALLER_PATH="${OUTPUT_DIR}/install-managed-portal-latest.sh" INCLUDE_WEIGHTS="${INCLUDE_WEIGHTS:-0}" GENERATE_WEIGHTS_ARCHIVE="${GENERATE_WEIGHTS_ARCHIVE:-1}" PEOPLE_FLOW_WEIGHTS_SOURCE="${PEOPLE_FLOW_WEIGHTS_SOURCE:-$REPO_ROOT/managed/people_flow_project/weights}" WEIGHTS_ARCHIVE_PATH="${OUTPUT_DIR}/people-flow-weights-${RELEASE_VERSION}.tar.gz" +YOLO_ARCHIVE_PATH="${OUTPUT_DIR}/people-flow-yolo11n-${RELEASE_VERSION}.tar.gz" require_path() { target="$1" @@ -113,6 +116,9 @@ content = re.sub( target_path.write_text(content, encoding="utf-8") target_path.chmod(0o755) PY +cp "$INSTALLER_PATH" "$LATEST_INSTALLER_PATH" +cp "$INSTALLER_PATH" "$LOWERCASE_LATEST_INSTALLER_PATH" +chmod 755 "$LATEST_INSTALLER_PATH" "$LOWERCASE_LATEST_INSTALLER_PATH" if [ "$GENERATE_WEIGHTS_ARCHIVE" = "1" ] && dir_has_payload_files "$PEOPLE_FLOW_WEIGHTS_SOURCE"; then python3 - "$PEOPLE_FLOW_WEIGHTS_SOURCE" "$WEIGHTS_ARCHIVE_PATH" <<'PY' @@ -135,8 +141,26 @@ with tarfile.open(archive_path, "w:gz") as archive: PY fi +if [ "$GENERATE_WEIGHTS_ARCHIVE" = "1" ] && [ -f "$PEOPLE_FLOW_WEIGHTS_SOURCE/yolo11n.pt" ]; then + python3 - "$PEOPLE_FLOW_WEIGHTS_SOURCE/yolo11n.pt" "$YOLO_ARCHIVE_PATH" <<'PY' +from pathlib import Path +import sys +import tarfile + +source_path = Path(sys.argv[1]) +archive_path = Path(sys.argv[2]) + +if archive_path.exists(): + archive_path.unlink() + +with tarfile.open(archive_path, "w:gz") as archive: + archive.add(source_path, arcname="people_flow_project/weights/yolo11n.pt", recursive=False) +PY +fi + echo "OTA bundle created: $BUNDLE_PATH" echo "Versioned installer created: $INSTALLER_PATH" +echo "Latest installers created: $LATEST_INSTALLER_PATH and $LOWERCASE_LATEST_INSTALLER_PATH" if [ "$INCLUDE_WEIGHTS" = "1" ]; then echo "Bundle includes managed/people_flow_project/weights" else @@ -147,3 +171,8 @@ if [ "$GENERATE_WEIGHTS_ARCHIVE" = "1" ] && dir_has_payload_files "$PEOPLE_FLOW_ else echo "Separate weights archive skipped; no people-flow weights payload found under $PEOPLE_FLOW_WEIGHTS_SOURCE" fi +if [ "$GENERATE_WEIGHTS_ARCHIVE" = "1" ] && [ -f "$PEOPLE_FLOW_WEIGHTS_SOURCE/yolo11n.pt" ]; then + echo "Separate YOLO archive created: $YOLO_ARCHIVE_PATH" +else + echo "Separate YOLO archive skipped; no yolo11n.pt found under $PEOPLE_FLOW_WEIGHTS_SOURCE" +fi diff --git a/docs/《AI门店人流识别系统技术方案》.pdf b/docs/《AI门店人流识别系统技术方案》.pdf new file mode 100644 index 0000000..352c8cf Binary files /dev/null and b/docs/《AI门店人流识别系统技术方案》.pdf differ diff --git a/managed/people_flow_project/src/people_flow/webhook.py b/managed/people_flow_project/src/people_flow/webhook.py index 7241df8..44b787f 100644 --- a/managed/people_flow_project/src/people_flow/webhook.py +++ b/managed/people_flow_project/src/people_flow/webhook.py @@ -5,6 +5,12 @@ from pathlib import Path from urllib import request +def _payload_for_webhook(payload: dict) -> dict: + outbound = dict(payload) + outbound.pop("tracks", None) + return outbound + + def dispatch_json_event( path: str | Path, payload: dict, @@ -21,7 +27,7 @@ def dispatch_json_event( req = request.Request( url=webhook_url, - data=json.dumps(payload).encode("utf-8"), + data=json.dumps(_payload_for_webhook(payload)).encode("utf-8"), method="POST", ) req.add_header("Content-Type", "application/json") diff --git a/managed/people_flow_project/tests/test_webhook.py b/managed/people_flow_project/tests/test_webhook.py new file mode 100644 index 0000000..936393a --- /dev/null +++ b/managed/people_flow_project/tests/test_webhook.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json + +from src.people_flow.webhook import dispatch_json_event + + +def test_dispatch_json_event_omits_tracks_from_webhook_but_keeps_local_log( + tmp_path, monkeypatch +): + sent: dict[str, object] = {} + + class DummyResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def fake_urlopen(req, timeout): + sent["url"] = req.full_url + sent["timeout"] = timeout + sent["payload"] = json.loads(req.data.decode("utf-8")) + return DummyResponse() + + monkeypatch.setattr("src.people_flow.webhook.request.urlopen", fake_urlopen) + + output = tmp_path / "logs" / "events.jsonl" + payload = { + "event": "half_hour_report", + "total_people": 3, + "tracks": [ + {"track_id": 1, "direction": "in"}, + {"track_id": 2, "direction": "out"}, + ], + } + + dispatch_json_event( + output, + payload, + webhook_url="https://example.test/webhook", + timeout_seconds=7.5, + ) + + lines = output.read_text(encoding="utf-8").splitlines() + assert json.loads(lines[0]) == payload + assert sent["url"] == "https://example.test/webhook" + assert sent["timeout"] == 7.5 + assert sent["payload"] == { + "event": "half_hour_report", + "total_people": 3, + } diff --git a/managed/store_dwell_alert/tests/test_notifier.py b/managed/store_dwell_alert/tests/test_notifier.py index d0b15f4..b125c19 100644 --- a/managed/store_dwell_alert/tests/test_notifier.py +++ b/managed/store_dwell_alert/tests/test_notifier.py @@ -1,6 +1,9 @@ import json +from datetime import datetime +from zoneinfo import ZoneInfo -from app.modules.notifier import append_json_event +from app.modules.dwell_engine import DwellEngine +from app.modules.notifier import append_json_event, dispatch_json_event def test_append_json_event_writes_jsonl(tmp_path): @@ -13,3 +16,54 @@ def test_append_json_event_writes_jsonl(tmp_path): assert json.loads(lines[0]) == {"event": "long_stay_alert", "count": 5} assert json.loads(lines[1]) == {"event": "half_hour_report", "count": 3} + + +def test_dispatch_json_event_posts_report_without_tracks(tmp_path, monkeypatch): + tz = ZoneInfo("Asia/Shanghai") + window_start = datetime(2026, 4, 15, 11, 7, tzinfo=tz) + engine = DwellEngine( + camera_id="store_cam_01", + queue_time_threshold_seconds=300, + crowded_count_threshold=5, + normal_count_threshold=2, + pause_timeout_seconds=300, + alert_cooldown_seconds=600, + report_window_start=window_start, + ) + + engine.process_observations( + [{"person_id": f"cust_{idx}", "role": "customer"} for idx in range(6)], + window_start, + ) + events = engine.process_observations([], window_start.replace(minute=37)) + report_event = next(event for event in events if event["event"] == "half_hour_report") + + sent: dict[str, object] = {} + + class DummyResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def fake_urlopen(req, timeout): + sent["url"] = req.full_url + sent["timeout"] = timeout + sent["payload"] = json.loads(req.data.decode("utf-8")) + return DummyResponse() + + monkeypatch.setattr("app.modules.notifier.request.urlopen", fake_urlopen) + + output = tmp_path / "logs" / "events.jsonl" + dispatch_json_event( + output, + report_event, + webhook_url="https://example.test/webhook", + timeout_seconds=5.0, + ) + + assert sent["url"] == "https://example.test/webhook" + assert sent["timeout"] == 5.0 + assert sent["payload"]["event"] == "half_hour_report" + assert "tracks" not in sent["payload"]