From 8b5bbff3645fc7fec76393cbd2dababa7ac9e3af Mon Sep 17 00:00:00 2001 From: Yoilun Date: Fri, 29 May 2026 14:48:01 +0800 Subject: [PATCH] feat: stabilize cold display runtime deployment --- .dockerignore | 14 + .gitignore | 1 + Dockerfile | 29 + README_zh.md | 70 +- agent.md | 99 +++ config/example.toml | 60 +- deploy/cold-display-guard.env | 7 + deploy/docker-compose.yml | 71 ++ docs/project.md | 84 ++ findings.md | 81 ++ progress.md | 172 ++++ prototype/custom-zones/index.html | 1110 ++++++++++++++++++++++++++ src/cold_display_guard/config.py | 196 ++++- src/cold_display_guard/engine.py | 149 +++- src/cold_display_guard/main.py | 100 ++- src/cold_display_guard/manage_api.py | 110 ++- src/cold_display_guard/models.py | 1 + src/cold_display_guard/vision.py | 179 ++++- task_plan.md | 55 +- tests/test_cli.py | 3 +- tests/test_config.py | 91 ++- tests/test_engine.py | 205 ++++- tests/test_main.py | 113 +++ tests/test_manage_api.py | 248 +++++- tests/test_vision.py | 206 ++++- web/.dockerignore | 3 + web/Dockerfile | 29 + web/nginx.conf | 20 + web/src/main.js | 440 +++++++--- web/src/styles.css | 213 ++++- web/src/zone-state.js | 502 ++++++++++++ web/test/zone-state.test.js | 630 +++++++++++++++ 32 files changed, 5050 insertions(+), 241 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 agent.md create mode 100644 deploy/cold-display-guard.env create mode 100644 deploy/docker-compose.yml create mode 100644 docs/project.md create mode 100644 prototype/custom-zones/index.html create mode 100644 tests/test_main.py create mode 100644 web/.dockerignore create mode 100644 web/Dockerfile create mode 100644 web/nginx.conf create mode 100644 web/src/zone-state.js create mode 100644 web/test/zone-state.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..28ef78a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.DS_Store +.venv +__pycache__/ +*.py[cod] +.pytest_cache/ +dist/ +build/ +*.egg-info/ +logs/ +*.jsonl +web/node_modules/ +web/dist/ +managed-portal.textClipping diff --git a/.gitignore b/.gitignore index e6ab47b..047f286 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__/ *.py[cod] .DS_Store +*.textClipping .pytest_cache/ .venv/ dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b3969a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/python:3.12-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/src \ + TZ=Asia/Shanghai + +WORKDIR /app + +RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.aliyun.com/debian|g; s|http://deb.debian.org/debian-security|http://mirrors.aliyun.com/debian-security|g' /etc/apt/sources.list.d/debian.sources && \ + apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + ffmpeg \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml README_zh.md /app/ +COPY src /app/src +COPY config /app/config +COPY scripts /app/scripts + +RUN chmod +x /app/scripts/*.sh && mkdir -p /app/logs + +EXPOSE 19080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:19080/api/manage/health', timeout=3).read()" || exit 1 + +CMD ["python3", "-m", "cold_display_guard.manage_api", "--config", "/app/config/example.toml", "--host", "0.0.0.0", "--port", "19080"] diff --git a/README_zh.md b/README_zh.md index 5d7522a..983ff4c 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,19 +1,22 @@ # 冷藏展示柜食品批次计时报警 -这是一个独立项目,用于单摄像头监控冷藏展示柜和同画面垃圾桶,记录每个展示区域内食品批次的放置时长,并发现 3 小时到期后的违规行为。 +这是一个独立项目,用于单摄像头监控冷藏展示柜和同画面垃圾桶,记录每个展示区域内食品批次的放置时长,并发现超过自定义报警时间后的异常处理行为。 ## 已确认业务规则 - 摄像头同时看到展示柜和垃圾桶。 -- 展示柜初始布局为横向 4 列、竖向 2 行。 -- 布局后期可以通过配置调整。 +- 展示柜食品区域支持 1 到 10 个自定义区域。 +- 食品区域使用阿拉伯数字标注:`1`、`2`、`3` ... +- 垃圾桶 ROI 独立标定,不占用食品区域编号。 - 每个区域可以放多份食品,但这些食品按同一批次计时。 - 同一区域不允许混批,必须清空后才能放入新批次。 - 食品放入区域时记录开始时间。 - 区域清空时记录结束时间。 -- 未满 3 小时清空视为正常消耗。 -- 超过 3 小时清空后必须在确认窗口内看到垃圾桶投放动作。 -- 超过 3 小时的食品拿出后又放回展示柜,触发报警。 +- 未达到报警阈值前清空视为正常消耗。 +- 食品在区域内达到 `max_dwell_seconds` 时先产生 `time_alarm`。 +- 已报警食品从区域移出后,必须在确认窗口内看到垃圾桶投放动作。 +- 如果已报警食品移出后没有丢到垃圾桶里,报警事件升级为 `warning_escalated` 警告事件。 +- 已报警食品拿出后又放回展示柜,触发违规事件。 ## 当前实现范围 @@ -23,8 +26,8 @@ { "ts": "2026-04-27T10:00:00+08:00", "zone_counts": { - "r1c1": 3, - "r1c2": 0 + "1": 1, + "2": 0 }, "trash_deposit": false } @@ -33,12 +36,13 @@ 程序会输出 JSONL 事件,例如: - `batch_started` +- `time_alarm` - `batch_consumed` - `batch_pending_disposal` - `batch_discarded` +- `warning_escalated` - `mixed_batch_violation` - `overdue_return_violation` -- `missing_disposal_violation` ## 配置 @@ -46,9 +50,25 @@ 默认阈值: -- 最大放置时间:`10800` 秒,也就是 3 小时 +- 时间报警阈值:`10800` 秒,也就是 3 小时;管理页按分钟输入,例如 20 分钟会保存为 `1200` 秒 - 垃圾桶投放确认窗口:`120` 秒 +食品区域配置示例: + +```toml +[layout] +zone_count = 3 +zone_ids = ["1", "2", "3"] + +[[zones]] +id = "1" +label = "区域 1" +polygon = [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]] + +[trash] +roi = [[0.7, 0.7], [0.9, 0.7], [0.9, 0.9]] +``` + ## 区域标定 项目现在有正式管理页,前端默认 `23000`,后端默认 `19080`。 @@ -73,10 +93,10 @@ http://127.0.0.1:23000 - 配置 RTSP 地址和阈值 - 从 RTSP 拉取一帧截图 -- 标定 `r1c1` 到 `r2c4` 的 8 个格口 -- 标定垃圾桶区域 +- 设置 1 到 10 个食品区域 +- 标定数字食品区域和垃圾桶 ROI - 直接保存标定结果到项目配置文件 -- 查看事件汇总和最近 JSONL 事件 +- 查看事件汇总、区域序号、停留时间、报警和警告事件 项目仍保留 `tools/calibrator` 作为轻量单页标定工具,但正式使用建议走 `23000` 管理页。 @@ -131,11 +151,22 @@ frame_width = 640 frame_height = 360 capture_timeout_seconds = 12.0 baseline_frames = 3 -sample_stride_pixels = 8 -occupancy_mean_delta = 24.0 +sample_stride_pixels = 4 +occupancy_mean_delta = 55.0 occupancy_texture_delta = 18.0 +occupancy_dark_luma_threshold = 80.0 +occupancy_dark_fraction = 0.06 +occupancy_texture_dark_fraction = 0.04 +occupancy_bright_luma_threshold = 220.0 +occupancy_bright_reflection_fraction = 0.18 +occupancy_reflection_dark_fraction = 0.10 +occupancy_reflection_bright_dark_ratio = 2.0 +occupancy_confirm_frames = 2 +empty_confirm_frames = 2 trash_motion_delta = 18.0 -trash_motion_cooldown_seconds = 8 +trash_sustained_motion_delta = 8.0 +trash_sustained_motion_frames = 2 +trash_motion_cooldown_seconds = 3 diagnostics_path = "logs/runtime_diagnostics.jsonl" ``` @@ -144,3 +175,10 @@ diagnostics_path = "logs/runtime_diagnostics.jsonl" ```bash PYTHONPATH=src python3 -m unittest discover -s tests -v ``` + +前端测试和构建: + +```bash +node --test web/test/zone-state.test.js +cd web && pnpm build +``` diff --git a/agent.md b/agent.md new file mode 100644 index 0000000..26d215e --- /dev/null +++ b/agent.md @@ -0,0 +1,99 @@ +# Cold Display Guard Agent Instructions + +## Repository Snapshot + +- Root purpose: `cold-display-guard` monitors refrigerated display food batches, tracks dwell time per configured display zone, and records disposal compliance events. +- Backend: Python 3.11+ package in `src/cold_display_guard`, using only the standard library for application code. +- Frontend: Vite + vanilla JavaScript management console in `web/`. +- Data/storage: JSONL runtime outputs under `logs/` by default; configuration is TOML in `config/example.toml`. +- Runtime services: + - Management API: `127.0.0.1:19080`, routes under `/api/manage/*`. + - Web console: `127.0.0.1:23000`, Vite proxies `/api` to the management API. + - Runtime monitor: RTSP frame sampling through `ffmpeg`, writing events and diagnostics JSONL. +- Deployment: Docker/Compose files are present; containers mount `config/` and `logs/`, use `Asia/Shanghai`, and prefer China-accessible package/image mirrors. + +## Repository Map + +- `src/cold_display_guard/engine.py`: pure batch state machine and compliance event generation. +- `src/cold_display_guard/models.py`: domain dataclasses and observation parsing. +- `src/cold_display_guard/config.py`: TOML loading, saving, calibration merge, and path resolution. +- `src/cold_display_guard/manage_api.py`: standard-library HTTP management API and RTSP snapshot capture. +- `src/cold_display_guard/main.py`: long-running RTSP monitor that connects frame capture, vision detection, engine, and JSONL sinks. +- `src/cold_display_guard/frame_source.py`: `ffmpeg` raw RGB frame capture. +- `src/cold_display_guard/vision.py`: heuristic ROI occupancy and trash-motion detection. +- `src/cold_display_guard/cli.py`: JSONL observation CLI for deterministic engine processing. +- `web/src/main.js` and `web/src/styles.css`: management console UI. +- `scripts/`: local launch scripts for API, web, and runtime services. +- `deploy/`, `Dockerfile`, `web/Dockerfile`: container deployment artifacts. +- `tests/`: unittest coverage for engine, CLI, config, management summary/config behavior, and vision heuristics. +- `docs/plans/`, `task_plan.md`, `progress.md`, `findings.md`: existing planning and project-history artifacts. + +## Core Domain Rules + +- The reliable business unit is a display-zone batch, not an individual food item. +- Default layout is 2 rows by 4 columns with zone IDs `r1c1` through `r2c4`; layout and polygons are configurable. +- A batch starts when a zone changes from empty to occupied. +- A batch ends when a zone changes from occupied to empty. +- Count decreases keep the same batch active and emit `batch_count_changed`. +- Count increases before the zone clears are mixed-batch violations and emit `mixed_batch_violation`. +- Removal before `max_dwell_seconds` emits `batch_consumed`. +- Removal at or after `max_dwell_seconds` emits `batch_pending_disposal` and waits for trash confirmation. +- A trash deposit within `trash_confirmation_seconds` emits `batch_discarded`. +- No trash deposit before the deadline emits `missing_disposal_violation`. +- Any new occupied zone while an overdue batch is pending disposal emits `overdue_return_violation`. +- The current vision layer reports binary `0/1` occupancy per zone; it does not count individual items. +- The detector learns an empty baseline from the first configured frames. If food is already present at startup, it may become baseline until the image changes. + +## Change Rules + +- Keep `BatchEngine` deterministic and free of camera, file, HTTP, subprocess, or wall-clock dependencies. +- Add or update focused tests when changing business rules, event names, event payloads, observation parsing, config formatting, or path resolution. +- Keep the observation contract stable: `ts`, `zone_counts`, and `trash_deposit` or `trash_deposit_count`. +- If event names or payload shapes change, update engine tests, CLI tests, runtime code, management summary behavior, frontend rendering, and README examples together. +- Keep ROI and polygon coordinates normalized to `0.0..1.0`; clamp or validate inputs at config/API boundaries. +- Keep `manage_api.py` as a small standard-library HTTP service unless the user explicitly asks to introduce a web framework. +- Preserve explicit `ffmpeg` timeout and error reporting behavior in `frame_source.py` and snapshot capture. +- Treat RTSP URLs, camera credentials, captured frames, and logs as sensitive operational data. Do not paste secrets into new docs, commits, or test fixtures. +- Do not commit generated runtime data such as `logs/`, captured snapshots, Vite `dist/`, Python caches, or ad hoc diagnostics. +- Frontend changes should preserve the current Vite single-page app, `/api/manage/*` backend contract, and 23000/19080 local development split. +- Deployment changes must keep README commands, scripts, ports, env vars, compose volumes, Docker health checks, and config paths aligned. +- Be careful with mirror settings in Dockerfiles; they are intentional for the expected deployment network. + +## Local Commands + +- Full Python test suite: + - `PYTHONPATH=src python3 -m unittest discover -s tests -v` +- Targeted Python tests: + - `PYTHONPATH=src python3 -m unittest tests/test_engine.py -v` + - `PYTHONPATH=src python3 -m unittest tests/test_config.py -v` + - `PYTHONPATH=src python3 -m unittest tests/test_manage_api.py -v` + - `PYTHONPATH=src python3 -m unittest tests/test_vision.py -v` + - `PYTHONPATH=src python3 -m unittest tests/test_cli.py -v` +- Management API: + - `scripts/run_manage_api.sh` + - Health check: `curl http://127.0.0.1:19080/api/manage/health` +- Web console: + - `scripts/run_web.sh` + - Build check: `cd web && pnpm build` +- Runtime monitor: + - `scripts/run_runtime.sh` + - One-frame smoke test when RTSP and `ffmpeg` are available: `PYTHONPATH=src python3 -m cold_display_guard.main --config config/example.toml --once` +- Compose config check: + - `docker compose --env-file deploy/cold-display-guard.env -f deploy/docker-compose.yml config` + +## Validation Matrix + +- Engine or domain behavior: run the targeted engine/CLI tests first, then the full Python test suite. +- Config or calibration behavior: run `tests/test_config.py`, `tests/test_manage_api.py`, then the full Python test suite. +- Vision or RTSP capture behavior: run `tests/test_vision.py`; use the one-frame runtime smoke test only when camera access and `ffmpeg` are available. +- Management API changes: run management API tests and, when practical, start `scripts/run_manage_api.sh` and hit `/api/manage/health`. +- Frontend changes: run `cd web && pnpm build`; if API interactions changed, also run or inspect the management API route behavior. +- Deployment changes: run the compose config check and verify Dockerfile/package mirror choices, ports, volumes, and health checks. +- Documentation-only changes: verify the documented paths, commands, ports, and business rules against the current files before reporting completion. + +## Workflow + +- Read the relevant source and tests before editing; this project has tight coupling between business rules, event payloads, README examples, and UI summaries. +- Prefer small, surgical changes that preserve the current architecture. +- For non-trivial work, update or add planning notes in the existing project style (`docs/plans/`, `task_plan.md`, `progress.md`, or `findings.md`) only when useful for handoff or explicitly requested. +- Keep the final response grounded in verification evidence: say exactly which commands were run, or say when a validation step was skipped because it requires RTSP, Docker, network, or another external dependency. diff --git a/config/example.toml b/config/example.toml index b6146d5..c38cfb5 100644 --- a/config/example.toml +++ b/config/example.toml @@ -1,52 +1,56 @@ -camera_id = "cold_display_cam_01" +camera_id = "1" timezone = "Asia/Shanghai" [stream] -rtsp_url = "rtsp://admin:Zxjp2026@192.168.8.9:554/h264/ch1/main/av_stream" +rtsp_url = "" [thresholds] -max_dwell_seconds = 10800 +max_dwell_seconds = 1200 trash_confirmation_seconds = 120 [layout] -rows = 2 -cols = 4 -zone_ids = ["r1c1", "r1c2", "r1c3", "r1c4", "r2c1", "r2c2", "r2c3", "r2c4"] +zone_count = 4 +zone_ids = ["1", "2", "3", "4"] [[zones]] -id = "r1c1" -polygon = [[0.441053, 0.344678], [0.475789, 0.372749], [0.453684, 0.455088], [0.404211, 0.428889]] +id = "1" +label = "区域 1" +polygon = [[0.241988, 0.289459], [0.323741, 0.306900], [0.319817, 0.438286], [0.256377, 0.420845]] [[zones]] -id = "r1c2" -polygon = [[0.486316, 0.367135], [0.520000, 0.397076], [0.503158, 0.468187], [0.467368, 0.451345]] +id = "2" +label = "区域 2" +polygon = [[0.354480, 0.320852], [0.423152, 0.330154], [0.419228, 0.470842], [0.378025, 0.454564], [0.357096, 0.446425]] [[zones]] -id = "r1c3" +id = "3" +label = "区域 3" polygon = [[0.545263, 0.400819], [0.587368, 0.417661], [0.554737, 0.500000], [0.509474, 0.483158]] [[zones]] -id = "r1c4" +id = "4" +label = "区域 4" polygon = [[0.581255, 0.408928], [0.717971, 0.468544], [0.711092, 0.574018], [0.556320, 0.500645]] -[[zones]] -id = "r2c1" -polygon = [[0.396842, 0.475673], [0.487368, 0.543041], [0.472632, 0.612281], [0.373684, 0.584211]] - -[[zones]] -id = "r2c2" -polygon = [[0.502105, 0.528070], [0.535789, 0.546784], [0.516842, 0.660936], [0.477895, 0.632865]] - -[[zones]] -id = "r2c3" -polygon = [[0.555789, 0.552398], [0.602105, 0.569240], [0.580000, 0.657193], [0.535789, 0.645965]] - -[[zones]] -id = "r2c4" -polygon = [[0.602105, 0.567368], [0.700000, 0.606667], [0.689474, 0.722690], [0.581053, 0.683392]] - [trash] roi = [[0.776842, 0.486901], [0.896842, 0.522456], [0.841053, 0.857427], [0.716842, 0.853684]] +[runtime] +sample_stride_pixels = 4 +occupancy_mean_delta = 55.0 +occupancy_dark_luma_threshold = 80.0 +occupancy_dark_fraction = 0.06 +occupancy_texture_dark_fraction = 0.04 +occupancy_bright_luma_threshold = 220.0 +occupancy_bright_reflection_fraction = 0.18 +occupancy_reflection_dark_fraction = 0.10 +occupancy_reflection_bright_dark_ratio = 2.0 +occupancy_confirm_frames = 2 +empty_confirm_frames = 2 +trash_motion_delta = 18.0 +trash_sustained_motion_delta = 8.0 +trash_sustained_motion_frames = 2 +trash_motion_cooldown_seconds = 3 + [event_sink] path = "logs/events.jsonl" diff --git a/deploy/cold-display-guard.env b/deploy/cold-display-guard.env new file mode 100644 index 0000000..1f9a567 --- /dev/null +++ b/deploy/cold-display-guard.env @@ -0,0 +1,7 @@ +IMAGE_VERSION=dev +TZ=Asia/Shanghai + +COLD_DISPLAY_GUARD_API_PORT=19080 +COLD_DISPLAY_GUARD_WEB_PORT=23000 +COLD_DISPLAY_GUARD_CONFIG_DIR=../config +COLD_DISPLAY_GUARD_LOG_DIR=../logs diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..75cf6ef --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,71 @@ +name: cold-display-guard + +services: + cold-display-guard-api: + build: + context: .. + dockerfile: Dockerfile + image: cold-display-guard:${IMAGE_VERSION:-dev} + container_name: cold-display-guard-api + restart: unless-stopped + environment: + TZ: ${TZ:-Asia/Shanghai} + command: + - python3 + - -m + - cold_display_guard.manage_api + - --config + - /app/config/example.toml + - --host + - 0.0.0.0 + - --port + - "19080" + ports: + - "${COLD_DISPLAY_GUARD_API_PORT:-19080}:19080" + volumes: + - ${COLD_DISPLAY_GUARD_CONFIG_DIR:-../config}:/app/config + - ${COLD_DISPLAY_GUARD_LOG_DIR:-../logs}:/app/logs + networks: + - cold-display-guard + + cold-display-guard-runtime: + image: cold-display-guard:${IMAGE_VERSION:-dev} + container_name: cold-display-guard-runtime + restart: unless-stopped + healthcheck: + disable: true + depends_on: + cold-display-guard-api: + condition: service_started + environment: + TZ: ${TZ:-Asia/Shanghai} + command: + - python3 + - -m + - cold_display_guard.main + - --config + - /app/config/example.toml + volumes: + - ${COLD_DISPLAY_GUARD_CONFIG_DIR:-../config}:/app/config + - ${COLD_DISPLAY_GUARD_LOG_DIR:-../logs}:/app/logs + networks: + - cold-display-guard + + cold-display-guard-web: + build: + context: ../web + dockerfile: Dockerfile + image: cold-display-guard-web:${IMAGE_VERSION:-dev} + container_name: cold-display-guard-web + restart: unless-stopped + depends_on: + cold-display-guard-api: + condition: service_started + ports: + - "${COLD_DISPLAY_GUARD_WEB_PORT:-23000}:80" + networks: + - cold-display-guard + +networks: + cold-display-guard: + driver: bridge diff --git a/docs/project.md b/docs/project.md new file mode 100644 index 0000000..eec1f71 --- /dev/null +++ b/docs/project.md @@ -0,0 +1,84 @@ +# Cold Display Guard Project Documentation + +## Goal + +`cold-display-guard` monitors refrigerated display food batches by camera region. It tracks how long each configured food region remains occupied, raises a configurable time alarm, and escalates alarmed food to a warning if it is removed without a matching trash-bin deposit. + +The `v1.1 优化改造` batch upgrades the product from a fixed 8-zone layout to a configurable 1-10 zone workflow with numeric region labels and editable trash ROI calibration. All v1.1 items are part of one batch; backend, API, frontend, and documentation are implementation workstreams inside that same batch. + +## Architecture + +- Backend package: `src/cold_display_guard/` + - `models.py`: settings, observations, and batch dataclasses. + - `engine.py`: deterministic batch state machine. + - `config.py`: TOML config load/save, calibration merge, and project path resolution. + - `manage_api.py`: standard-library HTTP management API. + - `main.py`: RTSP runtime loop connecting frame capture, vision, state engine, and JSONL sinks. + - `vision.py`: heuristic ROI occupancy and trash-motion detection. +- Frontend package: `web/` + - Vite + vanilla JavaScript management console. + - Default web port `23000`. + - API proxy target `http://127.0.0.1:19080`. + - Runtime home view falls back to clearly marked demo data when no real events exist, so the operational layout still shows summary cards, dwell timers, and event rows. +- Runtime data: + - Events JSONL default path `logs/events.jsonl`. + - Diagnostics JSONL default path `logs/runtime_diagnostics.jsonl`. +- Deployment: + - Root Python Docker image for API/runtime. + - `web/Dockerfile` for static web console. + - `deploy/docker-compose.yml` wires API, runtime, and web services. + +## Configuration + +- Main config path: `config/example.toml`. +- Camera identity: `camera_id`. +- Timezone default: `Asia/Shanghai`. +- RTSP input: `[stream] rtsp_url`. +- Thresholds: + - `max_dwell_seconds`: v1.1 time-alarm threshold. Default can remain 10800 seconds; users can set values such as 1200 seconds for 20 minutes. + - `trash_confirmation_seconds`: window after an alarmed batch is removed where a trash deposit must be observed before warning escalation. +- Food zones: + - v1.1 food zone IDs are numeric strings from `"1"` through `"10"`. + - The configured zone count must be between 1 and 10. + - If both `zone_count` and numeric `zone_ids` are present, they must agree. + - Each `[[zones]]` polygon must have at least 3 normalized points. +- Trash ROI: + - Stored under `[trash] roi`. + - Does not use a food zone number. + +## Event Model + +- `batch_started`: a food region changes from empty to occupied. +- `time_alarm`: an active batch reaches `max_dwell_seconds` while still in the display region. +- `batch_count_changed`: count decreases while the region remains occupied. +- `mixed_batch_violation`: count increases before the region clears. +- `batch_consumed`: a non-alarmed batch clears before the threshold. +- `batch_pending_disposal`: an alarmed batch clears and waits for trash confirmation. +- `batch_discarded`: a pending alarmed batch is matched to a trash deposit. +- `warning_escalated`: a pending alarmed batch is not matched to a trash deposit before the confirmation deadline. + +Events should include `zone_id`, `zone_index`, `zone_label`, `started_at`, `dwell_seconds`, and relevant alarm/removal/deadline timestamps when available. + +## Runbook + +- Python tests: + - `PYTHONPATH=src python3 -m unittest discover -s tests -v` +- Management API: + - `scripts/run_manage_api.sh` + - Health: `curl http://127.0.0.1:19080/api/manage/health` +- Web console: + - `scripts/run_web.sh` + - Build: `cd web && pnpm build` + - Frontend logic tests: `node --test web/test/zone-state.test.js` + - Docker web URL: `http://127.0.0.1:23000` +- Runtime monitor: + - `scripts/run_runtime.sh` + - One-frame smoke test when camera and `ffmpeg` are available: + - `PYTHONPATH=src python3 -m cold_display_guard.main --config config/example.toml --once` + +## Known Risks + +- The current vision detector is heuristic and reports binary occupancy, not item counts. +- If food is already present during baseline collection, those regions may be treated as empty baseline until visual changes occur. +- Changing calibration while the runtime process has active batches can create operational ambiguity; v1.1 should document or enforce a pause/restart expectation. +- Historical events must keep the zone index at the time of emission so later region reordering does not reinterpret old logs. diff --git a/findings.md b/findings.md index 5cdacdd..6901ad9 100644 --- a/findings.md +++ b/findings.md @@ -14,3 +14,84 @@ - If a batch is removed after the maximum dwell threshold, the system expects a trash-bin deposit event within a configurable window. - If a removed over-threshold batch reappears in any display zone before being discarded, that is a violation. - If food is added while a zone is already occupied, that is a mixed-batch violation. + +## v1.1 优化改造 Findings + +## Architecture + +- The current backend already accepts configured `layout.zone_ids`, so the engine does not require a fixed 2x4 grid internally. +- The current frontend is the main fixed-grid constraint: `web/src/main.js` hard-codes `r1c1` through `r2c4` and draws those region controls. +- `merge_calibration()` already accepts arbitrary zone IDs and clamps polygon points, but it does not enforce the new 1-10 numeric region policy. +- The runtime vision layer consumes `[[zones]]` from config, so it can follow numeric zones once config and frontend write them. +- `BatchEngine` only emits events when a zone changes or pending disposal expires; a time alarm while the batch remains occupied requires a new periodic active-batch check. + +## Constraints + +- Food regions must be numbered `1` through `N`, with `N` between 1 and 10. +- Trash ROI is a separate region under `[trash]` and must not consume a food zone number. +- The management API should preserve standard-library HTTP behavior. +- Existing JSONL consumers may still expect `event`, `zone_id`, `dwell_seconds`, and timestamps; v1.1 should add fields rather than remove core fields. +- The frontend remains Vite + vanilla JavaScript; no framework migration in this batch. + +## Decisions + +- Keep `max_dwell_seconds` as the configurable time-alarm threshold to avoid introducing two competing dwell thresholds. +- Add `time_alarm` when an active batch reaches the threshold, while keeping the batch active until the zone clears. +- Once an alarmed batch clears, put it into pending trash confirmation and emit `batch_pending_disposal`. +- If no trash deposit occurs before the confirmation deadline, emit `warning_escalated` with severity `warning`; retain compatibility by using the same pending-disposal mechanism. +- Emit `zone_index` and `zone_label` on every zone event when the zone ID is numeric. +- The backend planning agent suggested `batch_dwell_alert` and `missing_disposal_warning`, but the accepted prototype and user phrasing use `time_alarm` and `warning_escalated`; implementation should follow the accepted prototype names. +- Every future subagent dispatch must begin with the standard context header requested by the user: + +```text +[项目: /Users/yoilun/Code/cold_display_guard] +[工作流批次: v1.1 优化改造] +[阶段: 阶段 x] +[角色: 对应智能体角色] +``` + +Use `阶段 x` only as a workflow stage inside the same `v1.1 优化改造` batch. + +## Backend Planning Notes + +- `EngineSettings.zone_ids` should remain config driven; numeric zones are preferred for new configs, but old `r1c1` style IDs should continue loading. +- v1.1 can derive `zone_index` from numeric `zone_id` when possible. +- Suggested batch additions: + - `alerted_at` + - an indicator that the time alarm has already been emitted +- Suggested state flow: + - `active`: region occupied before threshold. + - `alerted`: region occupied after `time_alarm`. + - `pending_disposal`: alarmed food removed and waiting for trash deposit. + - `discarded`: pending batch matched to trash. + - `warning`: alarmed food removed and no trash deposit before deadline. + - `consumed`: non-alarmed food removed. +- `build_summary()` currently counts only `*_violation`; v1.1 should include `severity: alert|warning` in alert summaries. +- FIFO matching of pending batches to trash deposits remains acceptable but should stay covered by tests. + +## Frontend Planning Notes + +- Replace the fixed `zoneIds` array in `web/src/main.js` with dynamic food-zone state derived from config, constrained to 1-10 zones. +- Keep `trashRoi` separate from `foodZones`; never include it in `zone_index` numbering. +- Suggested frontend state groups: + - `foodZoneCount` + - `foodZones: [{id, label, points}]` + - `trashRoi` + - active edit target type/index + - config form values including alarm threshold + - normalized event rows for display +- Current target must be visually obvious before canvas clicks add points. +- Coordinates must remain normalized relative to the displayed frame/image. +- Reducing zone count should truncate removed zones only after a clear confirmation or obvious UI affordance. +- Re-capturing an RTSP frame must not discard unsaved point edits. +- Event table must safely render old and new events, including `time_alarm` and `warning_escalated`. +- Frontend validation should cover 1/10 zones, food/trash editing, save/reload recovery, old 8-zone config compatibility, and threshold validation. + +## Homepage Demo Runtime Notes + +- Docker runtime can write diagnostics while no events exist, especially when the configured RTSP stream is unreachable. +- A diagnostics-only summary is not enough to represent the accepted prototype; the home runtime view should show a clearly marked demo state until real events exist. +- Demo runtime content must never look like a real alarm: banner, metrics labels, event source tags, and event file text identify it as demo data. +- Real events take precedence over demo data. Per-zone progress uses the latest event by timestamp when both candidates have timestamps; otherwise it falls back to event order. +- Event-derived progress uses the configured dwell threshold when an event omits `max_dwell_seconds`. +- Any runtime summary value rendered through `innerHTML`, including `latest_zone_counts`, must be HTML-escaped. diff --git a/progress.md b/progress.md index 3cfb2b0..e32793c 100644 --- a/progress.md +++ b/progress.md @@ -11,3 +11,175 @@ - Initialized git repository and created the initial project commit. - Added RTSP single-frame calibration tool under `tools/calibrator`. - Added formal management API on port `19080` and Vite frontend on port `23000`. + +## 2026-05-26 v1.1 优化改造 + +### Session Log + +| Time | Batch Workstream | Actor | Action | Result | +| --- | --- | --- | --- | --- | +| 2026-05-26 | Batch setup and planning | Main Agent | Created active goal for `v1.1 优化改造` | Goal tracks dynamic zones, trash ROI editing, custom alarm threshold, warning escalation | +| 2026-05-26 | Batch setup and planning | Main Agent | Started backend and frontend planning sub-agents | Waiting for role outputs while updating plan files | +| 2026-05-26 | Batch setup and planning | Main Agent | Updated `task_plan.md` and `findings.md` with v1.1 scope | `v1.1 优化改造` planning in progress | +| 2026-05-26 | Batch setup and planning | Frontend Agent | Returned frontend planning notes | Added dynamic zones, trash ROI, event display, and validation notes to `findings.md` | +| 2026-05-26 | Batch setup and planning | Backend Agent | Returned backend event model and test risk notes | Added event flow, summary risk, and compatibility notes to `findings.md` | +| 2026-05-26 | Batch setup and planning | User | Clarified that all requested work belongs to one v1.1 development batch with different workstreams | Updated `task_plan.md` and `docs/project.md` wording | +| 2026-05-26 | Batch setup and planning | User | Asked to remove earlier split-work wording and make it part of one batch | Renamed v1.1 plan table to batch workstreams and replaced split-work wording in progress | +| 2026-05-26 | Batch setup and planning | User | Set the batch name to `v1.1 优化改造` | Updated planning documents to use this name | +| 2026-05-26 | Backend event model | Review Agent | Re-reviewed event model after severity fix | Pass; no blocking issues for Config/API workstream | +| 2026-05-26 | Batch setup and planning | User | Required a standard context header before each subagent task dispatch | Added header convention to `task_plan.md` and `findings.md`; future subagent prompts will include project, batch, stage, and role | +| 2026-05-26 | Batch setup and planning | User | Clarified that `项目` in subagent task headers should use the actual project path | Updated the required header path to `/Users/yoilun/Code/cold_display_guard` | +| 2026-05-26 | Config and management API | Main Agent | Added 1-10 numeric zone validation, `zone_count`, `label`, trash ROI separation, and alarm/warning summary counts | Target config/API tests and full Python tests passed | +| 2026-05-26 | Config and management API | Review Agent | Reviewed Config/API workstream | No blocking issues; raised two major contract issues, both fixed with regression tests | +| 2026-05-26 | Frontend management console | Main Agent | Added dynamic 1-10 numeric zone editor, independent trash ROI editing, minute-based alarm threshold, and alarm/warning event rendering | Frontend unit test and Vite build passed | +| 2026-05-26 | Frontend management console | Frontend Agent | Reviewed frontend workstream | No blocking issues; raised two major legacy-mapping/label issues, both fixed with regression tests and sent for re-review | +| 2026-05-26 | Documentation and final review | Main Agent | Updated `README_zh.md`, `docs/project.md`, and `config/example.toml` for v1.1 numeric zones and event flow | Docs now describe one `v1.1 优化改造` batch and current configuration/event model | +| 2026-05-26 | Frontend management console | Frontend Agent | Re-reviewed frontend legacy mapping and label normalization fixes | Pass; no blocking, no major, no new minor issues | +| 2026-05-26 | Documentation and final review | Review Agent | Ran final v1.1 review | No blocking; found two major issues in removal-time alarm ordering and partial calibration zone-count preservation, plus one planning-doc minor | +| 2026-05-26 | Documentation and final review | Main Agent | Fixed final review issues | Added tests and fixes for removal-frame `time_alarm`, partial numeric calibration preserving `zone_count`, and updated top-level plan wording | +| 2026-05-26 | Documentation and final review | Review Agent | Re-reviewed final fixes | Pass; no blocking, no major, no minor issues | +| 2026-05-26 | Documentation and final review | Main Agent | Completed `v1.1 优化改造` batch | Stop conditions satisfied and final verification recorded | +| 2026-05-26 | Homepage demo runtime display | Main Agent | Cleared old event data and added complete demo runtime homepage | Homepage now defaults to runtime view with demo banner, metrics, dwell progress, and event table when real events are empty | +| 2026-05-26 | Homepage demo runtime display | Frontend Agent | Implemented demo runtime display and tests | Added `buildRuntimeDisplayModel`, progress rows, demo/real labels, and responsive styles | +| 2026-05-26 | Homepage demo runtime display | Review Agent | Reviewed homepage demo runtime display | Found progress ordering, threshold fallback, and XSS issues; all were fixed with regression tests | +| 2026-05-26 | Homepage demo runtime display | Review Agent | Final re-review | Pass; no blocking or major issues | +| 2026-05-26 | Homepage demo runtime display | Main Agent | Rebuilt Docker web service | `http://127.0.0.1:23000` serves nginx container with latest frontend asset | +| 2026-05-26 | Homepage demo runtime display | User | Reported that the runtime demo homepage was still not visible | Reproduced in Chrome; found frontend initialization stopped before tab switching | +| 2026-05-26 | Homepage demo runtime display | Main Agent | Fixed null config runtime display crash | `buildRuntimeDisplayModel()` now tolerates `config: null`; Chrome shows runtime page with demo data, metrics, progress, and event table | +| 2026-05-26 | Remote Docker deployment | Main Agent | Probed `xiaozheng@192.168.5.206` for Docker availability | Blocked by SSH authentication failure: `Permission denied (publickey,password)` | +| 2026-05-26 | Remote Docker deployment | Main Agent | Synced project to `xiaozheng@192.168.5.206:/home/xiaozheng/cold_display_guard` | `rsync -az --delete` completed with `.git`, local env, node modules, web dist, and logs excluded | +| 2026-05-26 | Remote Docker deployment | Main Agent | Built and started Docker services on `192.168.5.206` | API and Web are running; runtime was stopped because `[stream].rtsp_url` is empty | +| 2026-05-26 | Hide demo runtime data | Main Agent | Removed synthetic demo runtime summary/events/progress from the frontend model | Empty or diagnostics-only runtime data now renders empty states and real metrics only | +| 2026-05-26 | Hide demo runtime data | Main Agent | Synced and rebuilt Web on `192.168.5.206` | Remote Web serves `index-D3qCb2DS.js` without demo batch/camera or visible demo-data strings | +| 2026-05-27 | Runtime recognition startup | Main Agent | Checked whether recognition had started on `192.168.5.206` | Runtime was stopped, then started after fixing remote timezone from `shanghai` to `Asia/Shanghai`; diagnostics show frame capture and `baseline_ready: true` | +| 2026-05-27 | Runtime recognition investigation | Main Agent | Investigated why zones 1 and 6 appeared to stop counting after 20 minutes | Evidence shows zones 1 and 6 remain occupied; frontend freezes at the one-time `time_alarm` event dwell value because no live tick is emitted/rendered | +| 2026-05-28 | Runtime vision small-object detection | Main Agent | Investigated why zones 1/2/5 placements were missed, zone 4 started timing, and zone 2 produced repeated short events | Evidence showed small dark objects were diluted by whole-region mean/texture while zone 4 reflection drove texture false positives | +| 2026-05-28 | Runtime vision small-object detection | Main Agent | Added dark-pixel fraction occupancy and bright-reflection filtering, then deployed to `192.168.5.206` | Current remote diagnostics show zones 1/2/5 occupied and zone 4 empty | +| 2026-05-29 | Same-frame trash confirmation | Main Agent | Investigated why zone 1 was removed and trash motion was visible but still escalated | Diagnostics showed `deposit=true` in the same frame as removal, but the engine applied trash deposits before creating `pending_disposal` | +| 2026-05-29 | Same-frame trash confirmation | Main Agent | Applied remaining trash deposits again after zone transitions and deployed to `192.168.5.206` | Same-frame removal plus visible trash ROI motion now emits `batch_pending_disposal` followed by `batch_discarded` | +| 2026-05-29 | Zone 4 reflection and stale progress | Main Agent | Investigated why zone 4 kept timing and why zone 9/10 progress bars appeared | Zone 4 had high bright reflection plus a small dark edge crossing the dark threshold; zone 9/10 came from historical events outside the current 1-8 config | +| 2026-05-29 | Zone 4 reflection and stale progress | Main Agent | Added high-bright/small-dark reflection suppression and filtered progress rows to current configured zones only | Remote summary now reports zones 1-8 all empty and frontend model returns no progress rows | +| 2026-05-29 | Event table dwell display | Main Agent | Investigated why a zone 1 `batch_started` row kept counting after the batch was consumed | Frontend displayed live dwell per row and did not know the same batch had a later terminal event | +| 2026-05-29 | Event table dwell display | Main Agent | Limited event-table live dwell to the latest non-terminal event per batch and deployed the Web container | Removed `batch_started` rows now keep their recorded dwell value while the terminal row shows final dwell | + +### Test Results + +| Time | Command | Result | Notes | +| --- | --- | --- | --- | +| 2026-05-26 | `PYTHONPATH=src python3 -m unittest tests/test_engine.py -v` | pass | 11 engine tests passed after v1.1 event model changes | +| 2026-05-26 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 20 full Python tests passed | +| 2026-05-26 | `PYTHONPATH=src python3 -m unittest tests/test_config.py -v` | pass | 6 config tests passed after numeric zone validation fixes | +| 2026-05-26 | `PYTHONPATH=src python3 -m unittest tests/test_manage_api.py -v` | pass | 7 manage API tests passed after warning summary fixes | +| 2026-05-26 | `node --test web/test/zone-state.test.js` | pass | 7 frontend zone-state tests passed | +| 2026-05-26 | `cd web && pnpm build` | pass | Vite production build passed | +| 2026-05-26 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 28 full Python tests passed after v1.1 config/API changes | +| 2026-05-26 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 30 full Python tests passed after final review fixes | +| 2026-05-26 | `node --test web/test/zone-state.test.js` | pass | 7 frontend zone-state tests passed after final review fixes | +| 2026-05-26 | `cd web && pnpm build` | pass | Vite production build passed after final review fixes | +| 2026-05-26 | `node --test web/test/zone-state.test.js` | pass | 14 frontend zone-state tests passed after homepage demo runtime and review fixes | +| 2026-05-26 | `cd web && pnpm build` | pass | Vite production build passed with latest homepage runtime asset | +| 2026-05-26 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 30 full Python tests passed after homepage runtime changes | +| 2026-05-26 | `docker compose --env-file cold-display-guard.env -f docker-compose.yml up -d --build cold-display-guard-web` | pass | Docker web image rebuilt and container restarted | +| 2026-05-26 | `node --test web/test/zone-state.test.js` | pass | 15 frontend zone-state tests passed after null-config startup fix | +| 2026-05-26 | `cd web && pnpm build` | pass | Vite production build passed after null-config startup fix | +| 2026-05-26 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 30 full Python tests passed after null-config startup fix | +| 2026-05-26 | `docker compose build cold-display-guard-api cold-display-guard-web` on `192.168.5.206` | pass | Built `cold-display-guard:dev` and `cold-display-guard-web:dev` | +| 2026-05-26 | `docker compose up -d` on `192.168.5.206` | pass | API, runtime, and web containers created; runtime exited due missing RTSP | +| 2026-05-26 | `curl http://192.168.5.206:19080/api/manage/health` | pass | API returned `{"status":"ok"}` | +| 2026-05-26 | `curl -I http://192.168.5.206:23000/` | pass | Web returned `HTTP/1.1 200 OK` | +| 2026-05-26 | `node --test web/test/zone-state.test.js` | pass | 16 frontend model tests passed after hiding demo runtime data | +| 2026-05-26 | `cd web && pnpm build` | pass | Vite production build passed with `index-D3qCb2DS.js` | +| 2026-05-26 | `docker compose up -d --build cold-display-guard-web` on `192.168.5.206` | pass | Remote Web and API containers restarted; API healthy, Web returned `HTTP/1.1 200 OK` | +| 2026-05-26 | `rg "演示数据|DEMO DATA|demo_batch|demo_camera" /private/tmp/cold-display-guard-remote-web.js` | pass | No matches in the deployed remote JS asset | +| 2026-05-27 | `docker ps -a --filter name=cold-display-guard` on `192.168.5.206` | pass | Runtime is `Up`; API is healthy; Web is up | +| 2026-05-27 | `tail -5 logs/runtime_diagnostics.jsonl` on `192.168.5.206` | pass | Runtime is writing fresh diagnostics; baseline became ready and all 10 zones reported counts | +| 2026-05-27 | `grep '"zone_id": "1"' logs/events.jsonl | tail -20` on `192.168.5.206` | pass | Zone 1 started `2026-05-27T09:23:43+08:00` and emitted `time_alarm` at `2026-05-27T09:43:48+08:00`; no removal event followed | +| 2026-05-27 | `grep '"zone_id": "6"' logs/events.jsonl | tail -20` on `192.168.5.206` | pass | Zone 6 started `2026-05-27T09:23:49+08:00` and emitted `time_alarm` at `2026-05-27T09:43:54+08:00`; no removal event followed | +| 2026-05-27 | `tail -5 logs/runtime_diagnostics.jsonl` on `192.168.5.206` | pass | Latest diagnostics still report zones 1 and 6 as `occupied: true` | +| 2026-05-27 | `node --test web/test/zone-state.test.js` | pass | 18 frontend model tests passed after live dwell timer fix | +| 2026-05-27 | `PYTHONPATH=src python3 -m unittest tests/test_vision.py -v` | pass | Vision regression tests passed for consecutive occupancy confirmation and raised reflection threshold | +| 2026-05-27 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 32 full Python tests passed after runtime vision changes | +| 2026-05-27 | `pnpm build` in `web/` | pass | Vite production build passed with `index-BkBYO5x5.js` | +| 2026-05-27 | `rsync -az --delete ... --exclude config/example.toml` to `192.168.5.206` | pass | Code synced while preserving remote RTSP/calibration config | +| 2026-05-27 | Remote config append `[runtime]` thresholds | pass | Added `occupancy_mean_delta = 45.0`, `occupancy_confirm_frames = 2`, and `empty_confirm_frames = 2` without changing RTSP | +| 2026-05-27 | `docker compose build cold-display-guard-api cold-display-guard-web` on `192.168.5.206` | pass | Built new API/runtime shared image and web image with live timer code | +| 2026-05-27 | `docker compose up -d --no-deps cold-display-guard-api cold-display-guard-web` on `192.168.5.206` | pass | API and Web restarted; runtime intentionally left running to avoid relearning baseline with items still present | +| 2026-05-27 | `curl` remote health and web index | pass | API returned healthy and Web serves `index-BkBYO5x5.js` | +| 2026-05-27 | `node --test web/test/zone-state.test.js` | pass | 20 frontend model tests passed after event-table live dwell and current-zone filtering | +| 2026-05-27 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 36 full Python tests passed after runtime state restore and seeded vision baseline | +| 2026-05-27 | `docker compose up -d --no-deps cold-display-guard-runtime cold-display-guard-api` on `192.168.5.206` | pass | Runtime restarted on new image while preserving active 1/6/7 timers from event history and prior baseline | +| 2026-05-27 | `tail -n 3 logs/runtime_diagnostics.jsonl` on `192.168.5.206` | pass | New diagnostics include `raw_occupied`/streaks; 1/6/7 occupied, 3/4/5/8 empty | +| 2026-05-27 | Remote summary after 12 seconds | pass | Event count stayed at 579; no new false events after runtime restart | +| 2026-05-28 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 39 full Python tests passed after dark-fraction occupancy and reflection filtering | +| 2026-05-28 | `node --test web/test/zone-state.test.js` | pass | 20 frontend model tests passed | +| 2026-05-28 | `pnpm build` in `web/` | pass | Vite production build passed with `index-DFRi3R8X.js` | +| 2026-05-28 | `rsync -az --delete ... --exclude config/example.toml` to `192.168.5.206` | pass | Code synced while preserving remote RTSP/calibration config | +| 2026-05-28 | Remote config runtime patch | pass | Added dark-fraction and bright-reflection runtime thresholds without printing or changing RTSP | +| 2026-05-28 | `docker compose build cold-display-guard-api` and `up -d --no-deps cold-display-guard-runtime cold-display-guard-api` on `192.168.5.206` | pass | API/runtime rebuilt and restarted on the new image | +| 2026-05-28 | Remote diagnostics/API summary | pass | Current counts show zones 1/2/5 occupied and zones 3/4/6/7/8 empty; zone 4 has reflection texture but dark fraction remains `0.0` | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_engine.BatchEngineTests.test_same_observation_removal_and_trash_motion_discards_alerted_batch -v` | red then pass | Reproduced and fixed same-frame trash motion being ignored for a newly pending alerted batch | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_main -v` | red then pass | Runtime restart state restore now uses dark-fraction/bright-reflection occupancy rules | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 41 full Python tests passed after same-frame trash confirmation and restore-rule fixes | +| 2026-05-29 | `node --test web/test/zone-state.test.js` | pass | 20 frontend model tests still passed | +| 2026-05-29 | `rsync -az --delete ... --exclude config/example.toml` to `192.168.5.206` | pass | Code synced while preserving remote RTSP/calibration config | +| 2026-05-29 | `docker compose build cold-display-guard-api` and `up -d --no-deps cold-display-guard-runtime cold-display-guard-api` on `192.168.5.206` | pass | API/runtime rebuilt and restarted on the same-frame trash confirmation fix | +| 2026-05-29 | Remote status and diagnostics check | pass | Runtime/API are up; API healthy; latest diagnostics are being written after restart | +| 2026-05-29 | Targeted reflection/progress tests | red then pass | Added regressions for bright reflection with small dark edge, runtime restore/API recompute using that rule, and hiding historical zone 9/10 progress | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 42 full Python tests passed after zone 4 reflection suppression | +| 2026-05-29 | `node --test web/test/zone-state.test.js` | pass | 21 frontend model tests passed after current-zone progress filtering | +| 2026-05-29 | `pnpm build` in `web/` | pass | Vite production build passed with `index-sJMxcaD6.js` | +| 2026-05-29 | `docker compose build cold-display-guard-api cold-display-guard-web` and `up -d --no-deps cold-display-guard-runtime cold-display-guard-api cold-display-guard-web` on `192.168.5.206` | pass | Runtime/API/Web rebuilt and restarted with reflection and progress fixes | +| 2026-05-29 | Remote API/diagnostics/frontend model verification | pass | API `latest_zone_counts` shows zones 1-8 all `0`; latest diagnostics show zone 4 `occupied: false`; model progress rows are empty | +| 2026-05-29 | `node --test web/test/zone-state.test.js` | red then pass | Added regression so `batch_started` rows stop live ticking after the same batch has a terminal event | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 42 Python tests still passed after frontend-only dwell display fix | +| 2026-05-29 | `pnpm build` in `web/` | pass | Vite production build passed with `index-BoXFyXbk.js` | +| 2026-05-29 | `docker compose build cold-display-guard-web` and `up -d --no-deps cold-display-guard-web` on `192.168.5.206` | pass | Remote Web rebuilt and serves `index-BoXFyXbk.js` | +| 2026-05-29 | Remote frontend model verification for `batch_000473` | pass | `batch_started` displays `0` seconds and `batch_consumed` displays final `64` seconds | +| 2026-05-29 | API stable-occupancy regression tests | red then pass | `latest_zone_counts` now uses runtime's debounced `occupied` state before raw threshold fallback | +| 2026-05-29 | Trash sustained-motion regression test | red then pass | Two consecutive moderate trash motions below the strong threshold now confirm disposal | +| 2026-05-29 | Runtime restore stable-occupancy regression test | red then pass | Restart restore now preserves debounced occupancy for threshold-edge zones | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 45 full Python tests passed | +| 2026-05-29 | `node --test web/test/zone-state.test.js` | pass | 22 frontend model tests passed | +| 2026-05-29 | `docker compose build cold-display-guard-runtime cold-display-guard-api` and `up -d --no-deps cold-display-guard-runtime cold-display-guard-api` on `192.168.5.206` | pass | Runtime/API rebuilt and restarted with stable count and sustained trash-motion fixes | +| 2026-05-29 | Remote API/diagnostics verification | pass | API healthy; runtime writes `motion_streak`/`strong_motion`/`sustained_motion`; API summary matches stable zone counts | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_vision.VisionTests.test_runtime_vision_defaults_raise_brightness_reflection_threshold -v` | red then pass | Default runtime sampling is now dense enough for small ROIs: `sample_stride_pixels = 4` | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 45 full Python tests passed after dense sampling default | +| 2026-05-29 | `node --test web/test/zone-state.test.js` | pass | 22 frontend model tests passed | +| 2026-05-29 | Remote runtime/API rebuild and config patch | pass | Remote config now has `sample_stride_pixels = 4`; runtime/API restarted | +| 2026-05-29 | Remote zone 1 diagnostics verification | pass | Zone 1 stayed occupied for 16 consecutive post-deploy checks; API summary reports zone 1 as occupied | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_engine.BatchEngineTests.test_same_observation_trash_motion_discards_multiple_newly_pending_batches -v` | red then pass | Same-frame trash motion now discards multiple alerted batches that clear together | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_vision.VisionTests.test_detector_allows_quick_sequential_strong_trash_motions -v` | red then pass | Quick sequential strong trash motions are no longer suppressed by the old 8-second cooldown | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 47 full Python tests passed after multi-zone trash confirmation fix | +| 2026-05-29 | `node --test web/test/zone-state.test.js` | pass | 22 frontend model tests passed | +| 2026-05-29 | Remote runtime/API rebuild and config patch | pass | Remote config now has `trash_motion_cooldown_seconds = 3`; diagnostics include `in_cooldown` | + +### Bug Loop + +| Batch Workstream | Bug | Fix Attempt | Retest Result | +| --- | --- | --- | --- | +| Backend event model | Review agent found `severity` missing from some events such as `batch_started` and `mixed_batch_violation` | Added regression assertions and made `_event()` assign default severity by event type | Resolved; re-review passed | +| Config and management API | Review agent found `zone_count` could conflict with numeric `zone_ids`, and pending disposal was counted as an upgraded warning | Added regression tests; validate `zone_count == len(zone_ids)` and count only `warning_escalated`/legacy `_violation` as warning events | Resolved; target tests and full Python tests passed | +| Frontend management console | Frontend agent found old `rows/cols` configs without `zone_ids` lost legacy polygons, and old labels could be saved back to numeric zones | Added regression tests; derive legacy `rNcM` source IDs from `rows/cols` and normalize labels to `区域 N` | Resolved; re-review passed | +| Documentation and final review | Final review found removal observations at the threshold skipped `time_alarm` before pending disposal | Added regression tests and moved time-alarm application before zone-clear transitions | Resolved; Python tests passed | +| Documentation and final review | Final review found partial calibration saves could shrink `zone_count` to the number of completed polygons | Added frontend payload `layout`, backend merge support for target numeric zone IDs, and regression tests preserving existing polygons/count | Resolved; Python and frontend tests passed | +| Homepage demo runtime display | Review found real progress kept maximum dwell time instead of the latest event | Added latest-event regression tests and selected per-zone progress by timestamp or event order | Resolved; frontend tests passed | +| Homepage demo runtime display | Review found event rows missing threshold values fell back to 1200 instead of config threshold | Added regression test and inherited the configured threshold | Resolved; frontend tests passed | +| Homepage demo runtime display | Review found `latest_zone_counts` was interpolated into `innerHTML` without escaping | Added shared `escapeHtml()` and escaped each zone-count fragment | Resolved; final review passed | +| Homepage demo runtime display | Browser showed static calibration page because runtime display read `config.thresholds` while `config` was still `null` | Added regression test for `config: null` and normalized missing config to `{}` before deriving thresholds | Resolved; Chrome verification shows runtime demo homepage | +| Remote Docker deployment | SSH to `xiaozheng@192.168.5.206` failed with `Permission denied (publickey,password)` | Tried normal SSH and escalated SSH using the same host/user; local key was not accepted and no interactive password was available | Blocked until credentials are provided or the local public key is authorized on the remote host | +| Remote Docker deployment | Initial `docker compose up -d --build` attempted to pull `cold-display-guard:dev` before a local image existed | Stopped the hanging SSH command, confirmed no existing local image, then explicitly ran `docker compose build cold-display-guard-api cold-display-guard-web` before `up -d` | Resolved; images built and API/Web started | +| Remote Docker deployment | `cold-display-guard-runtime` restarted repeatedly | Checked runtime logs; root cause was `ValueError: stream.rtsp_url is required` because `config/example.toml` has an empty RTSP URL | Stopped runtime container until an RTSP URL is configured | +| Hide demo runtime data | Empty runtime data still generated synthetic demo summary/events/progress | Added failing frontend tests, then replaced demo fallback with empty summary, empty events, and empty progress rows | Resolved; frontend tests and build passed | +| Hide demo runtime data | Legacy `event.demo` rows or `cold_display_guard_demo` summaries could still surface if old data existed | Added failing regression test, filtered demo-marked events/summaries, and removed visible demo labels from event rendering | Resolved; remote JS asset has no visible demo-data strings | +| Runtime recognition startup | Runtime restarted after RTSP was configured but exited with `ZoneInfoNotFoundError: 'No time zone found with key shanghai'` | Updated remote config and local example config to `timezone = "Asia/Shanghai"`, then restarted runtime | Resolved; runtime remains up and writes diagnostics | +| Runtime recognition investigation | Frontend dwell display stops near the 20-minute alarm even while the item remains present | Added live dwell computation from `started_at` for non-ended batches, one-second frontend re-render, five-second runtime-data polling, and broader event fetch limit | Resolved; Web/API deployed | +| Runtime vision false positives | Reflections in empty zones crossed the old mean-luma threshold and triggered occupancy/alarm | Raised default `occupancy_mean_delta` to `55.0`, added 2-frame occupied/empty confirmation, recomputed current counts in API from diagnostics, restored runtime state from events/diagnostics, and restarted runtime | Resolved; latest diagnostics show only 1/6/7 occupied | +| Runtime vision small dark objects | Zones 1/2/5 contained compact dark objects that did not always exceed whole-region mean/texture thresholds; zone 4 bright reflection exceeded texture threshold; zone 2 flickered around the old threshold and created repeated short batches | Added dark-fraction metrics, required dark evidence for texture occupancy, ignored bright reflection without dark evidence, and added manage API recomputation with the same rule | Resolved; latest remote diagnostics keep 1/2/5 occupied, 4 empty, and zone 2 no longer produces consume/start flicker after deployment | +| Same-frame trash confirmation | Trash motion in the visible trash ROI could occur in the exact frame where an alerted zone was removed; the engine consumed trash deposits before it created the new pending-disposal batch, so the deposit was lost and the batch later escalated | Added a regression test and reapplied leftover trash deposits after zone transitions; also updated runtime restore to use the dark-fraction rules before restart | Resolved for future events; existing historical `warning_escalated` rows are not rewritten | +| Zone 4 reflection and stale zone 9/10 progress | Zone 4 reflection produced both bright pixels and a small dark edge above the dark-object threshold; old zone 9/10 events were still eligible for progress rows because missing live counts were treated as occupied | Added a reflection classifier for high-bright/small-dark patterns and required progress rows to belong to current configured food zones with explicit live occupancy when diagnostics are present | Resolved; remote currently shows no occupied zones and no progress rows | +| Event table live dwell after removal | Earlier `batch_started` rows had no `ended_at`, so the frontend kept applying live dwell even after a later event in the same batch ended it | Event rows now compute live dwell only for the latest non-terminal event in each batch | Resolved; removed batches no longer keep counting in their `batch_started` row | +| Zone 2 progress flicker | API summary recomputed latest zone counts from raw metrics, bypassing runtime's occupied/empty confirmation; threshold-edge zone 2 could flip to `0` while runtime still held stable occupied | Summary now prefers per-zone stable `occupied` from diagnostics, then falls back to raw recompute only for older diagnostics | Resolved; API counts align with runtime stable state | +| Zone 2 disposal escalated after trash drop | The zone 2 batch was removed and entered pending disposal, but trash `motion_delta` peaked around `10.1`, below the one-frame threshold `18`, so no trash deposit was counted before the deadline | Added sustained trash-motion confirmation: two consecutive moderate motions at `>= 8.0` count as a deposit, while the strong one-frame threshold remains | Resolved for future events; historical `batch_000474` warning row is not rewritten | +| Runtime restart can drop threshold-edge timers | Runtime restore used raw threshold recompute instead of stable `occupied`, so a restart during a one-frame raw dip could lose an active timer | Restore now uses stable `occupied` when present and keeps raw recompute only as a fallback | Resolved; regression test covers zone 2-style flicker | +| Zone 1 timer reset | Zone 1's ROI had too few samples with the default stride `8`; dark evidence jumped between `0.0714` and `0.0357/0`, causing two occupied frames followed by two empty frames and repeated short batches | Reduced default and remote `sample_stride_pixels` to `4` so small ROI/object evidence is less quantized | Resolved in current verification; zone 1 remains continuously occupied after deploy | +| Zone 1/4 disposal missed after zone 2 discard | Zone 2 generated a trash deposit at `14:32:13`; zone 1/4 cleared at `14:32:19`, but their strong trash motion was inside the old 8-second cooldown, and one same-frame deposit could only discard one newly pending batch | Reduced trash cooldown to 3 seconds and let one same-frame trash motion discard all newly pending alerted batches from that observation | Resolved for future events; historical zone 1/4 rows are not rewritten | diff --git a/prototype/custom-zones/index.html b/prototype/custom-zones/index.html new file mode 100644 index 0000000..f43e604 --- /dev/null +++ b/prototype/custom-zones/index.html @@ -0,0 +1,1110 @@ + + + + + + 自定义区域标定样例 - Cold Display Guard + + + +
+
+
+
CG
+
+
CUSTOM ZONE PROTOTYPE
+
冷藏展示柜自定义区域样例
+
+
+ +
+ + 原型服务:127.0.0.1:27000 +
+
+ +
+ + +
+
+ RTSP 抓帧标定画布 + + +
+
+
+
+
垃圾桶 ROI
+ + + +
+
+
+ + +
+
+ + + + diff --git a/src/cold_display_guard/config.py b/src/cold_display_guard/config.py index 3aca8c0..47af412 100644 --- a/src/cold_display_guard/config.py +++ b/src/cold_display_guard/config.py @@ -9,6 +9,7 @@ from cold_display_guard.models import DEFAULT_ZONE_IDS, EngineSettings DEFAULT_CONFIG_PATH = Path("config/example.toml") +MAX_CUSTOM_FOOD_ZONES = 10 def load_settings(path: str | Path) -> EngineSettings: @@ -16,7 +17,7 @@ def load_settings(path: str | Path) -> EngineSettings: thresholds: dict[str, Any] = data.get("thresholds", {}) layout: dict[str, Any] = data.get("layout", {}) - zone_ids = tuple(layout.get("zone_ids") or _zone_ids_from_rows_cols(layout)) + zone_ids = _zone_ids_from_layout(layout) if not zone_ids: zone_ids = DEFAULT_ZONE_IDS @@ -56,32 +57,61 @@ def merge_calibration( data: dict[str, Any], zones: list[dict[str, Any]], trash_roi: list[list[float]] | None, + layout_update: dict[str, Any] | None = None, ) -> dict[str, Any]: merged = deepcopy(data) + incoming_numeric_zone_ids = _incoming_numeric_zone_ids(layout_update) valid_zones: dict[str, dict[str, Any]] = {} for zone in zones: zone_id = str(zone.get("id", "")).strip() + if zone_id.lower() == "trash": + continue polygon = _normalize_points(zone.get("polygon", [])) if not zone_id or len(polygon) < 3: continue - valid_zones[zone_id] = {"id": zone_id, "polygon": polygon} + valid_zone: dict[str, Any] = {"id": zone_id, "polygon": polygon} + label = str(zone.get("label", "")).strip() + if zone_id.isdecimal(): + valid_zone["label"] = f"区域 {int(zone_id)}" + elif label: + valid_zone["label"] = label + valid_zones[zone_id] = valid_zone - if valid_zones: - existing_by_id = { - str(zone.get("id", "")).strip(): zone - for zone in merged.get("zones", []) - if str(zone.get("id", "")).strip() - } - existing_by_id.update(valid_zones) + if valid_zones or incoming_numeric_zone_ids: layout = merged.setdefault("layout", {}) - zone_order = [str(item) for item in layout.get("zone_ids", []) if str(item) in existing_by_id] - for zone_id in valid_zones: - if zone_id not in zone_order: - zone_order.append(zone_id) - if not zone_order: - zone_order = list(valid_zones) - layout["zone_ids"] = zone_order - merged["zones"] = [existing_by_id[zone_id] for zone_id in zone_order if zone_id in existing_by_id] + existing_numeric_zone_ids = _existing_numeric_zone_ids(layout) + if incoming_numeric_zone_ids or existing_numeric_zone_ids or _is_numeric_zone_ids(valid_zones): + zone_order = _numeric_calibration_zone_order( + incoming_numeric_zone_ids, + existing_numeric_zone_ids, + valid_zones, + ) + _validate_numeric_zone_ids(zone_order) + existing_by_id = { + str(zone.get("id", "")).strip(): zone + for zone in merged.get("zones", []) + if str(zone.get("id", "")).strip() + } + layout.pop("rows", None) + layout.pop("cols", None) + layout["zone_count"] = len(zone_order) + layout["zone_ids"] = zone_order + merged["zones"] = _ordered_normalized_zones(zone_order, valid_zones, existing_by_id) + else: + existing_by_id = { + str(zone.get("id", "")).strip(): zone + for zone in merged.get("zones", []) + if str(zone.get("id", "")).strip() + } + existing_by_id.update(valid_zones) + zone_order = [str(item) for item in layout.get("zone_ids", []) if str(item) in existing_by_id] + for zone_id in valid_zones: + if zone_id not in zone_order: + zone_order.append(zone_id) + if not zone_order: + zone_order = list(valid_zones) + layout["zone_ids"] = zone_order + merged["zones"] = [existing_by_id[zone_id] for zone_id in zone_order if zone_id in existing_by_id] if trash_roi is not None: normalized_roi = _normalize_points(trash_roi) @@ -123,12 +153,18 @@ def format_config_document(data: dict[str, Any]) -> str: lines.append("") layout = data.get("layout", {}) - zone_ids = [str(item) for item in layout.get("zone_ids", DEFAULT_ZONE_IDS)] - rows = int(layout.get("rows", 2)) - cols = int(layout.get("cols", 4)) + zone_ids = list(_zone_ids_from_layout(layout)) + if not zone_ids: + zone_ids = list(DEFAULT_ZONE_IDS) + numeric_layout = _is_numeric_zone_ids(zone_ids) lines.append("[layout]") - lines.append(f"rows = {rows}") - lines.append(f"cols = {cols}") + if numeric_layout: + lines.append(f"zone_count = {len(zone_ids)}") + else: + rows = int(layout.get("rows", 2)) + cols = int(layout.get("cols", 4)) + lines.append(f"rows = {rows}") + lines.append(f"cols = {cols}") lines.append(f"zone_ids = {_format_string_array(zone_ids)}") lines.append("") @@ -139,6 +175,9 @@ def format_config_document(data: dict[str, Any]) -> str: continue lines.append("[[zones]]") lines.append(f'id = "{_escape(zone_id)}"') + label = str(zone.get("label", "")).strip() + if label: + lines.append(f'label = "{_escape(label)}"') lines.append(f"polygon = {_format_points(polygon)}") lines.append("") @@ -156,6 +195,17 @@ def format_config_document(data: dict[str, Any]) -> str: return "\n".join(lines) +def _zone_ids_from_layout(layout: dict[str, Any]) -> tuple[str, ...]: + zone_ids = _coerce_zone_ids(layout.get("zone_ids")) + if zone_ids: + _validate_numeric_zone_ids(zone_ids) + _validate_zone_count_matches_ids(layout, zone_ids) + return tuple(zone_ids) + if "zone_count" in layout: + return tuple(_numeric_zone_ids_from_count(layout.get("zone_count"))) + return _zone_ids_from_rows_cols(layout) + + def _zone_ids_from_rows_cols(layout: dict[str, Any]) -> tuple[str, ...]: rows = int(layout.get("rows", 0)) cols = int(layout.get("cols", 0)) @@ -164,6 +214,108 @@ def _zone_ids_from_rows_cols(layout: dict[str, Any]) -> tuple[str, ...]: return tuple(f"r{row}c{col}" for row in range(1, rows + 1) for col in range(1, cols + 1)) +def _incoming_numeric_zone_ids(layout_update: dict[str, Any] | None) -> list[str]: + if not isinstance(layout_update, dict): + return [] + zone_ids = list(_zone_ids_from_layout(layout_update)) + if not zone_ids: + return [] + if not _is_numeric_zone_ids(zone_ids): + raise ValueError("calibration layout zone IDs must be numeric") + return zone_ids + + +def _existing_numeric_zone_ids(layout: dict[str, Any]) -> list[str]: + zone_ids = list(_zone_ids_from_layout(layout)) + if not _is_numeric_zone_ids(zone_ids): + return [] + return zone_ids + + +def _ordered_normalized_zones( + zone_order: list[str], + valid_zones: dict[str, dict[str, Any]], + existing_by_id: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + zones: list[dict[str, Any]] = [] + for zone_id in zone_order: + zone = _normalized_zone(valid_zones.get(zone_id) or existing_by_id.get(zone_id)) + if zone is not None: + zones.append(zone) + return zones + + +def _numeric_calibration_zone_order( + incoming_numeric_zone_ids: list[str], + existing_numeric_zone_ids: list[str], + valid_zones: dict[str, dict[str, Any]], +) -> list[str]: + if incoming_numeric_zone_ids: + return incoming_numeric_zone_ids + valid_zone_ids = sorted(valid_zones, key=int) if _is_numeric_zone_ids(valid_zones) else [] + if existing_numeric_zone_ids and valid_zone_ids: + if set(valid_zone_ids).issubset(set(existing_numeric_zone_ids)): + return existing_numeric_zone_ids + return valid_zone_ids + return existing_numeric_zone_ids or valid_zone_ids + + +def _normalized_zone(zone: dict[str, Any] | None) -> dict[str, Any] | None: + if zone is None: + return None + zone_id = str(zone.get("id", "")).strip() + polygon = _normalize_points(zone.get("polygon", [])) + if not zone_id or len(polygon) < 3: + return None + normalized: dict[str, Any] = {"id": zone_id, "polygon": polygon} + label = str(zone.get("label", "")).strip() + if zone_id.isdecimal(): + normalized["label"] = f"区域 {int(zone_id)}" + elif label: + normalized["label"] = label + return normalized + + +def _coerce_zone_ids(value: Any) -> list[str]: + if not isinstance(value, list | tuple): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + +def _numeric_zone_ids_from_count(value: Any) -> list[str]: + count = int(value) + if count < 1 or count > MAX_CUSTOM_FOOD_ZONES: + raise ValueError(f"food zone count must be 1 to {MAX_CUSTOM_FOOD_ZONES}") + return [str(index) for index in range(1, count + 1)] + + +def _is_numeric_zone_ids(zone_ids: Any) -> bool: + return bool(zone_ids) and all(str(zone_id).isdecimal() for zone_id in zone_ids) + + +def _validate_numeric_zone_ids(zone_ids: list[str] | tuple[str, ...]) -> None: + numeric_ids = [zone_id for zone_id in zone_ids if zone_id.isdecimal()] + if not numeric_ids: + return + if len(numeric_ids) != len(zone_ids): + raise ValueError("numeric food zone IDs must not be mixed with legacy zone IDs") + if len(zone_ids) < 1 or len(zone_ids) > MAX_CUSTOM_FOOD_ZONES: + raise ValueError(f"food zone count must be 1 to {MAX_CUSTOM_FOOD_ZONES}") + expected = [str(index) for index in range(1, len(zone_ids) + 1)] + if list(zone_ids) != expected: + raise ValueError("numeric food zone IDs must be contiguous from 1") + + +def _validate_zone_count_matches_ids(layout: dict[str, Any], zone_ids: list[str]) -> None: + if "zone_count" not in layout: + return + count = int(layout["zone_count"]) + if count < 1 or count > MAX_CUSTOM_FOOD_ZONES: + raise ValueError(f"food zone count must be 1 to {MAX_CUSTOM_FOOD_ZONES}") + if count != len(zone_ids): + raise ValueError("zone_count must match zone_ids length") + + def _normalize_points(value: Any) -> list[list[float]]: points: list[list[float]] = [] if not isinstance(value, list): diff --git a/src/cold_display_guard/engine.py b/src/cold_display_guard/engine.py index ca7c094..77ac601 100644 --- a/src/cold_display_guard/engine.py +++ b/src/cold_display_guard/engine.py @@ -18,9 +18,13 @@ class BatchEngine: def process(self, observation: Observation) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] zone_counts = self._normalized_counts(observation.zone_counts) + previous_zone_counts = dict(self._zone_counts) + remaining_trash_deposits = observation.trash_deposit_count events.extend(self._expire_pending_disposal(observation.ts)) - events.extend(self._apply_trash_deposits(observation.ts, observation.trash_deposit_count)) + trash_events = self._apply_trash_deposits(observation.ts, remaining_trash_deposits) + remaining_trash_deposits = max(0, remaining_trash_deposits - len(trash_events)) + events.extend(trash_events) appeared_zones = [ zone_id @@ -30,6 +34,9 @@ class BatchEngine: if appeared_zones and self.pending_disposal: events.extend(self._mark_pending_as_returned(observation.ts, appeared_zones)) + events.extend(self._apply_time_alarms(observation.ts, previous_zone_counts)) + + pending_count_before_zone_transitions = len(self.pending_disposal) for zone_id, new_count in zone_counts.items(): previous_count = self._zone_counts.get(zone_id, 0) if previous_count == 0 and new_count > 0: @@ -59,6 +66,11 @@ class BatchEngine: self._zone_counts[zone_id] = new_count + newly_pending_count = max(0, len(self.pending_disposal) - pending_count_before_zone_transitions) + trash_deposits_to_apply = remaining_trash_deposits + if remaining_trash_deposits > 0 and newly_pending_count > 1: + trash_deposits_to_apply = max(remaining_trash_deposits, newly_pending_count) + events.extend(self._apply_trash_deposits(observation.ts, trash_deposits_to_apply)) return events def _normalized_counts(self, incoming: dict[str, int]) -> dict[str, int]: @@ -72,6 +84,59 @@ class BatchEngine: self._next_batch_index += 1 return batch_id + def restore_from_events(self, events: list[dict[str, Any]], active_zone_counts: dict[str, int] | None = None) -> None: + active_counts = {str(zone_id): max(0, int(count)) for zone_id, count in (active_zone_counts or {}).items()} + self.active_by_zone.clear() + self.pending_disposal.clear() + self.closed_batches.clear() + self._zone_counts = {zone_id: 0 for zone_id in self.settings.zone_ids} + max_batch_index = 0 + + for event in events: + batch_id = str(event.get("batch_id", "")) + max_batch_index = max(max_batch_index, batch_index(batch_id)) + zone_id = str(event.get("zone_id", "")) + if zone_id not in self._zone_counts: + continue + event_name = str(event.get("event", "")) + if event_name in {"batch_started", "batch_count_changed", "mixed_batch_violation", "time_alarm"}: + if active_zone_counts is not None and active_counts.get(zone_id, 0) <= 0: + self.active_by_zone.pop(zone_id, None) + self._zone_counts[zone_id] = 0 + continue + batch = self._batch_from_event(event) + if batch is None: + continue + self.active_by_zone[zone_id] = batch + self._zone_counts[zone_id] = max(1, active_counts.get(zone_id, batch.last_count)) + elif event_name in {"batch_consumed", "batch_pending_disposal", "batch_discarded", "warning_escalated", "overdue_return_violation"} or event_name.endswith("_violation"): + self.active_by_zone.pop(zone_id, None) + self._zone_counts[zone_id] = 0 + + if active_zone_counts is not None: + for zone_id in self._zone_counts: + self._zone_counts[zone_id] = active_counts.get(zone_id, 0) + self._next_batch_index = max(self._next_batch_index, max_batch_index + 1) + + def _batch_from_event(self, event: dict[str, Any]) -> Batch | None: + batch_id = str(event.get("batch_id", "")).strip() + zone_id = str(event.get("zone_id", "")).strip() + started_at = parse_event_datetime(event.get("started_at")) + if not batch_id or not zone_id or started_at is None: + return None + batch = Batch( + batch_id=batch_id, + zone_id=zone_id, + started_at=started_at, + last_count=max(1, int(event.get("current_count", 1) or 1)), + state=str(event.get("state", "active") or "active"), + ) + batch.alerted_at = parse_event_datetime(event.get("alerted_at")) + if batch.alerted_at is not None: + batch.state = "alerted" + batch.dwell_seconds = max(0, int(event.get("dwell_seconds", 0) or 0)) + return batch + def _start_batch(self, zone_id: str, count: int, when: datetime) -> dict[str, Any]: batch = Batch( batch_id=self._next_batch_id(), @@ -90,16 +155,37 @@ class BatchEngine: batch.dwell_seconds = batch.current_dwell_seconds(when) batch.ended_at = when - if batch.dwell_seconds >= self.settings.max_dwell_seconds: + if batch.alerted_at is not None or batch.dwell_seconds >= self.settings.max_dwell_seconds: batch.state = "pending_disposal" batch.pending_since = when batch.disposal_deadline = when + self.settings.trash_confirmation_window self.pending_disposal.append(batch) - return self._event("batch_pending_disposal", when, batch) + return self._event("batch_pending_disposal", when, batch, severity="warning") batch.state = "consumed" self.closed_batches.append(batch) - return self._event("batch_consumed", when, batch) + return self._event("batch_consumed", when, batch, severity="info") + + def _apply_time_alarms(self, when: datetime, zone_counts: dict[str, int]) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for zone_id, batch in self.active_by_zone.items(): + if batch.alerted_at is not None: + continue + dwell_seconds = batch.current_dwell_seconds(when) + if dwell_seconds < self.settings.max_dwell_seconds: + continue + batch.state = "alerted" + batch.alerted_at = when + events.append( + self._event( + "time_alarm", + when, + batch, + severity="alarm", + current_count=zone_counts.get(zone_id, batch.last_count), + ) + ) + return events def _mark_mixed_batch( self, @@ -149,7 +235,7 @@ class BatchEngine: batch = self.pending_disposal.pop(0) batch.state = "discarded" self.closed_batches.append(batch) - events.append(self._event("batch_discarded", when, batch)) + events.append(self._event("batch_discarded", when, batch, severity="info")) deposit_count -= 1 return events @@ -158,15 +244,16 @@ class BatchEngine: still_pending: list[Batch] = [] for batch in self.pending_disposal: if batch.disposal_deadline is not None and when > batch.disposal_deadline: - batch.state = "violation" + batch.state = "warning" batch.violation_reasons.add("missing_disposal") self.closed_batches.append(batch) events.append( self._event( - "missing_disposal_violation", + "warning_escalated", when, batch, - reason="trash_deposit_not_observed_before_deadline", + severity="warning", + reason="alarmed_batch_removed_without_trash_deposit", ) ) else: @@ -177,14 +264,22 @@ class BatchEngine: def _event(self, event_name: str, when: datetime, batch: Batch, **extra: Any) -> dict[str, Any]: payload: dict[str, Any] = { "event": event_name, + "severity": self._event_severity(event_name), "ts": when.isoformat(), "camera_id": self.settings.camera_id, "zone_id": batch.zone_id, + "zone_label": self._zone_label(batch.zone_id), "batch_id": batch.batch_id, "state": batch.state, "started_at": batch.started_at.isoformat(), "dwell_seconds": batch.current_dwell_seconds(when), + "max_dwell_seconds": self.settings.max_dwell_seconds, } + zone_index = self._zone_index(batch.zone_id) + if zone_index is not None: + payload["zone_index"] = zone_index + if batch.alerted_at is not None: + payload["alerted_at"] = batch.alerted_at.isoformat() if batch.ended_at is not None: payload["ended_at"] = batch.ended_at.isoformat() if batch.disposal_deadline is not None: @@ -193,3 +288,41 @@ class BatchEngine: payload["violation_reasons"] = sorted(batch.violation_reasons) payload.update(extra) return payload + + def _event_severity(self, event_name: str) -> str: + if event_name == "time_alarm": + return "alarm" + if event_name in {"warning_escalated", "batch_pending_disposal"}: + return "warning" + if event_name.endswith("_violation"): + return "warning" + return "info" + + def _zone_index(self, zone_id: str) -> int | None: + if zone_id.isdecimal(): + return int(zone_id) + return None + + def _zone_label(self, zone_id: str) -> str: + zone_index = self._zone_index(zone_id) + if zone_index is None: + return zone_id + return f"区域 {zone_index}" + + +def batch_index(batch_id: str) -> int: + try: + return int(str(batch_id).rsplit("_", maxsplit=1)[1]) + except (IndexError, ValueError): + return 0 + + +def parse_event_datetime(value: Any) -> datetime | None: + if isinstance(value, datetime): + return value + if not value: + return None + try: + return datetime.fromisoformat(str(value)) + except ValueError: + return None diff --git a/src/cold_display_guard/main.py b/src/cold_display_guard/main.py index 6657435..1a5af49 100644 --- a/src/cold_display_guard/main.py +++ b/src/cold_display_guard/main.py @@ -11,7 +11,13 @@ from cold_display_guard.config import load_config_document, load_settings, resol from cold_display_guard.engine import BatchEngine from cold_display_guard.frame_source import FrameCaptureError, RTSPFrameSource from cold_display_guard.models import Observation -from cold_display_guard.vision import ZoneOccupancyDetector, load_regions, load_runtime_vision_settings +from cold_display_guard.vision import ( + RegionMetrics, + ZoneOccupancyDetector, + load_regions, + load_runtime_vision_settings, + metrics_indicate_occupied, +) def main() -> int: @@ -57,8 +63,15 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) -> height=frame_height, timeout_seconds=capture_timeout_seconds, ) - detector = ZoneOccupancyDetector(regions, trash_region, load_runtime_vision_settings(config)) + vision_settings = load_runtime_vision_settings(config) + detector = ZoneOccupancyDetector(regions, trash_region, vision_settings) engine = BatchEngine(settings) + baseline_seed, active_zone_counts = restore_runtime_state(diagnostics_path, config) + if baseline_seed: + detector.seed_baseline(baseline_seed) + if active_zone_counts: + detector.seed_occupancy(active_zone_counts) + engine.restore_from_events(load_jsonl_tail(event_path, 2000), active_zone_counts=active_zone_counts) event_path.parent.mkdir(parents=True, exist_ok=True) diagnostics_path.parent.mkdir(parents=True, exist_ok=True) @@ -118,5 +131,88 @@ def append_jsonl(path: Path, payloads: list[dict]) -> None: handle.write("\n") +def restore_runtime_state(diagnostics_path: Path, config: dict) -> tuple[dict[str, RegionMetrics], dict[str, int]]: + latest = load_jsonl_tail(diagnostics_path, 1) + if not latest: + return {}, {} + item = latest[-1] + diagnostics = item.get("diagnostics") + if not isinstance(diagnostics, dict): + return {}, latest_zone_counts_from_item(item) + zones = diagnostics.get("zones") + if not isinstance(zones, dict): + return {}, latest_zone_counts_from_item(item) + + settings = load_runtime_vision_settings(config) + baselines: dict[str, RegionMetrics] = {} + zone_counts: dict[str, int] = {} + for zone_id, metrics in zones.items(): + if not isinstance(metrics, dict): + continue + region_id = str(zone_id) + baseline_mean = numeric_metric(metrics.get("baseline_mean_luma")) + baseline_texture = numeric_metric(metrics.get("baseline_texture")) + baseline_dark_fraction = numeric_metric(metrics.get("baseline_dark_fraction")) or 0.0 + baseline_bright_fraction = numeric_metric(metrics.get("baseline_bright_fraction")) or 0.0 + if baseline_mean is not None and baseline_texture is not None: + baselines[region_id] = RegionMetrics( + mean_luma=baseline_mean, + texture=baseline_texture, + sample_count=1, + dark_fraction=baseline_dark_fraction, + bright_fraction=baseline_bright_fraction, + ) + + stable_occupied = metrics.get("occupied") + if isinstance(stable_occupied, bool): + zone_counts[region_id] = 1 if stable_occupied else 0 + continue + + mean_delta = numeric_metric(metrics.get("mean_delta")) + texture_delta = numeric_metric(metrics.get("texture_delta")) + if mean_delta is None or texture_delta is None: + continue + dark_fraction = numeric_metric(metrics.get("dark_fraction")) + bright_fraction = numeric_metric(metrics.get("bright_fraction")) or 0.0 + occupied = metrics_indicate_occupied( + settings, + mean_delta, + texture_delta, + dark_fraction=dark_fraction, + baseline_dark_fraction=baseline_dark_fraction, + bright_fraction=bright_fraction, + ) + zone_counts[region_id] = 1 if occupied else 0 + return baselines, zone_counts or latest_zone_counts_from_item(item) + + +def latest_zone_counts_from_item(item: dict) -> dict[str, int]: + zone_counts = item.get("zone_counts") + if not isinstance(zone_counts, dict): + return {} + return {str(zone_id): max(0, int(count)) for zone_id, count in zone_counts.items()} + + +def numeric_metric(value: object) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None + + +def load_jsonl_tail(path: Path, limit: int) -> list[dict]: + if not path.exists(): + return [] + items: list[dict] = [] + for line in path.read_text(encoding="utf-8").splitlines()[-limit:]: + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + items.append(payload) + return items + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/src/cold_display_guard/manage_api.py b/src/cold_display_guard/manage_api.py index 287e28e..3b266a2 100644 --- a/src/cold_display_guard/manage_api.py +++ b/src/cold_display_guard/manage_api.py @@ -18,6 +18,7 @@ from cold_display_guard.config import ( resolve_project_root, save_config_document, ) +from cold_display_guard.vision import load_runtime_vision_settings, metrics_indicate_occupied PROJECT_TYPE = "cold_display_guard" @@ -119,9 +120,17 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]: if not isinstance(zones, list): self._send_json({"error": "zones must be a list"}, HTTPStatus.BAD_REQUEST) return + layout = payload.get("layout") + if layout is not None and not isinstance(layout, dict): + self._send_json({"error": "layout must be an object"}, HTTPStatus.BAD_REQUEST) + return trash_roi = trash.get("roi") if isinstance(trash, dict) else None data = load_config_document(ctx.config_path) - merged = merge_calibration(data, zones, trash_roi) + try: + merged = merge_calibration(data, zones, trash_roi, layout) + except ValueError as exc: + self._send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + return save_config_document(ctx.config_path, merged) self._send_json(config_payload(ctx)) @@ -238,24 +247,40 @@ def config_payload(ctx: ManageContext) -> dict[str, Any]: def build_summary(ctx: ManageContext) -> dict[str, Any]: + config = load_config_document(ctx.config_path) events = load_events(ctx, MAX_EVENT_LINES) diagnostics = load_diagnostics(ctx, MAX_EVENT_LINES) counts: dict[str, int] = {} last_event_time = "" latest_alert = "" + alert_count = 0 + warning_count = 0 + violation_count = 0 for event in events: event_name = str(event.get("event", "unknown")) + severity = str(event.get("severity", "")).lower() counts[event_name] = counts.get(event_name, 0) + 1 ts = str(event.get("ts", "")) if ts: last_event_time = ts - if event_name.endswith("_violation"): + is_alarm = severity == "alarm" or event_name == "time_alarm" + is_warning = event_name == "warning_escalated" or event_name.endswith("_violation") + if is_alarm: + alert_count += 1 + latest_alert = ts + if is_warning: + warning_count += 1 + latest_alert = ts + if event_name == "warning_escalated" or event_name.endswith("_violation"): + violation_count += 1 + elif severity == "warning" and event.get("state") == "warning": + violation_count += 1 + if event_name.endswith("_violation") and not severity: latest_alert = ts - active_alert_count = sum(counts.get(name, 0) for name in counts if name.endswith("_violation")) headline = "No batch events yet" if events: - headline = f"{len(events)} event(s), {active_alert_count} violation event(s)" + headline = f"{len(events)} event(s), {alert_count} alarm event(s), {warning_count} warning event(s)" return { "result_type": PROJECT_TYPE, @@ -264,12 +289,14 @@ def build_summary(ctx: ManageContext) -> dict[str, Any]: "metrics": { "event_counts": counts, "event_count": len(events), - "violation_count": active_alert_count, + "alert_count": alert_count, + "warning_count": warning_count, + "violation_count": violation_count, "latest_alert_time": latest_alert, "events_path": str(event_sink_path(ctx)), "diagnostics_path": str(diagnostics_path(ctx)), "diagnostics_count": len(diagnostics), - "latest_zone_counts": latest_zone_counts(diagnostics), + "latest_zone_counts": latest_zone_counts(diagnostics, config), "baseline_ready": latest_baseline_ready(diagnostics), }, } @@ -318,14 +345,83 @@ def diagnostics_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> return path.resolve() -def latest_zone_counts(diagnostics: list[dict[str, Any]]) -> dict[str, int]: +def latest_zone_counts(diagnostics: list[dict[str, Any]], config: dict[str, Any] | None = None) -> dict[str, int]: for item in reversed(diagnostics): + stable_counts = stable_zone_counts_from_diagnostics(item) + if stable_counts: + return stable_counts + recomputed = recompute_zone_counts_from_diagnostics(item, config or {}) + if recomputed: + return recomputed zone_counts = item.get("zone_counts") if isinstance(zone_counts, dict): return {str(key): int(value) for key, value in zone_counts.items()} return {} +def stable_zone_counts_from_diagnostics(item: dict[str, Any]) -> dict[str, int]: + diagnostics_payload = item.get("diagnostics") + if not isinstance(diagnostics_payload, dict): + return {} + zones = diagnostics_payload.get("zones") + if not isinstance(zones, dict): + return {} + + zone_counts = item.get("zone_counts") + counts: dict[str, int] = {} + if isinstance(zone_counts, dict): + counts = {str(key): int(value) for key, value in zone_counts.items()} + + saw_stable_state = False + for zone_id, metrics in zones.items(): + if not isinstance(metrics, dict): + continue + occupied = metrics.get("occupied") + if not isinstance(occupied, bool): + continue + counts[str(zone_id)] = 1 if occupied else 0 + saw_stable_state = True + return counts if saw_stable_state else {} + + +def recompute_zone_counts_from_diagnostics(item: dict[str, Any], config: dict[str, Any]) -> dict[str, int]: + diagnostics_payload = item.get("diagnostics") + if not isinstance(diagnostics_payload, dict): + return {} + zones = diagnostics_payload.get("zones") + if not isinstance(zones, dict): + return {} + settings = load_runtime_vision_settings(config) + counts: dict[str, int] = {} + for zone_id, metrics in zones.items(): + if not isinstance(metrics, dict): + continue + mean_delta = numeric_metric(metrics.get("mean_delta")) + texture_delta = numeric_metric(metrics.get("texture_delta")) + if mean_delta is None or texture_delta is None: + continue + dark_fraction = numeric_metric(metrics.get("dark_fraction")) + baseline_dark_fraction = numeric_metric(metrics.get("baseline_dark_fraction")) or 0.0 + bright_fraction = numeric_metric(metrics.get("bright_fraction")) or 0.0 + occupied = metrics_indicate_occupied( + settings, + mean_delta, + texture_delta, + dark_fraction=dark_fraction, + baseline_dark_fraction=baseline_dark_fraction, + bright_fraction=bright_fraction, + ) + counts[str(zone_id)] = 1 if occupied else 0 + return counts + + +def numeric_metric(value: Any) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None + + def latest_baseline_ready(diagnostics: list[dict[str, Any]]) -> bool: for item in reversed(diagnostics): diagnostics_payload = item.get("diagnostics") diff --git a/src/cold_display_guard/models.py b/src/cold_display_guard/models.py index 05c5d7a..e9e2c21 100644 --- a/src/cold_display_guard/models.py +++ b/src/cold_display_guard/models.py @@ -56,6 +56,7 @@ class Batch: started_at: datetime last_count: int state: str = "active" + alerted_at: datetime | None = None ended_at: datetime | None = None pending_since: datetime | None = None disposal_deadline: datetime | None = None diff --git a/src/cold_display_guard/vision.py b/src/cold_display_guard/vision.py index d1f9dfd..72b6d0d 100644 --- a/src/cold_display_guard/vision.py +++ b/src/cold_display_guard/vision.py @@ -25,11 +25,22 @@ class Region: @dataclass(frozen=True, slots=True) class RuntimeVisionSettings: baseline_frames: int = 3 - sample_stride_pixels: int = 8 - occupancy_mean_delta: float = 24.0 + sample_stride_pixels: int = 4 + occupancy_mean_delta: float = 55.0 occupancy_texture_delta: float = 18.0 + occupancy_dark_luma_threshold: float = 80.0 + occupancy_dark_fraction: float = 0.06 + occupancy_texture_dark_fraction: float = 0.04 + occupancy_bright_luma_threshold: float = 220.0 + occupancy_bright_reflection_fraction: float = 0.18 + occupancy_reflection_dark_fraction: float = 0.10 + occupancy_reflection_bright_dark_ratio: float = 2.0 + occupancy_confirm_frames: int = 2 + empty_confirm_frames: int = 2 trash_motion_delta: float = 18.0 - trash_motion_cooldown_seconds: int = 8 + trash_sustained_motion_delta: float = 8.0 + trash_sustained_motion_frames: int = 2 + trash_motion_cooldown_seconds: int = 3 @dataclass(frozen=True, slots=True) @@ -37,6 +48,8 @@ class RegionMetrics: mean_luma: float texture: float sample_count: int + dark_fraction: float = 0.0 + bright_fraction: float = 0.0 class ZoneOccupancyDetector: @@ -51,13 +64,26 @@ class ZoneOccupancyDetector: self.settings = settings or RuntimeVisionSettings() self._baseline: dict[str, RegionMetrics] = {} self._baseline_samples: dict[str, list[RegionMetrics]] = {region.region_id: [] for region in regions} + self._stable_occupancy: dict[str, bool] = {region.region_id: False for region in regions} + self._occupied_streaks: dict[str, int] = {region.region_id: 0 for region in regions} + self._empty_streaks: dict[str, int] = {region.region_id: 0 for region in regions} if trash_region is not None: self._baseline_samples[trash_region.region_id] = [] self._previous_trash_metrics: RegionMetrics | None = None self._last_trash_motion_at: datetime | None = None + self._trash_motion_streak = 0 def observe(self, frame: Frame, when: datetime) -> tuple[dict[str, int], int, dict[str, Any]]: - metrics_by_region = {region.region_id: region_metrics(frame, region, self.settings.sample_stride_pixels) for region in self.regions} + metrics_by_region = { + region.region_id: region_metrics( + frame, + region, + self.settings.sample_stride_pixels, + self.settings.occupancy_dark_luma_threshold, + self.settings.occupancy_bright_luma_threshold, + ) + for region in self.regions + } self._update_baseline(metrics_by_region) zone_counts: dict[str, int] = {} @@ -69,10 +95,8 @@ class ZoneOccupancyDetector: if baseline is not None: mean_delta = abs(metrics.mean_luma - baseline.mean_luma) texture_delta = metrics.texture - baseline.texture - occupied = ( - mean_delta >= self.settings.occupancy_mean_delta - or texture_delta >= self.settings.occupancy_texture_delta - ) + raw_occupied = self._raw_occupied(metrics, baseline, mean_delta, texture_delta) + occupied = self._confirmed_occupancy(region.region_id, raw_occupied) diagnostics["zones"][region.region_id] = { "mean_luma": round(metrics.mean_luma, 3), "baseline_mean_luma": round(baseline.mean_luma, 3), @@ -80,7 +104,15 @@ class ZoneOccupancyDetector: "texture": round(metrics.texture, 3), "baseline_texture": round(baseline.texture, 3), "texture_delta": round(texture_delta, 3), + "dark_fraction": round(metrics.dark_fraction, 4), + "baseline_dark_fraction": round(baseline.dark_fraction, 4), + "dark_fraction_delta": round(metrics.dark_fraction - baseline.dark_fraction, 4), + "bright_fraction": round(metrics.bright_fraction, 4), + "baseline_bright_fraction": round(baseline.bright_fraction, 4), + "raw_occupied": raw_occupied, "occupied": occupied, + "occupied_streak": self._occupied_streaks[region.region_id], + "empty_streak": self._empty_streaks[region.region_id], } zone_counts[region.region_id] = 1 if occupied else 0 @@ -91,6 +123,37 @@ class ZoneOccupancyDetector: def baseline_ready(self) -> bool: return all(region.region_id in self._baseline for region in self.regions) + def seed_baseline(self, baselines: dict[str, RegionMetrics]) -> None: + known_region_ids = {region.region_id for region in self.regions} + for region_id, metrics in baselines.items(): + if region_id not in known_region_ids: + continue + self._baseline[region_id] = metrics + self._baseline_samples[region_id] = [] + + def seed_occupancy(self, zone_counts: dict[str, int]) -> None: + for region in self.regions: + occupied = int(zone_counts.get(region.region_id, 0)) > 0 + self._stable_occupancy[region.region_id] = occupied + self._occupied_streaks[region.region_id] = self.settings.occupancy_confirm_frames if occupied else 0 + self._empty_streaks[region.region_id] = self.settings.empty_confirm_frames if not occupied else 0 + + def _raw_occupied( + self, + metrics: RegionMetrics, + baseline: RegionMetrics, + mean_delta: float, + texture_delta: float, + ) -> bool: + return metrics_indicate_occupied( + self.settings, + mean_delta, + texture_delta, + dark_fraction=metrics.dark_fraction, + baseline_dark_fraction=baseline.dark_fraction, + bright_fraction=metrics.bright_fraction, + ) + def _update_baseline(self, metrics_by_region: dict[str, RegionMetrics]) -> None: for region_id, metrics in metrics_by_region.items(): if region_id in self._baseline: @@ -100,6 +163,19 @@ class ZoneOccupancyDetector: if len(samples) >= self.settings.baseline_frames: self._baseline[region_id] = average_metrics(samples) + def _confirmed_occupancy(self, region_id: str, raw_occupied: bool) -> bool: + if raw_occupied: + self._occupied_streaks[region_id] = self._occupied_streaks.get(region_id, 0) + 1 + self._empty_streaks[region_id] = 0 + if self._occupied_streaks[region_id] >= self.settings.occupancy_confirm_frames: + self._stable_occupancy[region_id] = True + else: + self._empty_streaks[region_id] = self._empty_streaks.get(region_id, 0) + 1 + self._occupied_streaks[region_id] = 0 + if self._empty_streaks[region_id] >= self.settings.empty_confirm_frames: + self._stable_occupancy[region_id] = False + return self._stable_occupancy.get(region_id, False) + def _trash_deposit_count(self, frame: Frame, when: datetime, diagnostics: dict[str, Any]) -> int: if self.trash_region is None: return 0 @@ -108,17 +184,29 @@ class ZoneOccupancyDetector: previous = self._previous_trash_metrics self._previous_trash_metrics = metrics if previous is None: - diagnostics["trash"] = {"motion_delta": 0.0, "deposit": False} + diagnostics["trash"] = {"motion_delta": 0.0, "motion_streak": 0, "deposit": False} return 0 motion_delta = abs(metrics.mean_luma - previous.mean_luma) + abs(metrics.texture - previous.texture) + if motion_delta >= self.settings.trash_sustained_motion_delta: + self._trash_motion_streak += 1 + else: + self._trash_motion_streak = 0 cooldown = timedelta(seconds=self.settings.trash_motion_cooldown_seconds) in_cooldown = self._last_trash_motion_at is not None and when - self._last_trash_motion_at < cooldown - deposit = motion_delta >= self.settings.trash_motion_delta and not in_cooldown + strong_motion = motion_delta >= self.settings.trash_motion_delta + sustained_motion = self._trash_motion_streak >= self.settings.trash_sustained_motion_frames + deposit = (strong_motion or sustained_motion) and not in_cooldown + motion_streak = self._trash_motion_streak if deposit: self._last_trash_motion_at = when + self._trash_motion_streak = 0 diagnostics["trash"] = { "motion_delta": round(motion_delta, 3), + "motion_streak": motion_streak, + "strong_motion": strong_motion, + "sustained_motion": sustained_motion, + "in_cooldown": in_cooldown, "deposit": deposit, } return 1 if deposit else 0 @@ -143,11 +231,22 @@ def load_runtime_vision_settings(config: dict[str, Any]) -> RuntimeVisionSetting runtime = config.get("runtime", {}) return RuntimeVisionSettings( baseline_frames=max(1, int(runtime.get("baseline_frames", 3))), - sample_stride_pixels=max(1, int(runtime.get("sample_stride_pixels", 8))), - occupancy_mean_delta=float(runtime.get("occupancy_mean_delta", 24.0)), + sample_stride_pixels=max(1, int(runtime.get("sample_stride_pixels", 4))), + occupancy_mean_delta=float(runtime.get("occupancy_mean_delta", 55.0)), occupancy_texture_delta=float(runtime.get("occupancy_texture_delta", 18.0)), + occupancy_dark_luma_threshold=float(runtime.get("occupancy_dark_luma_threshold", 80.0)), + occupancy_dark_fraction=float(runtime.get("occupancy_dark_fraction", 0.06)), + occupancy_texture_dark_fraction=float(runtime.get("occupancy_texture_dark_fraction", 0.04)), + occupancy_bright_luma_threshold=float(runtime.get("occupancy_bright_luma_threshold", 220.0)), + occupancy_bright_reflection_fraction=float(runtime.get("occupancy_bright_reflection_fraction", 0.18)), + occupancy_reflection_dark_fraction=float(runtime.get("occupancy_reflection_dark_fraction", 0.10)), + occupancy_reflection_bright_dark_ratio=float(runtime.get("occupancy_reflection_bright_dark_ratio", 2.0)), + occupancy_confirm_frames=max(1, int(runtime.get("occupancy_confirm_frames", 2))), + empty_confirm_frames=max(1, int(runtime.get("empty_confirm_frames", 2))), trash_motion_delta=float(runtime.get("trash_motion_delta", 18.0)), - trash_motion_cooldown_seconds=max(0, int(runtime.get("trash_motion_cooldown_seconds", 8))), + trash_sustained_motion_delta=float(runtime.get("trash_sustained_motion_delta", 8.0)), + trash_sustained_motion_frames=max(1, int(runtime.get("trash_sustained_motion_frames", 2))), + trash_motion_cooldown_seconds=max(0, int(runtime.get("trash_motion_cooldown_seconds", 3))), ) @@ -162,7 +261,13 @@ def normalize_polygon(value: Any) -> tuple[tuple[float, float], ...]: return tuple(points) -def region_metrics(frame: Frame, region: Region, stride: int) -> RegionMetrics: +def region_metrics( + frame: Frame, + region: Region, + stride: int, + dark_luma_threshold: float = 80.0, + bright_luma_threshold: float = 220.0, +) -> RegionMetrics: xs = [point[0] for point in region.polygon] ys = [point[1] for point in region.polygon] min_x = max(0, int(min(xs) * frame.width)) @@ -184,7 +289,15 @@ def region_metrics(frame: Frame, region: Region, stride: int) -> RegionMetrics: return RegionMetrics(mean_luma=0.0, texture=0.0, sample_count=0) mean = sum(values) / len(values) variance = sum((value - mean) ** 2 for value in values) / len(values) - return RegionMetrics(mean_luma=mean, texture=variance ** 0.5, sample_count=len(values)) + dark_fraction = sum(value < dark_luma_threshold for value in values) / len(values) + bright_fraction = sum(value > bright_luma_threshold for value in values) / len(values) + return RegionMetrics( + mean_luma=mean, + texture=variance ** 0.5, + sample_count=len(values), + dark_fraction=dark_fraction, + bright_fraction=bright_fraction, + ) def average_metrics(samples: list[RegionMetrics]) -> RegionMetrics: @@ -192,6 +305,42 @@ def average_metrics(samples: list[RegionMetrics]) -> RegionMetrics: mean_luma=sum(item.mean_luma for item in samples) / len(samples), texture=sum(item.texture for item in samples) / len(samples), sample_count=min(item.sample_count for item in samples), + dark_fraction=sum(item.dark_fraction for item in samples) / len(samples), + bright_fraction=sum(item.bright_fraction for item in samples) / len(samples), + ) + + +def metrics_indicate_occupied( + settings: RuntimeVisionSettings, + mean_delta: float, + texture_delta: float, + dark_fraction: float | None = None, + baseline_dark_fraction: float = 0.0, + bright_fraction: float = 0.0, +) -> bool: + if dark_fraction is None: + return mean_delta >= settings.occupancy_mean_delta or texture_delta >= settings.occupancy_texture_delta + + dark_delta = dark_fraction - baseline_dark_fraction + bright_reflection = is_bright_reflection(settings, dark_delta, bright_fraction) + dark_occupied = dark_delta >= settings.occupancy_dark_fraction and not bright_reflection + mean_occupied = mean_delta >= settings.occupancy_mean_delta and not bright_reflection + texture_occupied = ( + texture_delta >= settings.occupancy_texture_delta + and dark_delta >= settings.occupancy_texture_dark_fraction + and not bright_reflection + ) + return dark_occupied or mean_occupied or texture_occupied + + +def is_bright_reflection(settings: RuntimeVisionSettings, dark_delta: float, bright_fraction: float) -> bool: + if bright_fraction < settings.occupancy_bright_reflection_fraction: + return False + if dark_delta < settings.occupancy_texture_dark_fraction: + return True + return ( + dark_delta < settings.occupancy_reflection_dark_fraction + and bright_fraction >= dark_delta * settings.occupancy_reflection_bright_dark_ratio ) diff --git a/task_plan.md b/task_plan.md index 74aae75..d2d9d91 100644 --- a/task_plan.md +++ b/task_plan.md @@ -2,20 +2,20 @@ ## Goal -Create an independent git project under `~/Code` for monitoring food batches in a refrigerated display cabinet. The system tracks each configured display zone, starts a batch timer when food appears, ends it when the zone clears, and raises compliance alerts for over-3-hour removal without trash disposal or for over-3-hour food being put back. +Create and evolve an independent git project under `~/Code` for monitoring food batches in a refrigerated display cabinet. The system tracks each configured food zone, starts a batch timer when food appears, raises a configurable time alarm, and escalates alarmed food to a warning if it is removed without a matching trash-bin deposit. ## Confirmed Decisions - The trash bin is visible in the same camera frame. -- The display cabinet starts as a 4-column by 2-row layout, but zones must be configurable. +- Food zones are configurable; v1.1 supports 1 to 10 numeric zones. - A zone may contain multiple food items. - Items in the same zone are treated as one batch. - Mixed batches are not allowed; a zone must clear before a new batch can start. - The first implementation is a standalone project, not a modification of `store_dwell_alert`. -## Phases +## Original Milestones -| Phase | Status | Notes | +| Milestone | Status | Notes | | --- | --- | --- | | Create project skeleton | complete | Built under `~/Code/cold_display_guard`. | | Write design and implementation plan | complete | Saved in `docs/plans/`. | @@ -29,3 +29,50 @@ Create an independent git project under `~/Code` for monitoring food batches in | Error | Attempt | Resolution | | --- | --- | --- | | Ended batches reported `0` dwell seconds | First `unittest` run | Calculate dwell seconds before assigning `ended_at`. | + +## v1.1 优化改造 + +### Goal + +正式支持 1 到 10 个自定义食品区域、阿拉伯数字区域标注、可编辑垃圾桶 ROI、自定义时间报警阈值,以及“到达报警阈值先报警,报警后移出但未丢垃圾桶则升级为警告”的事件链路。 + +本节所有需求属于同一个 `v1.1 优化改造` 批次;下方只是该批次内的工作项,不代表拆成多个独立批次或多个版本。 + +### Stop Conditions + +- [x] v1.1 所有工作项完成。 +- [x] 必要 Python 测试通过。 +- [x] 前端构建通过。 +- [x] `docs/project.md` 更新项目目标、架构、配置、运行方式和关键决策。 +- [x] 没有 blocking bug 或未处理的高风险问题。 +- [x] 如果同一问题连续 3 次修复失败,暂停并报告原因、已尝试方案和建议下一步。 + +### Workstreams Inside This Batch + +| Workstream | Status | Goal | Acceptance Criteria | +| --- | --- | --- | --- | +| Batch setup and planning | complete | 建立 `v1.1 优化改造` 文件化计划和项目文档 | `task_plan.md`、`findings.md`、`progress.md`、`docs/project.md` 包含 v1.1 范围、工作项、验收标准和风险 | +| Backend event model | complete | 状态机支持数字区域、时间报警、报警升级警告 | TDD 覆盖 `time_alarm`、`warning_escalated`、数字区域元数据;目标测试和全量 Python 测试通过;代码审查通过 | +| Config and management API | complete | 配置/API 支持 1-10 区域、报警阈值、垃圾桶 ROI 保存 | 配置 round trip、校验、summary/events 字段测试通过;代码审查反馈已修复 | +| Frontend management console | complete | 管理页支持动态区域标定、垃圾桶 ROI 标点、报警阈值配置和新事件显示 | `web/src/main.js`、`web/src/styles.css` 实现交互;`pnpm build` 通过;前端复审通过 | +| Homepage demo runtime display | complete | 首页在无真实事件时也展示完整原型样例,并清空旧事件数据 | 首页默认进入运行页;演示态包含运行摘要、计时进度、事件表和清晰演示标识;真实事件优先;前端测试和构建通过;Docker web 已重建 | +| Documentation and final review | complete | 更新 README/project docs,执行最终代码审查和验证 | README 与命令/字段一致;代码审查无 blocking;验证证据记录到 `progress.md` | + +### v1.1 Decisions + +- 食品区域使用数字字符串 ID:`"1"` 到 `"10"`;事件中同时输出 `zone_index` 和 `zone_label`。 +- 垃圾桶 ROI 保持在 `[trash] roi`,不占用食品区域编号。 +- `max_dwell_seconds` 继续作为主要时间报警阈值;默认可保持 10800 秒,用户可以改成 1200 秒等。 +- 到达阈值时先发 `time_alarm`,批次继续处于活跃区域。 +- 已报警批次从区域移出后进入垃圾桶确认窗口;若窗口内没有垃圾桶动作,发 `warning_escalated`。 +- 首页运行页在事件为空或运行数据不完整时显示标记为演示的数据,避免空白页面;真实事件数据存在时优先展示真实数据。 +- 后续每次派发智能体任务,都必须在任务正文开头加入标准上下文头: + +```text +[项目: /Users/yoilun/Code/cold_display_guard] +[工作流批次: v1.1 优化改造] +[阶段: 阶段 x] +[角色: 对应智能体角色] +``` + +其中 `阶段 x` 表示同一 `v1.1 优化改造` 批次内的工作阶段,不代表拆分成独立批次。 diff --git a/tests/test_cli.py b/tests/test_cli.py index 5134ace..dba3a4f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -51,8 +51,9 @@ class CliTests(unittest.TestCase): events = [json.loads(line) for line in output.getvalue().splitlines()] self.assertEqual( [event["event"] for event in events], - ["batch_started", "batch_pending_disposal", "batch_discarded"], + ["batch_started", "time_alarm", "batch_pending_disposal", "batch_discarded"], ) + self.assertEqual(events[1]["severity"], "alarm") if __name__ == "__main__": diff --git a/tests/test_config.py b/tests/test_config.py index ddd1f7b..ed5f90b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,7 +4,7 @@ import tempfile import unittest from pathlib import Path -from cold_display_guard.config import load_settings +from cold_display_guard.config import load_settings, save_config_document class ConfigTests(unittest.TestCase): @@ -33,6 +33,95 @@ cols = 2 self.assertEqual(settings.trash_confirmation_seconds, 4) self.assertEqual(settings.zone_ids, ("r1c1", "r1c2")) + def test_loads_numeric_zone_ids_for_custom_zone_count(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "config.toml" + path.write_text( + """ +camera_id = "cam_numeric" + +[thresholds] +max_dwell_seconds = 1200 +trash_confirmation_seconds = 120 + +[layout] +zone_count = 3 +zone_ids = ["1", "2", "3"] +""".strip(), + encoding="utf-8", + ) + + settings = load_settings(path) + + self.assertEqual(settings.camera_id, "cam_numeric") + self.assertEqual(settings.max_dwell_seconds, 1200) + self.assertEqual(settings.zone_ids, ("1", "2", "3")) + + def test_rejects_more_than_ten_numeric_food_zones(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "config.toml" + path.write_text( + """ +[layout] +zone_ids = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"] +""".strip(), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "1 to 10"): + load_settings(path) + + def test_loads_numeric_zone_ids_from_zone_count_without_explicit_ids(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "config.toml" + path.write_text( + """ +[layout] +zone_count = 4 +""".strip(), + encoding="utf-8", + ) + + settings = load_settings(path) + + self.assertEqual(settings.zone_ids, ("1", "2", "3", "4")) + + def test_rejects_numeric_zone_count_that_conflicts_with_zone_ids(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "config.toml" + path.write_text( + """ +[layout] +zone_count = 5 +zone_ids = ["1", "2", "3"] +""".strip(), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "zone_count"): + load_settings(path) + + def test_save_config_document_round_trips_zone_count_and_numeric_labels(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "config.toml" + save_config_document( + path, + { + "layout": {"zone_count": 2, "zone_ids": ["1", "2"]}, + "zones": [ + {"id": "1", "label": "区域 1", "polygon": [[0, 0], [1, 0], [1, 1]]}, + {"id": "2", "label": "区域 2", "polygon": [[0, 0], [0.5, 0], [0.5, 1]]}, + ], + "trash": {"roi": [[0, 0], [1, 0], [1, 1]]}, + }, + ) + text = path.read_text(encoding="utf-8") + + self.assertIn("zone_count = 2", text) + self.assertIn('label = "区域 1"', text) + self.assertIn("[trash]", text) + self.assertNotIn('"trash"', text.split("[layout]", maxsplit=1)[1].split("[[zones]]", maxsplit=1)[0]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_engine.py b/tests/test_engine.py index db98932..7db9948 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -36,6 +36,7 @@ class BatchEngineTests(unittest.TestCase): self.assertEqual([event["event"] for event in events], ["batch_started"]) self.assertEqual(events[0]["zone_id"], "r1c1") self.assertEqual(events[0]["current_count"], 3) + self.assertEqual(events[0]["severity"], "info") def test_consumes_batch_when_removed_before_threshold(self) -> None: self.engine.process(obs(self.t0, {"r1c1": 2})) @@ -48,9 +49,29 @@ class BatchEngineTests(unittest.TestCase): self.engine.process(obs(self.t0, {"r1c1": 2})) events = self.engine.process(obs(self.t0 + timedelta(seconds=10), {"r1c1": 0})) - self.assertEqual([event["event"] for event in events], ["batch_pending_disposal"]) - self.assertEqual(events[0]["dwell_seconds"], 10) - self.assertIn("disposal_deadline", events[0]) + self.assertEqual([event["event"] for event in events], ["time_alarm", "batch_pending_disposal"]) + self.assertEqual(events[0]["severity"], "alarm") + self.assertEqual(events[1]["dwell_seconds"], 10) + self.assertIn("disposal_deadline", events[1]) + + def test_removal_observation_at_threshold_emits_alarm_before_pending_disposal(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("1",), + ) + engine = BatchEngine(settings) + engine.process(obs(self.t0, {"1": 1})) + + events = engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 0})) + + self.assertEqual([event["event"] for event in events], ["time_alarm", "batch_pending_disposal"]) + self.assertEqual(events[0]["severity"], "alarm") + self.assertEqual(events[0]["current_count"], 1) + self.assertEqual(events[0]["zone_index"], 1) + self.assertEqual(events[1]["severity"], "warning") + self.assertEqual(events[1]["state"], "pending_disposal") def test_trash_deposit_confirms_pending_disposal(self) -> None: self.engine.process(obs(self.t0, {"r1c1": 2})) @@ -59,12 +80,13 @@ class BatchEngineTests(unittest.TestCase): self.assertEqual([event["event"] for event in events], ["batch_discarded"]) - def test_missing_trash_deposit_raises_violation_after_deadline(self) -> None: + def test_missing_trash_deposit_escalates_warning_after_deadline(self) -> None: self.engine.process(obs(self.t0, {"r1c1": 2})) self.engine.process(obs(self.t0 + timedelta(seconds=11), {"r1c1": 0})) events = self.engine.process(obs(self.t0 + timedelta(seconds=17), {"r1c1": 0})) - self.assertEqual([event["event"] for event in events], ["missing_disposal_violation"]) + self.assertEqual([event["event"] for event in events], ["warning_escalated"]) + self.assertEqual(events[0]["severity"], "warning") self.assertEqual(events[0]["violation_reasons"], ["missing_disposal"]) def test_adding_food_before_zone_clears_raises_mixed_batch_violation(self) -> None: @@ -72,6 +94,7 @@ class BatchEngineTests(unittest.TestCase): events = self.engine.process(obs(self.t0 + timedelta(seconds=1), {"r1c1": 3})) self.assertEqual([event["event"] for event in events], ["mixed_batch_violation"]) + self.assertEqual(events[0]["severity"], "warning") self.assertEqual(events[0]["reason"], "food_added_before_zone_cleared") def test_count_decrease_keeps_same_batch_active(self) -> None: @@ -93,6 +116,178 @@ class BatchEngineTests(unittest.TestCase): ) self.assertEqual(events[0]["appeared_zones"], ["r1c2"]) + def test_time_alarm_emits_once_while_batch_remains_in_zone(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("1",), + ) + engine = BatchEngine(settings) + engine.process(obs(self.t0, {"1": 1})) + + alarm_events = engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1})) + repeated_events = engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 1})) + + self.assertEqual([event["event"] for event in alarm_events], ["time_alarm"]) + self.assertEqual(repeated_events, []) + self.assertEqual(alarm_events[0]["severity"], "alarm") + self.assertEqual(alarm_events[0]["zone_id"], "1") + self.assertEqual(alarm_events[0]["zone_index"], 1) + self.assertEqual(alarm_events[0]["zone_label"], "区域 1") + self.assertEqual(alarm_events[0]["dwell_seconds"], 1200) + self.assertEqual(alarm_events[0]["max_dwell_seconds"], 1200) + self.assertEqual(alarm_events[0]["current_count"], 1) + self.assertIn("alerted_at", alarm_events[0]) + + def test_alarmed_batch_removed_without_trash_deposit_escalates_warning(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("1",), + ) + engine = BatchEngine(settings) + engine.process(obs(self.t0, {"1": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1})) + pending_events = engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0})) + warning_events = engine.process(obs(self.t0 + timedelta(seconds=1421), {"1": 0})) + + self.assertEqual([event["event"] for event in pending_events], ["batch_pending_disposal"]) + self.assertEqual(pending_events[0]["severity"], "warning") + self.assertEqual(pending_events[0]["state"], "pending_disposal") + self.assertEqual(pending_events[0]["zone_index"], 1) + self.assertEqual(pending_events[0]["ended_at"], (self.t0 + timedelta(seconds=1300)).isoformat()) + + self.assertEqual([event["event"] for event in warning_events], ["warning_escalated"]) + self.assertEqual(warning_events[0]["severity"], "warning") + self.assertEqual(warning_events[0]["state"], "warning") + self.assertEqual(warning_events[0]["reason"], "alarmed_batch_removed_without_trash_deposit") + self.assertEqual(warning_events[0]["zone_label"], "区域 1") + + def test_alarmed_batch_removed_with_trash_deposit_is_discarded(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("1",), + ) + engine = BatchEngine(settings) + engine.process(obs(self.t0, {"1": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0})) + events = engine.process(obs(self.t0 + timedelta(seconds=1310), {"1": 0}, trash=True)) + + self.assertEqual([event["event"] for event in events], ["batch_discarded"]) + self.assertEqual(events[0]["severity"], "info") + self.assertEqual(events[0]["state"], "discarded") + + def test_same_observation_removal_and_trash_motion_discards_alerted_batch(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("1",), + ) + engine = BatchEngine(settings) + engine.process(obs(self.t0, {"1": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1})) + + events = engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0}, trash=True)) + later_events = engine.process(obs(self.t0 + timedelta(seconds=1421), {"1": 0})) + + self.assertEqual([event["event"] for event in events], ["batch_pending_disposal", "batch_discarded"]) + self.assertEqual(events[1]["state"], "discarded") + self.assertEqual(later_events, []) + + def test_same_observation_trash_motion_discards_multiple_newly_pending_batches(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=300, + trash_confirmation_seconds=120, + zone_ids=("1", "4"), + ) + engine = BatchEngine(settings) + engine.process(obs(self.t0, {"1": 1, "4": 1})) + engine.process(obs(self.t0 + timedelta(seconds=300), {"1": 1, "4": 1})) + + events = engine.process(obs(self.t0 + timedelta(seconds=360), {"1": 0, "4": 0}, trash=True)) + later_events = engine.process(obs(self.t0 + timedelta(seconds=481), {"1": 0, "4": 0})) + + self.assertEqual( + [event["event"] for event in events], + ["batch_pending_disposal", "batch_pending_disposal", "batch_discarded", "batch_discarded"], + ) + self.assertEqual([event["zone_id"] for event in events if event["event"] == "batch_discarded"], ["1", "4"]) + self.assertEqual(later_events, []) + + def test_restore_keeps_active_alarm_batch_after_runtime_restart(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("1",), + ) + engine = BatchEngine(settings) + engine.restore_from_events( + [ + { + "event": "batch_started", + "zone_id": "1", + "batch_id": "batch_000124", + "started_at": self.t0.isoformat(), + "current_count": 1, + "state": "active", + }, + { + "event": "time_alarm", + "zone_id": "1", + "batch_id": "batch_000124", + "started_at": self.t0.isoformat(), + "alerted_at": (self.t0 + timedelta(seconds=1200)).isoformat(), + "current_count": 1, + "state": "alerted", + }, + ], + active_zone_counts={"1": 1}, + ) + + repeated_events = engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 1})) + removal_events = engine.process(obs(self.t0 + timedelta(seconds=1400), {"1": 0})) + + self.assertEqual(repeated_events, []) + self.assertEqual([event["event"] for event in removal_events], ["batch_pending_disposal"]) + self.assertEqual(removal_events[0]["batch_id"], "batch_000124") + self.assertEqual(removal_events[0]["dwell_seconds"], 1400) + + def test_restore_skips_active_false_positive_when_latest_zone_count_is_empty(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("3",), + ) + engine = BatchEngine(settings) + engine.restore_from_events( + [ + { + "event": "time_alarm", + "zone_id": "3", + "batch_id": "batch_000213", + "started_at": self.t0.isoformat(), + "alerted_at": (self.t0 + timedelta(seconds=1200)).isoformat(), + "current_count": 1, + "state": "alerted", + }, + ], + active_zone_counts={"3": 0}, + ) + + events = engine.process(obs(self.t0 + timedelta(seconds=1300), {"3": 0})) + + self.assertEqual(events, []) + self.assertEqual(engine.active_by_zone, {}) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..22ec2f9 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from cold_display_guard.main import restore_runtime_state + + +class RuntimeRestoreTests(unittest.TestCase): + def test_restore_runtime_state_uses_stable_occupancy_when_raw_metrics_flicker(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + diagnostics_path = Path(tmpdir) / "runtime_diagnostics.jsonl" + diagnostics_path.write_text( + json.dumps( + { + "ts": "2026-05-29T10:05:26+08:00", + "zone_counts": {"2": 1}, + "diagnostics": { + "baseline_ready": True, + "zones": { + "2": { + "baseline_mean_luma": 165.0, + "baseline_texture": 16.0, + "baseline_dark_fraction": 0.0, + "baseline_bright_fraction": 0.0, + "mean_delta": 17.077, + "texture_delta": 8.819, + "dark_fraction": 0.0357, + "bright_fraction": 0.0, + "raw_occupied": False, + "occupied": True, + "empty_streak": 1, + }, + }, + }, + } + ), + encoding="utf-8", + ) + + _, zone_counts = restore_runtime_state( + diagnostics_path, + { + "runtime": { + "occupancy_mean_delta": 55.0, + "occupancy_texture_delta": 18.0, + "occupancy_dark_fraction": 0.06, + "occupancy_texture_dark_fraction": 0.04, + } + }, + ) + + self.assertEqual(zone_counts, {"2": 1}) + + def test_restore_runtime_state_uses_dark_fraction_rules(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + diagnostics_path = Path(tmpdir) / "runtime_diagnostics.jsonl" + diagnostics_path.write_text( + json.dumps( + { + "ts": "2026-05-29T10:00:00+08:00", + "zone_counts": {"1": 1, "4": 1}, + "diagnostics": { + "baseline_ready": True, + "zones": { + "1": { + "baseline_mean_luma": 165.0, + "baseline_texture": 16.0, + "baseline_dark_fraction": 0.0, + "baseline_bright_fraction": 0.0, + "mean_delta": 40.0, + "texture_delta": 18.0, + "dark_fraction": 0.10, + "bright_fraction": 0.0, + }, + "4": { + "baseline_mean_luma": 177.0, + "baseline_texture": 9.0, + "baseline_dark_fraction": 0.0, + "baseline_bright_fraction": 0.0, + "mean_delta": 16.0, + "texture_delta": 40.0, + "dark_fraction": 0.0769, + "bright_fraction": 0.3077, + }, + }, + }, + } + ), + encoding="utf-8", + ) + + baselines, zone_counts = restore_runtime_state( + diagnostics_path, + { + "runtime": { + "occupancy_mean_delta": 55.0, + "occupancy_texture_delta": 18.0, + "occupancy_dark_fraction": 0.06, + "occupancy_texture_dark_fraction": 0.04, + } + }, + ) + + self.assertEqual(zone_counts, {"1": 1, "4": 0}) + self.assertEqual(baselines["1"].dark_fraction, 0.0) + self.assertEqual(baselines["4"].bright_fraction, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manage_api.py b/tests/test_manage_api.py index d410fe4..cc4cab6 100644 --- a/tests/test_manage_api.py +++ b/tests/test_manage_api.py @@ -28,6 +28,62 @@ class ManageApiTests(unittest.TestCase): self.assertEqual(merged["zones"][1]["id"], "r1c2") self.assertEqual(merged["trash"]["roi"][0], [0.8, 0.8]) + def test_merge_calibration_replaces_numeric_food_zones_and_keeps_trash_separate(self) -> None: + data = { + "layout": {"zone_count": 2, "zone_ids": ["1", "2"]}, + "zones": [ + {"id": "1", "polygon": [[0, 0], [0.3, 0], [0.3, 0.3]]}, + {"id": "2", "polygon": [[0.3, 0], [0.6, 0], [0.6, 0.3]]}, + ], + } + + merged = merge_calibration( + data, + [ + {"id": "1", "label": "区域 1", "polygon": [[0, 0], [0.2, 0], [0.2, 0.2]]}, + {"id": "2", "label": "区域 2", "polygon": [[0.2, 0], [0.4, 0], [0.4, 0.2]]}, + {"id": "3", "label": "区域 3", "polygon": [[0.4, 0], [0.6, 0], [0.6, 0.2]]}, + ], + [[0.8, 0.8], [1, 0.8], [1, 1], [0.8, 1]], + ) + + self.assertEqual(merged["layout"]["zone_count"], 3) + self.assertEqual(merged["layout"]["zone_ids"], ["1", "2", "3"]) + self.assertEqual([zone["label"] for zone in merged["zones"]], ["区域 1", "区域 2", "区域 3"]) + self.assertEqual(merged["trash"]["roi"][0], [0.8, 0.8]) + self.assertNotIn("trash", merged["layout"]["zone_ids"]) + + def test_merge_calibration_preserves_numeric_zone_count_when_some_zones_are_unmarked(self) -> None: + data = { + "layout": {"zone_count": 3, "zone_ids": ["1", "2", "3"]}, + "zones": [ + {"id": "1", "label": "区域 1", "polygon": [[0, 0], [0.2, 0], [0.2, 0.2]]}, + {"id": "2", "label": "区域 2", "polygon": [[0.2, 0], [0.4, 0], [0.4, 0.2]]}, + ], + } + + merged = merge_calibration( + data, + [{"id": "1", "label": "区域 1", "polygon": [[0, 0], [0.3, 0], [0.3, 0.3]]}], + [[0.8, 0.8], [1, 0.8], [1, 1]], + {"zone_count": 3, "zone_ids": ["1", "2", "3"]}, + ) + + self.assertEqual(merged["layout"]["zone_count"], 3) + self.assertEqual(merged["layout"]["zone_ids"], ["1", "2", "3"]) + self.assertEqual([zone["id"] for zone in merged["zones"]], ["1", "2"]) + self.assertEqual(merged["zones"][0]["polygon"], [[0.0, 0.0], [0.3, 0.0], [0.3, 0.3]]) + self.assertEqual(merged["zones"][1]["polygon"], [[0.2, 0.0], [0.4, 0.0], [0.4, 0.2]]) + + def test_merge_calibration_rejects_more_than_ten_numeric_food_zones(self) -> None: + zones = [ + {"id": str(index), "polygon": [[0, 0], [0.1, 0], [0.1, 0.1]]} + for index in range(1, 12) + ] + + with self.assertRaisesRegex(ValueError, "1 to 10"): + merge_calibration({"layout": {}}, zones, None) + def test_save_config_document_round_trips_manage_fields(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / "config.toml" @@ -65,8 +121,9 @@ class ManageApiTests(unittest.TestCase): events_path.write_text( "\n".join( [ - json.dumps({"event": "batch_started", "ts": "2026-04-27T10:00:00+08:00"}), - json.dumps({"event": "missing_disposal_violation", "ts": "2026-04-27T13:02:00+08:00"}), + json.dumps({"event": "batch_started", "severity": "info", "ts": "2026-04-27T10:00:00+08:00"}), + json.dumps({"event": "time_alarm", "severity": "alarm", "ts": "2026-04-27T12:00:00+08:00"}), + json.dumps({"event": "warning_escalated", "severity": "warning", "ts": "2026-04-27T13:02:00+08:00"}), ] ), encoding="utf-8", @@ -74,8 +131,43 @@ class ManageApiTests(unittest.TestCase): summary = build_summary(ManageContext(config_path=config_path, project_root=root)) - self.assertEqual(summary["metrics"]["event_count"], 2) + self.assertEqual(summary["metrics"]["event_count"], 3) + self.assertEqual(summary["metrics"]["alert_count"], 1) + self.assertEqual(summary["metrics"]["warning_count"], 1) self.assertEqual(summary["metrics"]["violation_count"], 1) + self.assertEqual(summary["metrics"]["latest_alert_time"], "2026-04-27T13:02:00+08:00") + + def test_summary_counts_escalated_and_legacy_warnings_without_pending_disposal(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config_path = root / "config" / "local.toml" + save_config_document( + config_path, + { + "event_sink": {"path": "logs/events.jsonl"}, + "layout": {"rows": 1, "cols": 1, "zone_ids": ["r1c1"]}, + }, + ) + events_path = root / "logs" / "events.jsonl" + events_path.parent.mkdir() + events_path.write_text( + "\n".join( + [ + json.dumps({"event": "batch_pending_disposal", "severity": "warning", "ts": "2026-04-27T12:01:00+08:00"}), + json.dumps({"event": "mixed_batch_violation", "ts": "2026-04-27T12:02:00+08:00"}), + json.dumps({"event": "warning_escalated", "severity": "warning", "ts": "2026-04-27T12:03:00+08:00"}), + ] + ), + encoding="utf-8", + ) + + summary = build_summary(ManageContext(config_path=config_path, project_root=root)) + + self.assertEqual(summary["metrics"]["event_count"], 3) + self.assertEqual(summary["metrics"]["alert_count"], 0) + self.assertEqual(summary["metrics"]["warning_count"], 2) + self.assertEqual(summary["metrics"]["violation_count"], 2) + self.assertEqual(summary["metrics"]["latest_alert_time"], "2026-04-27T12:03:00+08:00") def test_summary_reads_runtime_diagnostics(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -108,6 +200,156 @@ class ManageApiTests(unittest.TestCase): self.assertEqual(summary["metrics"]["latest_zone_counts"], {"r1c1": 1}) self.assertTrue(summary["metrics"]["baseline_ready"]) + def test_summary_uses_stable_runtime_occupancy_when_raw_metrics_flicker(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config_path = root / "config" / "local.toml" + save_config_document( + config_path, + { + "runtime": { + "diagnostics_path": "logs/runtime_diagnostics.jsonl", + "occupancy_mean_delta": 55.0, + "occupancy_texture_delta": 18.0, + "occupancy_dark_fraction": 0.06, + "occupancy_texture_dark_fraction": 0.04, + }, + "event_sink": {"path": "logs/events.jsonl"}, + "layout": {"zone_count": 2, "zone_ids": ["1", "2"]}, + }, + ) + diagnostics_path = root / "logs" / "runtime_diagnostics.jsonl" + diagnostics_path.parent.mkdir() + diagnostics_path.write_text( + json.dumps( + { + "ts": "2026-05-29T10:05:26+08:00", + "zone_counts": {"1": 0, "2": 1}, + "diagnostics": { + "baseline_ready": True, + "zones": { + "1": { + "mean_delta": 0.0, + "texture_delta": 0.0, + "dark_fraction": 0.0, + "baseline_dark_fraction": 0.0, + "bright_fraction": 0.0, + "occupied": False, + }, + "2": { + "mean_delta": 17.077, + "texture_delta": 8.819, + "dark_fraction": 0.0357, + "baseline_dark_fraction": 0.0, + "bright_fraction": 0.0, + "raw_occupied": False, + "occupied": True, + "empty_streak": 1, + }, + }, + }, + } + ), + encoding="utf-8", + ) + + summary = build_summary(ManageContext(config_path=config_path, project_root=root)) + + self.assertEqual(summary["metrics"]["latest_zone_counts"], {"1": 0, "2": 1}) + + def test_summary_recomputes_latest_zone_counts_from_runtime_thresholds_when_stable_state_is_absent(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config_path = root / "config" / "local.toml" + save_config_document( + config_path, + { + "runtime": { + "diagnostics_path": "logs/runtime_diagnostics.jsonl", + "occupancy_mean_delta": 45.0, + "occupancy_texture_delta": 18.0, + }, + "event_sink": {"path": "logs/events.jsonl"}, + "layout": {"zone_count": 3, "zone_ids": ["1", "2", "3"]}, + }, + ) + diagnostics_path = root / "logs" / "runtime_diagnostics.jsonl" + diagnostics_path.parent.mkdir() + diagnostics_path.write_text( + json.dumps( + { + "ts": "2026-05-27T11:02:23+08:00", + "zone_counts": {"1": 1, "3": 1}, + "diagnostics": { + "baseline_ready": True, + "zones": { + "1": {"mean_delta": 70.0, "texture_delta": 27.0}, + "3": {"mean_delta": 36.0, "texture_delta": -9.0}, + }, + }, + } + ), + encoding="utf-8", + ) + + summary = build_summary(ManageContext(config_path=config_path, project_root=root)) + + self.assertEqual(summary["metrics"]["latest_zone_counts"], {"1": 1, "3": 0}) + + def test_summary_recomputes_latest_zone_counts_with_dark_fraction_rule(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + config_path = root / "config" / "local.toml" + save_config_document( + config_path, + { + "runtime": { + "diagnostics_path": "logs/runtime_diagnostics.jsonl", + "occupancy_mean_delta": 55.0, + "occupancy_texture_delta": 18.0, + "occupancy_dark_fraction": 0.06, + "occupancy_texture_dark_fraction": 0.04, + "occupancy_bright_reflection_fraction": 0.18, + }, + "event_sink": {"path": "logs/events.jsonl"}, + "layout": {"zone_count": 2, "zone_ids": ["1", "2"]}, + }, + ) + diagnostics_path = root / "logs" / "runtime_diagnostics.jsonl" + diagnostics_path.parent.mkdir() + diagnostics_path.write_text( + json.dumps( + { + "ts": "2026-05-28T09:41:13+08:00", + "zone_counts": {"1": 1, "2": 1}, + "diagnostics": { + "baseline_ready": True, + "zones": { + "1": { + "mean_delta": 45.0, + "texture_delta": 20.0, + "dark_fraction": 0.20, + "baseline_dark_fraction": 0.0, + "bright_fraction": 0.0, + }, + "2": { + "mean_delta": 16.0, + "texture_delta": 40.0, + "dark_fraction": 0.0769, + "baseline_dark_fraction": 0.0, + "bright_fraction": 0.3077, + }, + }, + }, + } + ), + encoding="utf-8", + ) + + summary = build_summary(ManageContext(config_path=config_path, project_root=root)) + + self.assertEqual(summary["metrics"]["latest_zone_counts"], {"1": 1, "2": 0}) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_vision.py b/tests/test_vision.py index 50260fb..7144360 100644 --- a/tests/test_vision.py +++ b/tests/test_vision.py @@ -1,13 +1,15 @@ from __future__ import annotations import unittest -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from cold_display_guard.vision import ( Frame, Region, + RegionMetrics, RuntimeVisionSettings, ZoneOccupancyDetector, + load_runtime_vision_settings, point_in_polygon, ) @@ -26,6 +28,16 @@ def patched_frame(width: int, height: int, base: int, patch: tuple[int, int, int return Frame(width=width, height=height, rgb=bytes(pixels)) +def multi_patched_frame(width: int, height: int, base: int, patches: list[tuple[int, int, int, int, int]]) -> Frame: + pixels = bytearray(bytes([base, base, base]) * width * height) + for x1, y1, x2, y2, value in patches: + for y in range(y1, y2): + for x in range(x1, x2): + offset = (y * width + x) * 3 + pixels[offset : offset + 3] = bytes([value, value, value]) + return Frame(width=width, height=height, rgb=bytes(pixels)) + + class VisionTests(unittest.TestCase): def test_point_in_polygon(self) -> None: polygon = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)) @@ -42,6 +54,8 @@ class VisionTests(unittest.TestCase): sample_stride_pixels=4, occupancy_mean_delta=10, occupancy_texture_delta=10, + occupancy_confirm_frames=1, + empty_confirm_frames=1, ), ) now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc) @@ -67,6 +81,196 @@ class VisionTests(unittest.TestCase): self.assertEqual(first_deposit, 0) self.assertEqual(second_deposit, 1) + def test_detector_reports_sustained_trash_motion_below_single_frame_threshold(self) -> None: + trash = Region("trash", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))) + detector = ZoneOccupancyDetector( + [], + trash_region=trash, + settings=RuntimeVisionSettings( + sample_stride_pixels=4, + trash_motion_delta=18, + trash_sustained_motion_delta=8, + trash_sustained_motion_frames=2, + ), + ) + now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc) + + _, first_deposit, _ = detector.observe(solid_frame(32, 32, 30), now) + _, second_deposit, second_diagnostics = detector.observe(solid_frame(32, 32, 39), now) + _, third_deposit, third_diagnostics = detector.observe(solid_frame(32, 32, 48), now) + + self.assertEqual(first_deposit, 0) + self.assertEqual(second_deposit, 0) + self.assertEqual(second_diagnostics["trash"]["motion_streak"], 1) + self.assertEqual(third_deposit, 1) + self.assertEqual(third_diagnostics["trash"]["motion_streak"], 2) + + def test_detector_allows_quick_sequential_strong_trash_motions(self) -> None: + trash = Region("trash", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))) + detector = ZoneOccupancyDetector( + [], + trash_region=trash, + settings=RuntimeVisionSettings(sample_stride_pixels=4, trash_motion_delta=18), + ) + now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc) + + _, first_deposit, _ = detector.observe(solid_frame(32, 32, 30), now) + _, second_deposit, _ = detector.observe(solid_frame(32, 32, 90), now + timedelta(seconds=1)) + _, third_deposit, third_diagnostics = detector.observe(solid_frame(32, 32, 30), now + timedelta(seconds=7)) + + self.assertEqual(first_deposit, 0) + self.assertEqual(second_deposit, 1) + self.assertEqual(third_deposit, 1) + self.assertFalse(third_diagnostics["trash"]["in_cooldown"]) + + def test_detector_requires_consecutive_occupied_frames(self) -> None: + detector = ZoneOccupancyDetector( + [Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))], + trash_region=None, + settings=RuntimeVisionSettings( + baseline_frames=1, + sample_stride_pixels=4, + occupancy_mean_delta=10, + occupancy_texture_delta=10, + occupancy_confirm_frames=2, + empty_confirm_frames=2, + ), + ) + now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc) + + detector.observe(solid_frame(32, 32, 30), now) + first_counts, _, first_diagnostics = detector.observe(solid_frame(32, 32, 90), now) + second_counts, _, second_diagnostics = detector.observe(solid_frame(32, 32, 90), now) + first_empty_counts, _, _ = detector.observe(solid_frame(32, 32, 30), now) + second_empty_counts, _, _ = detector.observe(solid_frame(32, 32, 30), now) + + self.assertEqual(first_counts, {"1": 0}) + self.assertTrue(first_diagnostics["zones"]["1"]["raw_occupied"]) + self.assertEqual(first_diagnostics["zones"]["1"]["occupied_streak"], 1) + self.assertEqual(second_counts, {"1": 1}) + self.assertTrue(second_diagnostics["zones"]["1"]["occupied"]) + self.assertEqual(first_empty_counts, {"1": 1}) + self.assertEqual(second_empty_counts, {"1": 0}) + + def test_runtime_vision_defaults_raise_brightness_reflection_threshold(self) -> None: + settings = load_runtime_vision_settings({}) + + self.assertEqual(settings.sample_stride_pixels, 4) + self.assertEqual(settings.occupancy_mean_delta, 55.0) + self.assertEqual(settings.occupancy_confirm_frames, 2) + self.assertEqual(settings.empty_confirm_frames, 2) + self.assertEqual(settings.trash_motion_delta, 18.0) + self.assertEqual(settings.trash_sustained_motion_delta, 8.0) + self.assertEqual(settings.trash_sustained_motion_frames, 2) + self.assertEqual(settings.trash_motion_cooldown_seconds, 3) + + def test_detector_can_seed_previous_baseline_and_occupancy(self) -> None: + detector = ZoneOccupancyDetector( + [Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))], + trash_region=None, + settings=RuntimeVisionSettings( + baseline_frames=10, + sample_stride_pixels=4, + occupancy_mean_delta=55, + occupancy_texture_delta=18, + occupancy_confirm_frames=2, + empty_confirm_frames=2, + ), + ) + detector.seed_baseline({"1": RegionMetrics(mean_luma=30.0, texture=0.0, sample_count=1)}) + detector.seed_occupancy({"1": 1}) + + counts, _, diagnostics = detector.observe(solid_frame(32, 32, 90), datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)) + + self.assertTrue(diagnostics["baseline_ready"]) + self.assertEqual(counts, {"1": 1}) + self.assertTrue(diagnostics["zones"]["1"]["occupied"]) + + def test_detector_reports_compact_dark_object_as_occupied(self) -> None: + detector = ZoneOccupancyDetector( + [Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))], + trash_region=None, + settings=RuntimeVisionSettings( + baseline_frames=1, + sample_stride_pixels=4, + occupancy_mean_delta=55, + occupancy_texture_delta=100, + occupancy_dark_luma_threshold=80, + occupancy_dark_fraction=0.06, + occupancy_confirm_frames=1, + empty_confirm_frames=1, + ), + ) + now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc) + + detector.observe(solid_frame(32, 32, 180), now) + counts, _, diagnostics = detector.observe(patched_frame(32, 32, 180, (0, 0, 8, 32, 20)), now) + + self.assertEqual(counts, {"1": 1}) + self.assertGreaterEqual(diagnostics["zones"]["1"]["dark_fraction"], 0.06) + + def test_detector_ignores_bright_reflection_without_dark_object(self) -> None: + detector = ZoneOccupancyDetector( + [Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))], + trash_region=None, + settings=RuntimeVisionSettings( + baseline_frames=1, + sample_stride_pixels=4, + occupancy_mean_delta=55, + occupancy_texture_delta=10, + occupancy_dark_luma_threshold=80, + occupancy_dark_fraction=0.06, + occupancy_texture_dark_fraction=0.04, + occupancy_confirm_frames=1, + empty_confirm_frames=1, + ), + ) + now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc) + + detector.observe(solid_frame(32, 32, 160), now) + counts, _, diagnostics = detector.observe(patched_frame(32, 32, 160, (0, 0, 8, 32, 255)), now) + + self.assertEqual(counts, {"1": 0}) + self.assertGreaterEqual(diagnostics["zones"]["1"]["texture_delta"], 10) + self.assertLess(diagnostics["zones"]["1"]["dark_fraction"], 0.04) + + def test_detector_ignores_bright_reflection_with_small_dark_edge(self) -> None: + detector = ZoneOccupancyDetector( + [Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))], + trash_region=None, + settings=RuntimeVisionSettings( + baseline_frames=1, + sample_stride_pixels=4, + occupancy_mean_delta=55, + occupancy_texture_delta=18, + occupancy_dark_luma_threshold=80, + occupancy_dark_fraction=0.06, + occupancy_texture_dark_fraction=0.04, + occupancy_confirm_frames=1, + empty_confirm_frames=1, + ), + ) + now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc) + + detector.observe(solid_frame(40, 40, 180), now) + counts, _, diagnostics = detector.observe( + multi_patched_frame( + 40, + 40, + 180, + [ + (0, 0, 12, 40, 255), + (12, 0, 16, 32, 20), + ], + ), + now, + ) + + zone = diagnostics["zones"]["1"] + self.assertEqual(counts, {"1": 0}) + self.assertGreaterEqual(zone["dark_fraction"], 0.06) + self.assertGreaterEqual(zone["bright_fraction"], 0.18) + if __name__ == "__main__": unittest.main() diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..0ca39c0 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.DS_Store diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..79dd416 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,29 @@ +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/node:20-alpine AS builder + +ENV TZ=Asia/Shanghai + +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories + +WORKDIR /source + +RUN npm install -g pnpm@10.30.3 + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm build + +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nginx:1.29.4-alpine + +ENV TZ=Asia/Shanghai + +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \ + apk add --no-cache tzdata + +COPY --from=builder /source/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..79ac86d --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://cold-display-guard-api:19080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/web/src/main.js b/web/src/main.js index 5a964c0..bda4a8f 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -1,35 +1,51 @@ import "./styles.css"; +import { + TRASH_REGION_ID, + alarmMinutesToSeconds, + buildCalibrationPayload, + buildPolygonMap, + buildRuntimeDisplayModel, + clampZoneCount, + classifyEvent, + deriveFoodZones, + escapeHtml, + getRegionColor, + getRegionLabel, + secondsToAlarmMinutes, +} from "./zone-state.js"; -const zoneIds = ["r1c1", "r1c2", "r1c3", "r1c4", "r2c1", "r2c2", "r2c3", "r2c4"]; -const allRegions = [...zoneIds, "trash"]; -const draftStorageKey = "cold-display-guard.calibrationDraft.v1"; -const palette = { - r1c1: "#d92d20", - r1c2: "#b54708", - r1c3: "#4e5ba6", - r1c4: "#008a5a", - r2c1: "#0077a3", - r2c2: "#155eef", - r2c3: "#7f56d9", - r2c4: "#c11574", - trash: "#111827", -}; +const draftStorageKey = "cold-display-guard.calibrationDraft.v2"; +const defaultFoodZones = deriveFoodZones({layout: {zone_count: 8}}); +const runtimeClockMs = 1000; +const runtimePollMs = 5000; + +window.addEventListener("error", (event) => { + showFatalError(event.error || event.message); +}); + +window.addEventListener("unhandledrejection", (event) => { + showFatalError(event.reason); +}); const state = { config: null, summary: null, events: [], - activeTab: "calibration", - activeRegion: "r1c1", - polygons: Object.fromEntries(allRegions.map((id) => [id, []])), + activeTab: "events", + activeRegion: "1", + foodZones: defaultFoodZones, + foodZoneCount: defaultFoodZones.length, + polygons: buildPolygonMap(defaultFoodZones), image: null, imageUrl: null, status: "正在连接后端...", + runtimeDemoReason: "正在读取后端运行数据", configDirty: false, calibrationDirty: false, }; const app = document.querySelector("#app"); +let runtimeRefreshInFlight = false; app.innerHTML = `
@@ -83,6 +99,10 @@ app.innerHTML = `