Compare commits

...

16 Commits

Author SHA1 Message Date
Yoilun
a4657a4bdf fix: seed trajectories from recent source motion 2026-06-01 12:03:05 +08:00
Yoilun
03bc7085ea feat: allow segmented disposal trajectories 2026-06-01 11:06:42 +08:00
Yoilun
1ecf881684 fix: ignore global lighting shifts in occupancy 2026-06-01 09:56:11 +08:00
Yoilun
100b949f1f fix: harden v1.2 trajectory disposal matching 2026-05-29 16:26:15 +08:00
Yoilun
90aa5dd704 feat: integrate trajectory runtime diagnostics 2026-05-29 15:58:26 +08:00
Yoilun
39cfc76fa2 feat: add lightweight trajectory tracking 2026-05-29 15:48:06 +08:00
Yoilun
d805273a10 feat: add disposal evidence engine handling 2026-05-29 15:30:07 +08:00
Yoilun
5f518991bf docs: plan v1.2 trajectory recognition workflow 2026-05-29 15:18:58 +08:00
Yoilun
ac6d368810 docs: design yolo-ready trajectory evidence 2026-05-29 15:13:48 +08:00
Yoilun
8b5bbff364 feat: stabilize cold display runtime deployment 2026-05-29 14:48:01 +08:00
Yoilun
ea5f9b1b07 Save calibration payload format and live config 2026-05-07 17:58:32 +08:00
Yoilun
96f5c14a26 feat: redesign web management console 2026-04-29 16:26:42 +08:00
Yoilun
aff2b1828e docs: plan web industrial console redesign 2026-04-29 16:09:36 +08:00
Yoilun
08c5d2e955 fix: separate runtime refresh from config reload 2026-04-29 15:53:01 +08:00
Yoilun
c81a20b2ea feat: show runtime diagnostics in management summary 2026-04-28 19:03:03 +08:00
Yoilun
b1c39d3fa7 feat: add rtsp runtime pipeline 2026-04-28 19:00:23 +08:00
40 changed files with 9068 additions and 366 deletions

14
.dockerignore Normal file
View File

@@ -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

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
__pycache__/
*.py[cod]
.DS_Store
*.textClipping
.pytest_cache/
.superpowers/
.venv/
dist/
build/

29
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -1,44 +1,66 @@
# 冷藏展示柜食品批次计时报警
这是一个独立项目,用于单摄像头监控冷藏展示柜和同画面垃圾桶,记录每个展示区域内食品批次的放置时长,并发现 3 小时到期后的违规行为。
这是一个独立项目,用于单摄像头监控冷藏展示柜和同画面垃圾桶,记录每个展示区域内食品批次的放置时长,并发现超过自定义报警时间后的异常处理行为。
## 已确认业务规则
- 摄像头同时看到展示柜和垃圾桶。
- 展示柜初始布局为横向 4 列、竖向 2 行
- 布局后期可以通过配置调整。
- 展示柜食品区域支持 1 到 10 个自定义区域
- 食品区域使用阿拉伯数字标注:`1``2``3` ...
- 垃圾桶 ROI 独立标定,不占用食品区域编号。
- 每个区域可以放多份食品,但这些食品按同一批次计时。
- 同一区域不允许混批,必须清空后才能放入新批次。
- 食品放入区域时记录开始时间。
- 区域清空时记录结束时间。
-满 3 小时清空视为正常消耗。
- 超过 3 小时清空后必须在确认窗口内看到垃圾桶投放动作
- 超过 3 小时的食品拿出后又放回展示柜,触发报警
-达到报警阈值前清空视为正常消耗。
- 食品在区域内达到 `max_dwell_seconds` 时先产生 `time_alarm`
- 已报警食品从区域移出后,必须在确认窗口内看到垃圾桶投放动作
- 如果已报警食品移出后没有丢到垃圾桶里,报警事件升级为 `warning_escalated` 警告事件。
- 已报警食品拿出后又放回展示柜,触发违规事件。
## 当前实现范围
当前版本先实现纯业务状态机,不依赖摄像头模型。后续视觉模块只需要输出标准观察数据:
当前版本已经接入可运行的轻量视觉流程:区域占用、垃圾桶动作和 v1.2 的轻量 motion trajectory 都使用启发式图像差分实现,不使用 YOLO。后续训练好的 YOLO 食品检测模型会通过统一的 `disposal_evidence` / backend 合约接入,不改变批次计时状态机的业务输入形态。
视觉或 backend 模块需要输出标准观察数据:
```json
{
"ts": "2026-04-27T10:00:00+08:00",
"zone_counts": {
"r1c1": 3,
"r1c2": 0
"1": 1,
"2": 0
},
"trash_deposit": false
"trash_deposit": false,
"disposal_evidence": [
{
"source_zone_id": "1",
"target": "trash",
"confidence": 0.9,
"method": "motion",
"track_points": [
{"x": 0.22, "y": 0.30},
{"x": 0.48, "y": 0.58},
{"x": 0.76, "y": 0.78}
],
"item_class": null,
"detector_score": null,
"observed_at": "2026-04-27T10:00:03+08:00"
}
]
}
```
程序会输出 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 +68,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 +111,10 @@ http://127.0.0.1:23000
- 配置 RTSP 地址和阈值
- 从 RTSP 拉取一帧截图
- 标定 `r1c1``r2c4` 的 8 个格口
- 标定垃圾桶区域
- 设置 1 到 10 个食品区域
- 标定数字食品区域和垃圾桶 ROI
- 直接保存标定结果到项目配置文件
- 查看事件汇总和最近 JSONL 事件
- 查看事件汇总、区域序号、停留时间、报警和警告事件
项目仍保留 `tools/calibrator` 作为轻量单页标定工具,但正式使用建议走 `23000` 管理页。
@@ -98,8 +136,96 @@ http://127.0.0.1:19080
- `GET /api/manage/summary`
- `GET /api/manage/events`
## 运行识别计时进程
管理页只负责配置和查看数据。要产生数据,还需要启动运行进程:
```bash
scripts/run_runtime.sh
```
运行进程会:
1. 按配置读取 RTSP。
2.`ffmpeg` 周期抓取小尺寸 RGB 帧。
3. 按标定区域做占用变化检测。
4. 判断垃圾桶区域是否有明显投放动作。
5. 对刚清空的来源区域运行轻量 motion trajectory生成可选的 `disposal_evidence`
6. 调用批次计时状态机,优先使用匹配 `source_zone_id``disposal_evidence` 确认丢弃,再回退到通用垃圾桶动作。
7. 写入 `logs/events.jsonl`,管理页会读取这个文件。
当前视觉版本是可运行的启发式版本:
- 每个格口输出 `0/1` 占用状态,不识别单份数量。
- 启动后的前几帧用于建立空柜基线,默认 `3` 帧。
- 如果启动时格口里已经有食品,系统会把它当作基线,后续要等画面变化后才会产生计时事件。
- v1.2 轨迹识别是轻量 motion trajectory不加载 YOLO不要求模型文件。
- 训练好的 YOLO 模型后续应作为新的 backend 接入,并继续输出统一的 `disposal_evidence`
可选运行参数可以放在配置文件的 `[runtime]` 中:
```toml
[runtime]
sample_interval_seconds = 5.0
frame_width = 640
frame_height = 360
capture_timeout_seconds = 12.0
baseline_frames = 3
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
lighting_shift_guard_enabled = true
lighting_shift_min_regions = 3
lighting_shift_region_fraction = 0.6
lighting_shift_mean_delta = 45.0
trash_motion_delta = 18.0
trash_sustained_motion_delta = 8.0
trash_sustained_motion_frames = 2
trash_motion_cooldown_seconds = 3
trajectory_enabled = true
trajectory_window_seconds = 8
trajectory_sample_interval_seconds = 1.0
trajectory_min_points = 3
trajectory_segmented_enabled = true
trajectory_segmented_min_points = 2
trajectory_min_confidence = 0.72
trajectory_motion_delta = 20.0
trajectory_min_blob_area = 12
trajectory_max_blob_area_fraction = 0.35
trajectory_trash_entry_margin = 0.04
trajectory_backend = "motion"
yolo_enabled = false
yolo_model_path = ""
yolo_min_confidence = 0.65
diagnostics_path = "logs/runtime_diagnostics.jsonl"
```
`trajectory_backend = "motion"` 表示当前使用轻量轨迹 backend。`yolo_enabled``yolo_model_path``yolo_min_confidence` 是为后续训练模型预留的配置项;当前版本即使保留这些字段,也不会启用 YOLO 推理。
运行诊断写入 `logs/runtime_diagnostics.jsonl`。每行包含顶层 `disposal_evidence`,以及 `diagnostics.trajectory`
- 顶层 `disposal_evidence`:本帧实际输出给状态机的来源区域到垃圾桶证据。
- `diagnostics.lighting_shift`:多数区域同时同方向亮度漂移时启用,防止灯光/曝光变化被当成食品进出。
- `diagnostics.trajectory`:轻量轨迹 backend 的候选、来源 motion 预缓存、过期、拒绝、分段轨迹和已发出证据等调试信息。
## 本地测试
```bash
PYTHONPATH=src python3 -m unittest discover -s tests -v
```
前端测试和构建:
```bash
node --test web/test/zone-state.test.js
cd web && pnpm build
```

99
agent.md Normal file
View File

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

View File

@@ -1,52 +1,76 @@
camera_id = "cold_display_cam_01"
camera_id = "1"
timezone = "Asia/Shanghai"
[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.00, 0.00], [0.25, 0.00], [0.25, 0.50], [0.00, 0.50]]
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.25, 0.00], [0.50, 0.00], [0.50, 0.50], [0.25, 0.50]]
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"
polygon = [[0.50, 0.00], [0.75, 0.00], [0.75, 0.50], [0.50, 0.50]]
id = "3"
label = "区域 3"
polygon = [[0.545263, 0.400819], [0.587368, 0.417661], [0.554737, 0.500000], [0.509474, 0.483158]]
[[zones]]
id = "r1c4"
polygon = [[0.75, 0.00], [1.00, 0.00], [1.00, 0.50], [0.75, 0.50]]
[[zones]]
id = "r2c1"
polygon = [[0.00, 0.50], [0.25, 0.50], [0.25, 1.00], [0.00, 1.00]]
[[zones]]
id = "r2c2"
polygon = [[0.25, 0.50], [0.50, 0.50], [0.50, 1.00], [0.25, 1.00]]
[[zones]]
id = "r2c3"
polygon = [[0.50, 0.50], [0.75, 0.50], [0.75, 1.00], [0.50, 1.00]]
[[zones]]
id = "r2c4"
polygon = [[0.75, 0.50], [1.00, 0.50], [1.00, 1.00], [0.75, 1.00]]
id = "4"
label = "区域 4"
polygon = [[0.581255, 0.408928], [0.717971, 0.468544], [0.711092, 0.574018], [0.556320, 0.500645]]
[trash]
roi = [[0.80, 0.65], [1.00, 0.65], [1.00, 1.00], [0.80, 1.00]]
roi = [[0.776842, 0.486901], [0.896842, 0.522456], [0.841053, 0.857427], [0.716842, 0.853684]]
[runtime]
sample_interval_seconds = 5.0
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
lighting_shift_guard_enabled = true
lighting_shift_min_regions = 3
lighting_shift_region_fraction = 0.6
lighting_shift_mean_delta = 45.0
trash_motion_delta = 18.0
trash_sustained_motion_delta = 8.0
trash_sustained_motion_frames = 2
trash_motion_cooldown_seconds = 3
trajectory_enabled = true
trajectory_window_seconds = 8
trajectory_sample_interval_seconds = 1.0
trajectory_min_points = 3
trajectory_segmented_enabled = true
trajectory_segmented_min_points = 2
trajectory_min_confidence = 0.72
trajectory_motion_delta = 20.0
trajectory_min_blob_area = 12
trajectory_max_blob_area_fraction = 0.35
trajectory_trash_entry_margin = 0.04
trajectory_backend = "motion"
yolo_enabled = false
yolo_model_path = ""
yolo_min_confidence = 0.65
[event_sink]
path = "logs/events.jsonl"

View File

@@ -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

71
deploy/docker-compose.yml Normal file
View File

@@ -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

View File

@@ -0,0 +1,61 @@
# Cold Display Guard Web Industrial Console Design
**Date:** 2026-04-29
## Goal
Redesign the existing management frontend as an industrial operations console for refrigerated display monitoring, while preserving the current Vite single-page app, management API contract, and calibration workflow.
## Direction
Use a dense, production-oriented control-room interface. The application should feel like a tool used by operators who repeatedly calibrate zones, check runtime health, inspect alerts, and update camera settings. It should avoid a marketing-page layout and prioritize scanning, comparison, and fast action.
## Information Architecture
The app keeps the existing three views:
- `区域标定`: primary working view for RTSP capture and polygon editing.
- `事件数据`: monitoring view for runtime metrics and recent JSONL events.
- `运行配置`: configuration view for camera identity, RTSP URL, thresholds, and JSON preview.
The top bar becomes a compact console header with the product name, operational subtitle, connection/status message, and refresh action. Tabs remain visible but are styled as segmented navigation.
## Calibration View
The captured camera frame is the visual center of the page. The region selector and point editing tools sit beside it as operator controls, while calibration completeness and per-region point counts sit in a right-side inspection panel.
The canvas keeps its current behavior:
- RTSP snapshot must be captured before adding points.
- Clicks add normalized polygon points to the active region.
- Local draft storage is preserved.
- Saved calibration uses `PUT /api/manage/calibration`.
## Events View
Metrics appear as compact telemetry cards, with violation count and baseline state visually distinct. The event table remains the primary data surface, with rows styled for scanability and violation event names emphasized.
## Settings View
Configuration uses a two-column control layout with an adjacent JSON preview. The UI should make production parameters easy to inspect without changing the existing `PUT /api/manage/config` payload.
## Visual System
The console uses a cool industrial palette:
- charcoal and steel for structure
- white panels for data surfaces
- cyan/green for ready or active state
- amber for learning or pending state
- red for violations and failures
Controls use restrained radius, clear focus states, high contrast, and predictable spacing. The design avoids purple gradients, oversized hero sections, decorative cards, and purely ornamental backgrounds.
## Implementation Scope
Modify only the web frontend:
- `web/src/main.js`
- `web/src/styles.css`
Do not change API routes, backend behavior, deployment files, or existing unrelated workspace changes.

View File

@@ -0,0 +1,61 @@
# Web Industrial Console Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Redesign the Cold Display Guard management frontend into a dense industrial control console without changing the backend API.
**Architecture:** Keep the existing Vite app and vanilla JavaScript state model. Replace the static HTML shell with a more structured console layout, then update render helpers to emit richer classes for status, calibration completeness, and event severity. Implement the visual system in `web/src/styles.css`.
**Tech Stack:** Vite 5, vanilla JavaScript modules, HTML canvas, CSS custom properties.
---
### Task 1: Restructure Console Shell
**Files:**
- Modify: `web/src/main.js`
**Steps:**
1. Replace the current `app.innerHTML` shell with a console header, segmented tabs, status pill, and view-specific panels.
2. Preserve all existing element IDs used by JavaScript event handlers.
3. Keep the three views: `calibrationView`, `eventsView`, and `settingsView`.
4. Run `pnpm --dir web build`.
### Task 2: Add UI State Classes
**Files:**
- Modify: `web/src/main.js`
**Steps:**
1. Update `renderRegionList()` to include region color swatches, labels, active state, and completion state.
2. Update `renderRegionSummary()` to emit completion classes.
3. Update `renderMetrics()` to classify baseline and violation cards.
4. Update `renderEvents()` to classify violation rows.
5. Update `setStatus()` to classify success, error, and progress states.
6. Run `pnpm --dir web build`.
### Task 3: Implement Industrial Visual System
**Files:**
- Modify: `web/src/styles.css`
**Steps:**
1. Replace the existing generic styles with CSS variables for the industrial console palette.
2. Style the header, tabs, status strip, controls, panels, calibration canvas, metrics, tables, and settings layout.
3. Add responsive behavior for tablet and mobile widths.
4. Confirm text does not overflow compact controls.
5. Run `pnpm --dir web build`.
### Task 4: Verify
**Files:**
- Read: `web/dist/index.html`
**Steps:**
1. Run `pnpm --dir web build`.
2. Confirm Vite emits `web/dist`.
3. Review `git diff -- web/src/main.js web/src/styles.css docs/plans/2026-04-29-web-industrial-console-design.md docs/plans/2026-04-29-web-industrial-console.md`.

134
docs/project.md Normal file
View File

@@ -0,0 +1,134 @@
# 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.
The `v1.2 轨迹识别` batch adds source-zone trajectory evidence for disposal confirmation. The first implementation uses lightweight motion tracking and keeps YOLO disabled, while preserving an evidence contract that a later trained YOLO product detector can enrich.
## 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.
- v1.2 adds trajectory evidence between vision and engine: `TrajectoryTracker` emits source-zone-to-trash evidence; `BatchEngine` consumes the backend-neutral `disposal_evidence` contract.
- 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`.
- v1.2 diagnostics include root-level `disposal_evidence` plus `diagnostics.trajectory`.
- 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.
- v1.2 trajectory settings:
- `lighting_shift_guard_enabled`: freezes occupancy changes when many regions shift brightness in the same direction.
- `lighting_shift_min_regions`, `lighting_shift_region_fraction`, `lighting_shift_mean_delta`: tune the global lighting/exposure guard.
- `trajectory_enabled`: enables source-zone trajectory evidence.
- `trajectory_window_seconds`: seconds after a zone clears where movement can confirm disposal.
- `trajectory_sample_interval_seconds`: faster runtime delay while a candidate is active.
- `trajectory_min_points`: minimum sampled motion points required before evidence can emit.
- `trajectory_segmented_enabled`, `trajectory_segmented_min_points`: allow a source point plus trash-entry point to confirm disposal when the middle of the path is occluded.
- `trajectory_min_confidence`: minimum confidence before evidence can close pending disposal.
- `trajectory_motion_delta`: frame-difference threshold for trajectory motion points.
- `trajectory_min_blob_area`: minimum connected motion area to keep as a point.
- `trajectory_max_blob_area_fraction`: rejects overly broad frame motion as ambiguous.
- `trajectory_trash_entry_margin`: margin for treating a track point as entering the trash ROI.
- `trajectory_backend`: first valid value is `"motion"`.
- `yolo_enabled`, `yolo_model_path`, `yolo_min_confidence`: reserved for a future trained model backend. Current v1.2 keeps YOLO disabled.
## v1.2 Runtime Flow
1. `RTSPFrameSource` captures a resized RGB frame.
2. `ZoneOccupancyDetector` updates per-zone binary occupancy and generic trash-motion count from calibrated ROIs.
3. `TrajectoryTracker` caches recent source-region motion while a zone is occupied, watches zones that just cleared, follows lightweight motion points toward the trash ROI, and emits source-specific `DisposalEvidence` when confidence passes the configured threshold. If the middle of the path is occluded, a segmented source-to-trash track can still emit evidence.
4. `BatchEngine` processes `Observation(zone_counts, trash_deposit_count, disposal_evidence)`.
5. For pending disposal, matching `disposal_evidence.source_zone_id` confirms `batch_discarded` before generic FIFO `trash_deposit_count` fallback is used.
6. Runtime writes events to `logs/events.jsonl` and diagnostics to `logs/runtime_diagnostics.jsonl`.
The current tracker is a motion backend only. A later trained YOLO detector should plug in as another backend that enriches or replaces the evidence producer while preserving the same `disposal_evidence` contract consumed by the engine.
## Diagnostics
- Runtime diagnostics JSONL records one item per runtime iteration.
- Root `disposal_evidence` is the exact evidence list passed into the engine for that iteration.
- `diagnostics.zones` contains occupancy metrics used to derive `zone_counts`.
- `diagnostics.lighting_shift` reports whether global brightness drift suppressed occupancy transitions.
- `diagnostics.trash` contains generic trash-motion metrics and cooldown state.
- `diagnostics.trajectory` contains v1.2 candidate counts, emitted evidence count, motion point count, source-seeded and segmented-track flags, and per-candidate emitted/rejected/expired records.
- Capture failures still keep the v1.2 schema with root `disposal_evidence: []` and `diagnostics.trajectory.reason = "frame_capture_failed"`.
## 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.
In v1.2, `batch_discarded` can be triggered by zone-scoped `disposal_evidence` before falling back to generic `trash_deposit_count`. Evidence must match the pending batch's `source_zone_id`.
## 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`
- v1.2 operating notes:
- Keep `trajectory_backend = "motion"` and `yolo_enabled = false` unless a trained YOLO backend has been explicitly deployed.
- Confirm `logs/runtime_diagnostics.jsonl` contains top-level `disposal_evidence` and `diagnostics.trajectory` before judging trajectory behavior from events alone.
- When `TrajectoryTracker` has active candidates, runtime sampling uses `trajectory_sample_interval_seconds`; this can temporarily be faster than the normal `sample_interval_seconds`.
- On remote deployments, preserve the remote `config/example.toml` calibration and stream settings when syncing code.
## Known Risks
- The current vision detector is heuristic and reports binary occupancy, not item counts.
- The lighting-shift guard suppresses multi-zone brightness/exposure jumps; if operators intentionally fill most zones at once under a large lighting change, diagnostics should be reviewed before treating that interval as clean data.
- v1.2 motion tracking improves disposal matching but can still miss movement if the hand/object path is occluded, too broad, too small, or sampled too sparsely.
- YOLO config fields are present for compatibility, but no trained YOLO model is part of the current runtime.
- 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.

View File

@@ -0,0 +1,169 @@
# v1.2 Trajectory Recognition Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add source-zone trajectory evidence so alarmed items moved to the trash are discarded by their actual source zone, while keeping YOLO as a future optional backend.
**Architecture:** Extend `Observation` with backend-neutral `disposal_evidence`, make `BatchEngine` consume matching evidence before generic trash fallback, then add a no-dependency motion trajectory tracker in the vision layer. Runtime writes diagnostics and uses faster sampling only while trajectory candidates are active.
**Tech Stack:** Python 3.11+ standard library, existing `Frame` RGB bytes, `unittest`, Vite/Node tests only if frontend files change.
---
### Task 1: Data Contract And Engine Evidence Handling
**Files:**
- Modify: `src/cold_display_guard/models.py`
- Modify: `src/cold_display_guard/engine.py`
- Test: `tests/test_engine.py`
- [ ] **Step 1: Write failing tests**
Add tests for:
- `Observation.from_dict()` normalizes `disposal_evidence`.
- Matching evidence discards the pending batch for the same source zone.
- Evidence for zone 4 does not discard pending zone 1.
- Same-observation removal plus evidence closes the newly pending batch.
- Low-confidence evidence is ignored.
- [ ] **Step 2: Run RED tests**
Run: `PYTHONPATH=src python3 -m unittest tests.test_engine -v`
Expected: FAIL because `Observation` has no `disposal_evidence` and engine ignores evidence.
- [ ] **Step 3: Implement minimal contract and engine logic**
Add a `DisposalEvidence` dataclass and `Observation.disposal_evidence`. In `BatchEngine.process()`, apply evidence to matching `pending_disposal` before generic trash deposits and again after zone transitions for same-frame removals.
- [ ] **Step 4: Run GREEN tests**
Run: `PYTHONPATH=src python3 -m unittest tests.test_engine -v`
Expected: PASS.
- [ ] **Step 5: Commit phase**
Run:
```bash
git add src/cold_display_guard/models.py src/cold_display_guard/engine.py tests/test_engine.py
git commit -m "feat: add disposal evidence engine handling"
```
### Task 2: Lightweight Motion Trajectory Backend
**Files:**
- Modify: `src/cold_display_guard/vision.py`
- Test: `tests/test_vision.py`
- [ ] **Step 1: Write failing tests**
Add synthetic RGB-frame tests for:
- Motion from source zone to trash ROI emits evidence.
- Motion that starts away from source zone is rejected.
- Motion that never reaches trash ROI is rejected.
- One-frame reflection flash is rejected.
- Multiple active candidates do not cross-close each other.
- [ ] **Step 2: Run RED tests**
Run: `PYTHONPATH=src python3 -m unittest tests.test_vision -v`
Expected: FAIL because no trajectory tracker exists.
- [ ] **Step 3: Implement minimal motion tracker**
Add trajectory settings, candidate state, motion blob extraction from frame deltas, confidence scoring, and diagnostics. Keep the implementation standard-library only.
- [ ] **Step 4: Run GREEN tests**
Run: `PYTHONPATH=src python3 -m unittest tests.test_vision -v`
Expected: PASS.
- [ ] **Step 5: Commit phase**
Run:
```bash
git add src/cold_display_guard/vision.py tests/test_vision.py
git commit -m "feat: add lightweight trajectory tracking"
```
### Task 3: Runtime Configuration And Diagnostics Integration
**Files:**
- Modify: `src/cold_display_guard/main.py`
- Modify: `src/cold_display_guard/vision.py`
- Modify: `config/example.toml`
- Test: `tests/test_vision.py`
- Test: `tests/test_main.py`
- [ ] **Step 1: Write failing tests**
Add tests that verify runtime defaults include trajectory settings with YOLO disabled and diagnostics rows include emitted evidence when present.
- [ ] **Step 2: Run RED tests**
Run: `PYTHONPATH=src python3 -m unittest tests.test_vision tests.test_main -v`
Expected: FAIL because runtime does not pass evidence into `Observation` or expose trajectory sampling state.
- [ ] **Step 3: Implement runtime integration**
Return `disposal_evidence` from vision observation, write it to diagnostics, pass it to `Observation`, and use `trajectory_sample_interval_seconds` while candidates are active.
- [ ] **Step 4: Run GREEN tests**
Run: `PYTHONPATH=src python3 -m unittest tests.test_vision tests.test_main -v`
Expected: PASS.
- [ ] **Step 5: Commit phase**
Run:
```bash
git add src/cold_display_guard/main.py src/cold_display_guard/vision.py config/example.toml tests/test_vision.py tests/test_main.py
git commit -m "feat: integrate trajectory runtime diagnostics"
```
### Task 4: Documentation, Full Verification, And Deployment Prep
**Files:**
- Modify: `README_zh.md`
- Modify: `docs/project.md`
- Modify: `task_plan.md`
- Modify: `findings.md`
- Modify: `progress.md`
- Modify: `memories.md`
- [ ] **Step 1: Update docs**
Document v1.2 trajectory settings, evidence semantics, tests, and remote deployment notes.
- [ ] **Step 2: Run full verification**
Run:
```bash
PYTHONPATH=src python3 -m unittest discover -s tests -v
node --test web/test/zone-state.test.js
cd web && pnpm build
```
Expected: PASS for all commands. If frontend files did not change, frontend commands still provide regression coverage for the management console.
- [ ] **Step 3: Commit phase**
Run:
```bash
git add README_zh.md docs/project.md task_plan.md findings.md progress.md memories.md docs/superpowers/plans/2026-05-29-v1.2-trajectory-recognition.md
git commit -m "docs: document v1.2 trajectory recognition"
```
- [ ] **Step 4: Prepare remote deploy**
Use rsync excluding `config/example.toml`, rebuild runtime/API, and verify Docker services. Record the exact commands and results in `progress.md`.

View File

@@ -0,0 +1,278 @@
# Lightweight Trajectory Tracking With YOLO-Ready Evidence
Date: 2026-05-29
Branch: `lightweight-trajectory-tracking`
## Summary
The runtime currently confirms disposal by matching a zone becoming empty with generic trash-bin motion. That produces false matches when several zones change close together, when the trash ROI moves for an unrelated reason, or when reflection changes look like motion.
This design adds a trajectory evidence layer. Version 1 uses lightweight motion tracking to infer "source zone -> trash ROI" during a short window after a zone becomes empty. Version 2 can add a trained YOLO backend later without changing the event engine contract.
The first implementation must not require YOLO, PyTorch, ONNX Runtime, or OpenVINO. It must keep the current ROI occupancy timer and add a stronger disposal confirmation path.
## Goals
- Confirm disposal by source zone, not by FIFO matching alone.
- Reduce cases where zone 1 or zone 4 removal is incorrectly matched to another zone.
- Suppress reflection-only and trash-bin-only movement from confirming disposal.
- Keep CPU load low by activating trajectory analysis only after a zone becomes empty.
- Preserve a stable data contract that a future trained YOLO model can enrich.
## Non-Goals
- Do not convert the whole project to YOLO in the first trajectory version.
- Do not train or bundle a model in this branch.
- Do not replace ROI occupancy timing; it remains the authority for zone occupied/empty state.
- Do not require visual access inside the trash bin. Confirmation is based on motion entering the trash mouth ROI.
## Current Architecture
`main.py` captures one RTSP frame per sample interval with `ffmpeg`, passes it to `ZoneOccupancyDetector.observe()`, creates an `Observation`, and sends it to `BatchEngine.process()`.
`vision.py` currently outputs:
- `zone_counts`: stable occupied/empty state per configured zone.
- `trash_deposit_count`: count of generic trash ROI motion events.
- `diagnostics`: metrics for zones and trash motion.
`engine.py` currently consumes:
- `Observation.zone_counts`
- `Observation.trash_deposit_count`
When a timed-out batch is removed, it becomes pending disposal. A later trash motion can close pending batches, using FIFO order when source-zone evidence is missing.
## Proposed Architecture
Add a trajectory evidence path between vision and engine:
1. Zone occupancy still runs first.
2. When a zone transitions from occupied to empty, vision opens a short tracking window for that zone.
3. While any tracking window is active, the runtime temporarily shortens the capture delay so movement is sampled densely enough for a path.
4. During the window, a lightweight motion backend tracks moving blobs across the source zone, the path/corridor, and the trash mouth ROI.
5. If the path is coherent, vision emits a zone-scoped disposal evidence item.
6. The engine applies zone-scoped disposal evidence before using generic trash motion fallback.
The engine should depend on a neutral evidence format, not on YOLO or any specific tracking backend.
## Data Contract
Add `disposal_evidence` to `Observation`.
Example V1 evidence:
```json
{
"source_zone_id": "1",
"target": "trash",
"confidence": 0.86,
"method": "motion",
"started_at": "2026-05-29T14:03:20+08:00",
"ended_at": "2026-05-29T14:03:25+08:00",
"track_points": [[152, 210], [181, 219], [226, 235], [275, 252]],
"item_class": null,
"detector_score": null
}
```
Example later YOLO-enriched evidence:
```json
{
"source_zone_id": "1",
"target": "trash",
"confidence": 0.94,
"method": "motion+yolo",
"started_at": "2026-05-29T14:03:20+08:00",
"ended_at": "2026-05-29T14:03:25+08:00",
"track_points": [[152, 210], [181, 219], [226, 235], [275, 252]],
"item_class": "trained_product_a",
"detector_score": 0.91
}
```
`trash_deposit_count` remains for compatibility and fallback, but zone-scoped `disposal_evidence` takes priority.
## Components
### `TrajectoryTracker`
Owns active tracking windows. It receives current frame, timestamp, zone counts, region polygons, and trash ROI.
Responsibilities:
- Detect occupied-to-empty transitions.
- Start a per-zone candidate window.
- Keep recent motion observations for each active candidate.
- Decide whether a candidate has enough evidence to emit disposal evidence.
- Expire weak candidates without closing a batch.
- Report whether any candidate is active so `main.py` can use the faster trajectory sample interval.
### `MotionTrajectoryBackend`
The default V1 backend. It uses frame-to-frame differences and connected motion regions.
Responsibilities:
- Compute motion mask from the current and previous frame.
- Filter out tiny, static, and reflection-like changes.
- Extract moving blob centroids and bounding boxes.
- Associate centroids over time into a short track.
- Return backend-neutral track observations.
The backend must work without external model dependencies.
### `YoloDetectionBackend`
An optional future backend. It is not implemented in V1 but the interface is reserved.
Responsibilities when enabled later:
- Run only during active tracking windows or on configured path crops.
- Detect trained product classes and optionally hands/person keypoints.
- Attach `item_class`, `detector_score`, and bounding boxes to the same evidence contract.
- Never bypass trajectory validation. YOLO detections enrich confidence; they do not directly close events.
### `EvidenceFusion`
Combines backend output into final evidence.
V1 uses motion-only signals:
- Origin score: first meaningful motion is near or inside the source zone.
- Direction score: track generally moves from source zone toward trash ROI.
- Target score: final track points intersect or approach the trash mouth ROI.
- Stability score: track persists across enough frames and is not a one-frame flash.
V2 can add YOLO class and detector confidence into the same confidence calculation.
### `BatchEngine`
The engine should process evidence in this order:
1. Expire old pending disposal records.
2. Apply zone-scoped `disposal_evidence` to matching pending batches first.
3. Process zone transitions.
4. Apply any evidence created in the same observation to newly pending batches.
5. Use remaining generic `trash_deposit_count` as fallback for older behavior.
Zone-scoped evidence should only discard the pending batch from `source_zone_id`. It must not close a different zone when the source zone has no pending disposal.
## Runtime Flow
1. A zone is occupied long enough to create an active batch.
2. The batch reaches the dwell alarm threshold and emits `time_alarm`.
3. The item is removed from the zone.
4. Occupancy confirms the zone is empty, and the tracker opens or continues a short candidate window for that zone.
5. The engine emits `batch_pending_disposal` for that zone.
6. While the candidate is active, the runtime samples faster than the normal dwell timer interval.
7. Motion backend observes a track from source zone toward trash ROI.
8. If the track enters the trash mouth ROI with enough confidence, `disposal_evidence` is emitted.
9. The engine emits `batch_discarded` for that same zone. If evidence is emitted in the same observation that created pending disposal, the engine applies it after processing the zone-empty transition.
10. If no evidence arrives before the pending deadline, the current warning escalation behavior remains.
## Configuration
Add runtime settings with conservative defaults:
```toml
[runtime]
trajectory_enabled = true
trajectory_window_seconds = 8
trajectory_sample_interval_seconds = 1.0
trajectory_min_points = 3
trajectory_min_confidence = 0.72
trajectory_motion_delta = 20.0
trajectory_min_blob_area = 12
trajectory_max_blob_area_fraction = 0.35
trajectory_trash_entry_margin = 0.04
trajectory_backend = "motion"
yolo_enabled = false
yolo_model_path = ""
yolo_min_confidence = 0.65
```
`yolo_enabled = false` is the only valid first implementation mode. The config keys are included so deployment files and UI can evolve without changing the observation contract.
`trajectory_sample_interval_seconds` applies only while at least one trajectory candidate is active. Normal monitoring keeps using the existing `sample_interval_seconds`.
## Diagnostics
Append trajectory diagnostics to each runtime diagnostics row:
```json
{
"trajectory": {
"active_candidates": ["1"],
"emitted_evidence": [
{
"source_zone_id": "1",
"confidence": 0.86,
"method": "motion"
}
],
"expired_candidates": [],
"rejected_candidates": [
{
"source_zone_id": "4",
"reason": "target_not_reached"
}
]
}
}
```
Diagnostics should explain why a candidate was accepted, expired, or rejected. This is required for tuning the live camera.
## Error Handling
- If tracking cannot run because there is no previous frame, no evidence is emitted.
- If trash ROI is not configured, trajectory evidence is disabled and current generic behavior remains.
- If faster sampling cannot keep up with RTSP capture time, runtime should continue at the achievable rate and record capture timing in diagnostics.
- If multiple zones become empty at once, keep independent candidates. A track can confirm only one source zone unless future YOLO tracking explicitly supports multiple objects.
- If evidence confidence is below threshold, do not close pending disposal.
- If YOLO is enabled later but the model fails to load, runtime should fall back to motion-only tracking and record a diagnostic error.
## Testing Strategy
Unit tests:
- `Observation.from_dict()` accepts and normalizes `disposal_evidence`.
- Engine discards a pending batch from the matching source zone when evidence arrives.
- Engine does not discard zone 1 when evidence says source zone 4.
- Same-observation zone removal plus disposal evidence closes the newly pending batch.
- Generic `trash_deposit_count` still works as fallback.
- Low-confidence evidence is ignored.
Vision tests:
- Motion track from zone polygon to trash ROI emits evidence.
- Motion that starts away from the source zone is rejected.
- Motion that never reaches trash ROI is rejected.
- One-frame reflection flash is rejected.
- Multiple active candidates do not cross-close each other.
Runtime tests:
- Diagnostics include trajectory status.
- Config defaults load with trajectory enabled and YOLO disabled.
- Existing tests for zone occupancy, trash motion, restore state, API summary, and web zone rendering keep passing.
## Rollout Plan
1. Implement the data contract and engine evidence handling behind config.
2. Add motion trajectory backend and diagnostics.
3. Keep generic trash motion fallback enabled during rollout.
4. Deploy to the remote runtime and observe diagnostics for zones 1, 2, 4, 5, 6, and trash ROI.
5. Tune thresholds from live diagnostics.
6. Later, add YOLO backend as a separate implementation that feeds the same evidence contract.
## Acceptance Criteria
- Removing an alarmed item from zone 1 and moving it visibly to the trash mouth closes zone 1, not another zone.
- Removing alarmed items from multiple zones close together does not rely on FIFO when trajectory evidence identifies the source zone.
- Motion inside trash ROI alone does not confirm disposal if no source-zone trajectory exists.
- Reflection-only changes do not emit disposal evidence.
- The runtime works without YOLO dependencies installed.
- The future YOLO path can be added by implementing the reserved backend without changing `BatchEngine` event semantics.

View File

@@ -14,3 +14,115 @@
- 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.
## v1.2 轨迹识别 Findings
## Architecture
- `main.py` 当前每轮从 RTSP 截一帧,调用 `ZoneOccupancyDetector.observe()` 得到 `zone_counts``trash_deposit_count``diagnostics`,再构造 `Observation``BatchEngine.process()`
- `vision.py` 当前只有区域占用和垃圾桶动作计数,没有从“来源区域到垃圾桶”的轨迹证据。
- `engine.py` 当前先处理 pending 过期和旧 trash deposit再处理区域转移最后对同帧剩余 trash deposit 做兜底;这为同帧 evidence 处理提供了插入点。
- `models.py``Observation` 是最适合扩展统一 evidence contract 的位置。
- v1.2 设计规格已保存并提交:`docs/superpowers/specs/2026-05-29-lightweight-trajectory-yolo-ready-design.md`commit `ac6d368`
## Constraints
- 第一版必须在无 YOLO 依赖下运行。
- 轨迹检测只应在区域变空后的短窗口活跃,避免持续 CPU 压力。
- 垃圾桶内部不可见;确认点是进入垃圾桶口 ROI不是看到桶内物品。
- 不能让一个来源区域的 evidence 关闭另一个区域的 pending batch。
- 远端部署必须继续排除 `config/example.toml`,以保留 RTSP 和现场标定。
## Decisions
- 新字段命名为 `disposal_evidence`,元素使用 `source_zone_id``target``confidence``method``track_points`、可选 `item_class``detector_score`
- `trash_deposit_count` 保留兼容和兜底,但 engine 先使用 zone-scoped evidence。
- 轨迹诊断必须记录 active、emitted、rejected、expired方便现场调参。
- 后续 YOLO 模型只作为 evidence 增强输入,不能修改 `BatchEngine` 的业务语义。
## Final Review Findings
- Evidence and generic trash fallback must share the same count budget: one matched `disposal_evidence` consumes one observed trash deposit signal when `trash_deposit_count` is also present, but extra trash deposits must still fall back to pending batches.
- A trajectory candidate must not append source-zone-external motion before source motion has been seen. If outside motion appears first and is later followed by source noise, the candidate is rejected with `motion_started_outside_source` instead of producing evidence.
- Runtime diagnostics on the deployed host should be checked for schema only, not by printing config, because the remote config may contain RTSP credentials and calibration.
## 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.

42
memories.md Normal file
View File

@@ -0,0 +1,42 @@
# Memories
## User and Workflow
- User wants Chinese responses.
- Project path for subagent headers is `/Users/yoilun/Code/cold_display_guard`.
- Current workflow batch is `v1.2 轨迹识别`.
- User requested following `/Users/yoilun/Code/goal-subagents-workflow-prompt.md`.
- Every subagent task must begin with:
```text
[项目: /Users/yoilun/Code/cold_display_guard]
[工作流批次: v1.2 轨迹识别]
[阶段: 阶段 x]
[角色: 对应智能体角色]
```
## Technical Direction
- First implement lightweight motion trajectory detection without YOLO dependencies.
- Preserve a stable `disposal_evidence` contract for a future trained YOLO product detector.
- Keep ROI occupancy timing as the source of zone occupied/empty state.
- Use trajectory evidence before generic trash-motion FIFO fallback.
- Current runtime writes top-level `disposal_evidence` and nested `diagnostics.trajectory` into runtime diagnostics JSONL.
## v1.2 Completed Facts
- Stage 1 established the backend contract: `Observation.disposal_evidence` normalizes backend-neutral disposal evidence, and the engine can discard a pending batch only when evidence targets `trash`, meets confidence, and matches the pending `source_zone_id`.
- Stage 2 added the lightweight motion trajectory runtime path: ROI occupancy still drives occupied/empty state, `TrajectoryTracker` emits source-zone-to-trash evidence, and generic trash-motion count remains as a fallback.
- Stage 3 added diagnostics and tests for runtime evidence propagation, trajectory sampling interval behavior, capture-failure schema, and trajectory/yolo runtime config parsing.
- Final review fixes: matched evidence now only subtracts the trash fallback budget by the number of batches it actually closed, and trajectory candidates reject outside-before-source motion with `motion_started_outside_source`.
- 2026-06-01 false events across zones 1-7 were caused by a global brightness/exposure drop around 04:55 and recovery around 08:16; the fix is a lighting-shift guard that freezes occupancy transitions when many regions shift brightness in the same direction.
- 2026-06-01 trajectory update allows segmented source-to-trash tracks: after a source-zone motion point is seen, the middle of the path may be occluded, and a later trash-entry point can still emit `disposal_evidence` when direction/confidence pass.
- 2026-06-01 follow-up fixed empty-confirmation lag: `TrajectoryTracker` now caches recent source-region motion while a zone is still occupied and seeds the disposal candidate when the stable zone count finally clears.
- Current v1.2 does not use YOLO. `yolo_enabled`, `yolo_model_path`, and `yolo_min_confidence` are reserved for a future trained model backend that should keep emitting the same `disposal_evidence` shape.
## Remote Deployment Notes
- Remote deployment target is `xiaozheng@192.168.5.206:/home/xiaozheng/cold_display_guard`.
- Preserve the remote `config/example.toml`; it may contain camera, calibration, threshold, and deployment-specific runtime settings that must not be overwritten blindly.
- When syncing code remotely, verify that runtime diagnostics still show top-level `disposal_evidence` and `diagnostics.trajectory` before evaluating v1.2 trajectory behavior from `logs/events.jsonl`.
- The latest v1.2 deployment was verified with `cold-display-guard-runtime` and `cold-display-guard-api` up, API health `status=ok`, and diagnostics schema showing `has_disposal_evidence=True` plus `has_trajectory=True`.

View File

@@ -11,3 +11,357 @@
- 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 |
## 2026-05-29 v1.2 轨迹识别
### Session Log
| Time | Phase | Actor | Action | Result |
| --- | --- | --- | --- | --- |
| 2026-05-29 | Setup | Main Agent | Created Goal for `v1.2 轨迹识别` using `/Users/yoilun/Code/cold_display_guard` as the real project path | Active goal tracks lightweight trajectory detection plus YOLO-ready evidence contract |
| 2026-05-29 | Setup | Main Agent | Read `/Users/yoilun/Code/goal-subagents-workflow-prompt.md` | Workflow requires task files, stage-based coding/testing agents, bug loop limit, and standard subagent context header |
| 2026-05-29 | Setup | Main Agent | Read existing `task_plan.md`, `findings.md`, `progress.md`, and `docs/project.md` | v1.1 state is complete; v1.2 plan can start on branch `lightweight-trajectory-tracking` |
| 2026-05-29 | Phase 1 | Main Agent | Marked Phase 1 as `in_progress` | Preparing coding agent for data contract and engine evidence handling |
| 2026-05-29 | Phase 1 | Coding Agent | Implemented initial `disposal_evidence` contract and engine handling | Target engine tests and full Python tests passed in coding agent run, but testing agent found review issues |
| 2026-05-29 | Phase 1 | Testing Agent | Reviewed phase 1 implementation | Verdict fail: evidence/count double consumption, missing target validation, null classifier fields coercion |
| 2026-05-29 | Phase 1 | Coding Agent | Fixed testing-agent findings | Added target validation, nullable optional fields, and evidence/count double-consume guard |
| 2026-05-29 | Phase 1 | Testing Agent | Re-tested phase 1 fixes | Verdict pass; no bugs found |
| 2026-05-29 | Phase 1 | Main Agent | Ran local verification | `tests.test_engine` passed with 24 tests; full Python suite passed with 55 tests |
| 2026-05-29 | Phase 2 | Main Agent | Marked Phase 2 as `in_progress` | Preparing fresh coding/testing agents for lightweight motion trajectory detection |
| 2026-05-29 | Phase 2 | Coding Agent | Implemented initial lightweight `TrajectoryTracker` | Target vision tests passed locally, but testing agent found multi-candidate and source-margin risks |
| 2026-05-29 | Phase 2 | Testing Agent | Reviewed initial trajectory tracker | Verdict fail: single blob can confirm multiple candidates, source margin false positive, diagnostics lack per-candidate reasons |
| 2026-05-29 | Phase 2 | Coding Agent | Fixed trajectory tracker findings | Added blob consumption, strict source polygon origin, and per-candidate diagnostics |
| 2026-05-29 | Phase 2 | Testing Agent | Re-tested phase 2 fixes | Verdict pass; no bugs found |
| 2026-05-29 | Phase 2 | Main Agent | Ran local verification | `tests.test_vision` passed with 20 tests; full Python suite passed with 64 tests; dependency scan had no model/heavy vision matches |
| 2026-05-29 | Phase 3 | Main Agent | Marked Phase 3 as `in_progress` | Preparing fresh coding/testing agents for runtime integration |
| 2026-05-29 | Phase 3 | Coding Agent | Implemented initial runtime integration | Target main/vision tests and full Python tests passed in coding agent run |
| 2026-05-29 | Phase 3 | Testing Agent | Reviewed runtime integration | Verdict pass with non-blocking concerns: capture-error diagnostics schema, `create=True` patch robustness, broad helper type |
| 2026-05-29 | Phase 3 | Coding Agent | Fixed runtime integration review concerns | Error diagnostics keep trajectory schema; tests no longer use `create=True`; evidence payload helper type narrowed |
| 2026-05-29 | Phase 3 | Testing Agent | Re-tested runtime integration concerns | Verdict pass; no new issues |
| 2026-05-29 | Phase 3 | Main Agent | Ran local verification | `tests.test_main tests.test_vision` passed with 26 tests; full Python suite passed with 68 tests; dependency scan had no model/heavy vision matches |
| 2026-05-29 | Phase 4 | Main Agent | Marked Phase 4 as `in_progress` | Preparing documentation, final verification, remote deployment, and final review |
| 2026-05-29 | Phase 4 | Main Agent | Synced v1.2 code to `xiaozheng@192.168.5.206` | `rsync` completed while excluding remote `config/example.toml` |
| 2026-05-29 | Phase 4 | Main Agent | Tried remote config patch with inline heredoc | Failed with shell quoting `SyntaxError`; switching to scp temporary patch script |
| 2026-05-29 | Phase 4 | Main Agent | Patched remote runtime config via uploaded Python script | Added missing trajectory/yolo runtime keys without printing RTSP config |
| 2026-05-29 | Phase 4 | Main Agent | Rebuilt and restarted remote runtime/API containers | `cold-display-guard-runtime` and `cold-display-guard-api` recreated and started |
| 2026-05-29 | Phase 4 | Final Code Review Agent | Reviewed full v1.2 implementation | Verdict fail: extra trash fallback was suppressed, tracker could seed from outside-source motion, and docs named a non-existent backend |
| 2026-05-29 | Phase 4 | Main Agent | Fixed final review findings | Added regression tests, adjusted trash fallback budgeting, rejected outside-before-source trajectories, and renamed docs to `TrajectoryTracker` |
| 2026-05-29 | Phase 4 | Main Agent | Re-ran local full verification | Python 70 tests passed; frontend 22 tests passed; Vite build passed; no heavy model dependency matches |
| 2026-05-29 | Phase 4 | Main Agent | Re-synced and redeployed fix to `xiaozheng@192.168.5.206` | Runtime/API rebuilt and restarted from the fixed code |
| 2026-05-29 | Phase 4 | Main Agent | Verified remote runtime after redeploy | Containers are up, API health returns `status=ok`, diagnostics contain `disposal_evidence` and `diagnostics.trajectory` |
### Test Results
| Time | Command | Result | Notes |
| --- | --- | --- | --- |
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_engine -v` | pass | 24 engine tests passed after phase 1 evidence fixes |
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 55 full Python tests passed after phase 1 |
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_vision -v` | pass | 20 vision tests passed after phase 2 trajectory tracker |
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 64 full Python tests passed after phase 2 |
| 2026-05-29 | `rg -n "ultralytics|torch|onnxruntime|openvino|opencv|cv2|numpy" src tests pyproject.toml` | pass | No matches; command exited 1 because no heavy vision/model dependency was found |
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_main tests.test_vision -v` | pass | 26 runtime/vision tests passed after phase 3 |
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 68 full Python tests passed after phase 3 |
| 2026-05-29 | `rg -n "ultralytics|torch|onnxruntime|openvino|opencv|cv2|numpy" src tests pyproject.toml` | pass | No matches; command exited 1 because no heavy vision/model dependency was found |
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 70 full Python tests passed after final review fixes |
| 2026-05-29 | `node --test web/test/zone-state.test.js` | pass | 22 frontend model tests passed |
| 2026-05-29 | `cd web && pnpm build` | pass | Vite production build passed |
| 2026-05-29 | `rg -n "ultralytics|torch|onnxruntime|openvino|opencv|cv2|numpy" src tests pyproject.toml` | pass | No matches; command exited 1 because no heavy vision/model dependency was found |
| 2026-05-29 | `docker compose ... build cold-display-guard-runtime cold-display-guard-api && docker compose ... up -d --no-deps ...` on remote | pass | Runtime/API rebuilt and restarted after final fixes |
| 2026-05-29 | `curl --max-time 5 http://192.168.5.206:19080/api/manage/health` | pass | `status=ok`, `runtime_status=running` |
| 2026-05-29 | Remote diagnostics schema check script | pass | `has_disposal_evidence=True`, `has_trajectory=True` |
### Bug Loop
| Phase | Bug | Fix Attempt | Retest Result |
| --- | --- | --- | --- |
| Phase 1 | `disposal_evidence` and `trash_deposit_count` can double-consume the same disposal signal | Added regression test and suppress generic trash fallback when confirming source-specific evidence exists in the observation | Resolved; testing agent and local full Python suite passed |
| Phase 1 | High-confidence evidence with non-trash target can close pending disposal | Added target whitelist for `trash` / `trash_bin` plus regression test | Resolved; testing agent and local full Python suite passed |
| Phase 1 | `item_class: null` and `detector_score: null` lose null semantics | Changed optional evidence fields to preserve `None` plus regression test | Resolved; testing agent and local full Python suite passed |
| Phase 2 | Single motion blob can confirm multiple active candidates | Added frame-local blob IDs and consume each sampled blob once per frame | Resolved; testing agent and local full Python suite passed |
| Phase 2 | Source-zone margin can treat near-outside movement as source-origin movement | Source-origin check now requires strict source polygon containment | Resolved; testing agent and local full Python suite passed |
| Phase 2 | Trajectory diagnostics only expose aggregate counts | Added `emitted`, `rejected`, and `expired` diagnostic lists with source, reason, point count, confidence, and direction score | Resolved; testing agent and local full Python suite passed |
| Phase 3 | Capture-error diagnostics rows lack `disposal_evidence` and `diagnostics.trajectory` schema | Added regression test and wrote empty evidence plus trajectory error diagnostics on capture failure | Resolved; testing agent and local full Python suite passed |
| Phase 3 | Runtime tests patch `TrajectoryTracker` with `create=True`, which can mask missing imports | Removed `create=True` and asserted fake tracker observe calls | Resolved; testing agent and local full Python suite passed |
| Phase 3 | `disposal_evidence_payloads()` accepts `list[object]` but blindly calls `asdict()` | Narrowed helper signature to `list[DisposalEvidence]` | Resolved; testing agent and local full Python suite passed |
| Phase 4 | Remote config patch inline heredoc lost the empty-string value for `yolo_model_path` | Switched to an scp-uploaded Python patch script instead of repeating inline quoting | Resolved; remote config patch reported 13 runtime keys patched |
| Phase 4 | Source-specific evidence disabled all generic fallback trash deposits | Subtract only the count of evidence-discard events from `remaining_trash_deposits`; add regression test for evidence plus `trash_deposit_count=2` | Resolved; targeted regression and full Python suite passed |
| Phase 4 | Tracker could keep an outside-source blob as the first point before source motion | Track outside-before-source contamination, reject later source noise with `motion_started_outside_source`, and never append those outside points | Resolved; targeted regression and full Python suite passed |
| Phase 4 | `docs/project.md` referenced non-existent `MotionTrajectoryBackend` | Changed architecture docs to name the implemented `TrajectoryTracker` | Resolved; `rg` no longer finds the stale name in project docs/README/src/tests |
## 2026-05-29 Phase Completed: Phase 4 - Documentation, Verification, And Deployment
Status: complete
Files Changed:
- `.gitignore`
- `README_zh.md`
- `docs/project.md`
- `findings.md`
- `memories.md`
- `progress.md`
- `task_plan.md`
- `src/cold_display_guard/engine.py`
- `src/cold_display_guard/vision.py`
- `tests/test_engine.py`
- `tests/test_vision.py`
Tests:
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`: pass, 70 tests
- `node --test web/test/zone-state.test.js`: pass, 22 tests
- `cd web && pnpm build`: pass
- `rg -n "ultralytics|torch|onnxruntime|openvino|opencv|cv2|numpy" src tests pyproject.toml`: pass, no matches
- Remote Docker rebuild/restart: pass
- Remote API health: pass
- Remote diagnostics schema check: pass
Notes:
- Final review findings were fixed before redeploy.
- Remote sync continued to exclude `config/example.toml` to preserve camera/calibration settings.
- Remote diagnostics check intentionally printed only schema booleans and safe trajectory keys.
Risks:
- v1.2 is still heuristic motion tracking. Live precision should be tuned from diagnostics, and future YOLO integration should continue using the same `disposal_evidence` contract.
## 2026-05-29 Phase Completed: Phase 3 - Runtime Integration
Status: complete
Files Changed:
- `src/cold_display_guard/main.py`
- `config/example.toml`
- `tests/test_main.py`
- `tests/test_vision.py`
- `task_plan.md`
- `progress.md`
Tests:
- `PYTHONPATH=src python3 -m unittest tests.test_main tests.test_vision -v`: pass
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`: pass
- `rg -n "ultralytics|torch|onnxruntime|openvino|opencv|cv2|numpy" src tests pyproject.toml`: pass, no matches
Notes:
- Runtime now passes trajectory `disposal_evidence` into `Observation`.
- Diagnostics rows include top-level serialized `disposal_evidence` and nested `diagnostics.trajectory`.
- Capture failure diagnostics keep the same trajectory/evidence schema.
- Runtime sleeps at `trajectory_sample_interval_seconds` while trajectory candidates are active.
Risks:
- No live-camera validation has run yet in this phase; deployment and remote runtime observation remain phase 4 work.
## 2026-05-29 Phase Completed: Phase 2 - Lightweight Motion Trajectory Backend
Status: complete
Files Changed:
- `src/cold_display_guard/vision.py`
- `tests/test_vision.py`
- `task_plan.md`
- `progress.md`
Tests:
- `PYTHONPATH=src python3 -m unittest tests.test_vision -v`: pass
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`: pass
- `rg -n "ultralytics|torch|onnxruntime|openvino|opencv|cv2|numpy" src tests pyproject.toml`: pass, no matches
Notes:
- `TrajectoryTracker` now emits `DisposalEvidence` with `target=trash` and `method=motion`.
- Tracker uses frame-delta motion blobs, strict source-origin validation, target ROI validation, direction scoring, and per-candidate diagnostics.
- Multiple candidates cannot reuse the same frame-local blob for confirmation.
Risks:
- Tracker is implemented but not yet wired into `main.py`; phase 3 will integrate runtime observation, diagnostics, and faster active-candidate sampling.
## 2026-05-29 Phase Completed: Phase 1 - Data Contract And Engine Evidence Handling
Status: complete
Files Changed:
- `src/cold_display_guard/models.py`
- `src/cold_display_guard/engine.py`
- `tests/test_engine.py`
- `task_plan.md`
- `progress.md`
Tests:
- `PYTHONPATH=src python3 -m unittest tests.test_engine -v`: pass
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`: pass
Notes:
- `Observation` now supports `disposal_evidence`.
- `BatchEngine` applies source-zone evidence before generic trash fallback and again after same-frame zone transitions.
- Evidence must meet confidence threshold and target the trash.
Risks:
- Threshold is currently a phase-1 constant; later runtime config integration will make it configurable.

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ dependencies = []
[project.scripts]
cold-display-guard = "cold_display_guard.cli:main"
cold-display-guard-manage = "cold_display_guard.manage_api:main"
cold-display-guard-run = "cold_display_guard.main:main"
[tool.setuptools.packages.find]
where = ["src"]

6
scripts/run_runtime.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
CONFIG_PATH="${CONFIG_PATH:-config/example.toml}"
PYTHONPATH=src python3 -m cold_display_guard.main --config "$CONFIG_PATH"

View File

@@ -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,24 +57,53 @@ 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:
if valid_zones or incoming_numeric_zone_ids:
layout = merged.setdefault("layout", {})
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)
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:
@@ -109,11 +139,30 @@ def format_config_document(data: dict[str, Any]) -> str:
lines.append(f'trash_confirmation_seconds = {int(thresholds.get("trash_confirmation_seconds", 120))}')
lines.append("")
runtime = data.get("runtime", {})
if runtime:
lines.append("[runtime]")
for key in sorted(runtime):
value = runtime[key]
if isinstance(value, bool):
lines.append(f"{key} = {str(value).lower()}")
elif isinstance(value, int | float):
lines.append(f"{key} = {value}")
else:
lines.append(f'{key} = "{_escape(str(value))}"')
lines.append("")
layout = data.get("layout", {})
zone_ids = [str(item) for item in layout.get("zone_ids", DEFAULT_ZONE_IDS)]
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]")
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("[layout]")
lines.append(f"rows = {rows}")
lines.append(f"cols = {cols}")
lines.append(f"zone_ids = {_format_string_array(zone_ids)}")
@@ -126,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("")
@@ -143,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))
@@ -151,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):

View File

@@ -3,7 +3,11 @@ from __future__ import annotations
from datetime import datetime
from typing import Any
from cold_display_guard.models import Batch, EngineSettings, Observation
from cold_display_guard.models import Batch, DisposalEvidence, EngineSettings, Observation
DISPOSAL_EVIDENCE_CONFIDENCE_THRESHOLD = 0.72
TRASH_DISPOSAL_TARGETS = {"trash", "trash_bin"}
class BatchEngine:
@@ -18,9 +22,21 @@ 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
used_disposal_evidence: set[int] = set()
events.extend(self._expire_pending_disposal(observation.ts))
events.extend(self._apply_trash_deposits(observation.ts, observation.trash_deposit_count))
evidence_events = self._apply_disposal_evidence(
observation.ts,
observation.disposal_evidence,
used_disposal_evidence,
)
remaining_trash_deposits = max(0, remaining_trash_deposits - len(evidence_events))
events.extend(evidence_events)
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 +46,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 +78,18 @@ class BatchEngine:
self._zone_counts[zone_id] = new_count
newly_pending_count = max(0, len(self.pending_disposal) - pending_count_before_zone_transitions)
evidence_events = self._apply_disposal_evidence(
observation.ts,
observation.disposal_evidence,
used_disposal_evidence,
)
remaining_trash_deposits = max(0, remaining_trash_deposits - len(evidence_events))
events.extend(evidence_events)
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 +103,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 +174,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,24 +254,58 @@ 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
def _apply_disposal_evidence(
self,
when: datetime,
disposal_evidence: list[DisposalEvidence],
used_evidence_indices: set[int],
) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
for index, evidence in enumerate(disposal_evidence):
if index in used_evidence_indices:
continue
if not self._is_confirming_disposal_evidence(evidence):
continue
pending_index = self._pending_index_for_source_zone(evidence.source_zone_id)
if pending_index is None:
continue
batch = self.pending_disposal.pop(pending_index)
batch.state = "discarded"
self.closed_batches.append(batch)
used_evidence_indices.add(index)
events.append(self._event("batch_discarded", when, batch, severity="info"))
return events
def _is_confirming_disposal_evidence(self, evidence: DisposalEvidence) -> bool:
if evidence.confidence < DISPOSAL_EVIDENCE_CONFIDENCE_THRESHOLD:
return False
return evidence.target.lower() in TRASH_DISPOSAL_TARGETS
def _pending_index_for_source_zone(self, source_zone_id: str) -> int | None:
for index, batch in enumerate(self.pending_disposal):
if batch.zone_id == source_zone_id:
return index
return None
def _expire_pending_disposal(self, when: datetime) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
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 +316,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 +340,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

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from cold_display_guard.vision import Frame
class FrameCaptureError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class RTSPFrameSource:
rtsp_url: str
width: int = 640
height: int = 360
timeout_seconds: float = 12.0
def capture(self) -> Frame:
command = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-rtsp_transport",
"tcp",
"-i",
self.rtsp_url,
"-frames:v",
"1",
"-vf",
f"scale={self.width}:{self.height}",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-",
]
try:
result = subprocess.run(
command,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=max(1.0, self.timeout_seconds),
)
except FileNotFoundError as exc:
raise FrameCaptureError("ffmpeg not found; install ffmpeg first") from exc
except subprocess.TimeoutExpired as exc:
raise FrameCaptureError(f"ffmpeg timed out after {self.timeout_seconds:g}s") from exc
if result.returncode != 0:
message = result.stderr.decode("utf-8", errors="replace").strip()
raise FrameCaptureError(message or f"ffmpeg exited with code {result.returncode}")
expected_size = self.width * self.height * 3
if len(result.stdout) != expected_size:
raise FrameCaptureError(f"expected {expected_size} RGB bytes, got {len(result.stdout)}")
return Frame(width=self.width, height=self.height, rgb=result.stdout)

View File

@@ -0,0 +1,251 @@
from __future__ import annotations
import argparse
import json
import time
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from cold_display_guard.config import load_config_document, load_settings, resolve_config_path, resolve_project_root
from cold_display_guard.engine import BatchEngine
from cold_display_guard.frame_source import FrameCaptureError, RTSPFrameSource
from cold_display_guard.models import DisposalEvidence, Observation
from cold_display_guard.vision import (
RegionMetrics,
TrajectoryTracker,
ZoneOccupancyDetector,
load_regions,
load_runtime_vision_settings,
metrics_indicate_occupied,
)
def main() -> int:
args = parse_args().parse_args()
run(args.config, once=args.once, max_iterations=args.max_iterations)
return 0
def parse_args() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run Cold Display Guard RTSP batch monitor")
parser.add_argument("--config", default=str(resolve_config_path(None)), help="Path to TOML config")
parser.add_argument("--once", action="store_true", help="Process one frame and exit")
parser.add_argument("--max-iterations", type=int, default=0, help="Stop after N frames; 0 means forever")
return parser
def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) -> None:
resolved_config = resolve_config_path(config_path)
project_root = resolve_project_root(resolved_config)
config = load_config_document(resolved_config)
settings = load_settings(resolved_config)
runtime = config.get("runtime", {})
stream = config.get("stream", {})
rtsp_url = str(stream.get("rtsp_url", "")).strip()
if not rtsp_url:
raise ValueError("stream.rtsp_url is required")
regions, trash_region = load_regions(config)
if not regions:
raise ValueError("at least one [[zones]] polygon is required")
timezone = ZoneInfo(str(config.get("timezone", "Asia/Shanghai")))
event_path = resolve_project_path(project_root, str(config.get("event_sink", {}).get("path", "logs/events.jsonl")))
diagnostics_path = resolve_project_path(project_root, str(runtime.get("diagnostics_path", "logs/runtime_diagnostics.jsonl")))
sample_interval_seconds = max(0.1, float(runtime.get("sample_interval_seconds", 5.0)))
frame_width = max(64, int(runtime.get("frame_width", 640)))
frame_height = max(64, int(runtime.get("frame_height", 360)))
capture_timeout_seconds = max(1.0, float(runtime.get("capture_timeout_seconds", 12.0)))
source = RTSPFrameSource(
rtsp_url=rtsp_url,
width=frame_width,
height=frame_height,
timeout_seconds=capture_timeout_seconds,
)
vision_settings = load_runtime_vision_settings(config)
detector = ZoneOccupancyDetector(regions, trash_region, vision_settings)
trajectory_tracker = TrajectoryTracker(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)
print(f"Cold Display Guard runtime started")
print(f"Config: {resolved_config}")
print(f"Events: {event_path}")
print(f"Diagnostics: {diagnostics_path}")
iteration = 0
while True:
iteration += 1
when = datetime.now(timezone)
try:
frame = source.capture()
zone_counts, trash_deposit_count, diagnostics = detector.observe(frame, when)
disposal_evidence, trajectory_diagnostics = trajectory_tracker.observe(frame, when, zone_counts)
observation = Observation(
ts=when,
zone_counts=zone_counts,
trash_deposit_count=trash_deposit_count,
disposal_evidence=disposal_evidence,
)
events = engine.process(observation)
append_jsonl(event_path, events)
append_jsonl(
diagnostics_path,
[
{
"ts": when.isoformat(),
"zone_counts": zone_counts,
"trash_deposit_count": trash_deposit_count,
"disposal_evidence": disposal_evidence_payloads(disposal_evidence),
"diagnostics": {**diagnostics, "trajectory": trajectory_diagnostics},
}
],
)
if events:
print(f"{when.isoformat()} wrote {len(events)} event(s)")
except FrameCaptureError as exc:
append_jsonl(
diagnostics_path,
[
{
"ts": when.isoformat(),
"error": "frame_capture_failed",
"message": str(exc),
"disposal_evidence": [],
"diagnostics": {
"trajectory": {
"disabled": True,
"reason": "frame_capture_failed",
"emitted_evidence": 0,
}
},
}
],
)
print(f"{when.isoformat()} frame capture failed: {exc}")
if once or (max_iterations > 0 and iteration >= max_iterations):
break
sleep_seconds = (
vision_settings.trajectory_sample_interval_seconds
if trajectory_tracker.has_active_candidates
else sample_interval_seconds
)
time.sleep(sleep_seconds)
def resolve_project_path(project_root: Path, raw_path: str) -> Path:
path = Path(raw_path).expanduser()
if not path.is_absolute():
path = project_root / path
return path.resolve()
def append_jsonl(path: Path, payloads: list[dict]) -> None:
if not payloads:
return
with path.open("a", encoding="utf-8") as handle:
for payload in payloads:
handle.write(json.dumps(payload, ensure_ascii=False, sort_keys=True))
handle.write("\n")
def disposal_evidence_payloads(disposal_evidence: list[DisposalEvidence]) -> list[dict]:
return [asdict(item) for item in disposal_evidence]
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())

View File

@@ -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"
@@ -65,6 +66,11 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
limit = bounded_int(query.get("limit", ["200"])[0], 1, MAX_EVENT_LINES)
self._send_json({"items": load_events(ctx, limit), "limit": limit})
return
if parsed.path == "/api/manage/diagnostics":
query = parse_qs(parsed.query)
limit = bounded_int(query.get("limit", ["50"])[0], 1, MAX_EVENT_LINES)
self._send_json({"items": load_diagnostics(ctx, limit), "limit": limit})
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_PUT(self) -> None:
@@ -114,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))
@@ -233,23 +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,
@@ -258,25 +289,40 @@ 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, config),
"baseline_ready": latest_baseline_ready(diagnostics),
},
}
def load_events(ctx: ManageContext, limit: int) -> list[dict[str, Any]]:
path = event_sink_path(ctx)
return load_jsonl_tail(path, limit)
def load_diagnostics(ctx: ManageContext, limit: int) -> list[dict[str, Any]]:
path = diagnostics_path(ctx)
return load_jsonl_tail(path, limit)
def load_jsonl_tail(path: Path, limit: int) -> list[dict[str, Any]]:
lines = tail_lines(path, limit)
events: list[dict[str, Any]] = []
items: list[dict[str, Any]] = []
for line in lines:
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(payload, dict):
events.append(payload)
return events
items.append(payload)
return items
def event_sink_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path:
@@ -289,6 +335,101 @@ def event_sink_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> P
return path.resolve()
def diagnostics_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path:
if data is None:
data = load_config_document(ctx.config_path)
raw_path = str(data.get("runtime", {}).get("diagnostics_path", "logs/runtime_diagnostics.jsonl"))
path = Path(raw_path).expanduser()
if not path.is_absolute():
path = ctx.project_root / path
return path.resolve()
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")
if isinstance(diagnostics_payload, dict):
return bool(diagnostics_payload.get("baseline_ready", False))
return False
def tail_lines(path: Path, limit: int) -> list[str]:
if not path.exists():
return []

View File

@@ -24,11 +24,39 @@ class EngineSettings:
return timedelta(seconds=self.trash_confirmation_seconds)
@dataclass(frozen=True, slots=True)
class DisposalEvidence:
source_zone_id: str
target: str
confidence: float
method: str
track_points: list[Any]
item_class: str | None
detector_score: float | None
observed_at: str | None = None
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "DisposalEvidence":
return cls(
source_zone_id=str(payload.get("source_zone_id", "")).strip(),
target=str(payload.get("target", "")).strip(),
confidence=_float_or_zero(payload.get("confidence", 0.0)),
method=str(payload.get("method", "")).strip(),
track_points=_normalize_track_points(payload.get("track_points", [])),
item_class=_optional_string(payload.get("item_class")),
detector_score=_optional_float(payload.get("detector_score")),
observed_at=_optional_string(
payload.get("observed_at", payload.get("detected_at", payload.get("ts")))
),
)
@dataclass(frozen=True, slots=True)
class Observation:
ts: datetime
zone_counts: dict[str, int]
trash_deposit_count: int = 0
disposal_evidence: list[DisposalEvidence] = field(default_factory=list)
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "Observation":
@@ -46,6 +74,7 @@ class Observation:
ts=ts,
zone_counts={key: max(0, int(value)) for key, value in payload["zone_counts"].items()},
trash_deposit_count=max(0, trash_deposit_count),
disposal_evidence=_normalize_disposal_evidence(payload.get("disposal_evidence", [])),
)
@@ -56,6 +85,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
@@ -66,3 +96,47 @@ class Batch:
if self.ended_at is not None:
return self.dwell_seconds
return max(0, int((when - self.started_at).total_seconds()))
def _normalize_disposal_evidence(raw_evidence: Any) -> list[DisposalEvidence]:
if raw_evidence is None:
return []
if isinstance(raw_evidence, dict):
raw_items = [raw_evidence]
else:
raw_items = raw_evidence
return [
DisposalEvidence.from_dict(item)
for item in raw_items
if isinstance(item, dict)
]
def _normalize_track_points(raw_track_points: Any) -> list[Any]:
if isinstance(raw_track_points, list):
return list(raw_track_points)
if isinstance(raw_track_points, tuple):
return list(raw_track_points)
return []
def _float_or_zero(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _optional_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _optional_string(value: Any) -> str | None:
if value is None:
return None
return str(value).strip()

View File

@@ -0,0 +1,933 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from math import ceil
from typing import Any
from cold_display_guard.models import DisposalEvidence
@dataclass(frozen=True, slots=True)
class Frame:
width: int
height: int
rgb: bytes
def pixel(self, x: int, y: int) -> tuple[int, int, int]:
offset = (y * self.width + x) * 3
return self.rgb[offset], self.rgb[offset + 1], self.rgb[offset + 2]
@dataclass(frozen=True, slots=True)
class Region:
region_id: str
polygon: tuple[tuple[float, float], ...]
@dataclass(frozen=True, slots=True)
class RuntimeVisionSettings:
baseline_frames: int = 3
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
lighting_shift_guard_enabled: bool = True
lighting_shift_min_regions: int = 3
lighting_shift_region_fraction: float = 0.6
lighting_shift_mean_delta: float = 45.0
trash_motion_delta: float = 18.0
trash_sustained_motion_delta: float = 8.0
trash_sustained_motion_frames: int = 2
trash_motion_cooldown_seconds: int = 3
trajectory_enabled: bool = True
trajectory_window_seconds: int = 8
trajectory_sample_interval_seconds: float = 1.0
trajectory_min_points: int = 3
trajectory_segmented_enabled: bool = True
trajectory_segmented_min_points: int = 2
trajectory_min_confidence: float = 0.72
trajectory_motion_delta: float = 20.0
trajectory_min_blob_area: int = 12
trajectory_max_blob_area_fraction: float = 0.35
trajectory_trash_entry_margin: float = 0.04
trajectory_backend: str = "motion"
yolo_enabled: bool = False
yolo_model_path: str = ""
yolo_min_confidence: float = 0.65
@dataclass(frozen=True, slots=True)
class RegionMetrics:
mean_luma: float
texture: float
sample_count: int
dark_fraction: float = 0.0
bright_fraction: float = 0.0
@dataclass(slots=True)
class _MotionPoint:
blob_id: int
x: float
y: float
area: int
when: datetime
@dataclass(slots=True)
class _TrajectoryCandidate:
source_region: Region
opened_at: datetime
last_sample_at: datetime | None = None
points: list[_MotionPoint] | None = None
source_motion_seen: bool = False
pre_source_motion_seen: bool = False
source_seeded: bool = False
forced_rejection_reason: str | None = None
def __post_init__(self) -> None:
if self.points is None:
self.points = []
class ZoneOccupancyDetector:
def __init__(
self,
regions: list[Region],
trash_region: Region | None,
settings: RuntimeVisionSettings | None = None,
) -> None:
self.regions = regions
self.trash_region = trash_region
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,
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] = {}
lighting_shift = self._lighting_shift(metrics_by_region)
diagnostics: dict[str, Any] = {
"zones": {},
"baseline_ready": self.baseline_ready,
"lighting_shift": lighting_shift,
}
for region in self.regions:
metrics = metrics_by_region[region.region_id]
baseline = self._baseline.get(region.region_id)
occupied = False
if baseline is not None:
mean_delta = abs(metrics.mean_luma - baseline.mean_luma)
texture_delta = metrics.texture - baseline.texture
if lighting_shift["active"]:
raw_occupied = False
occupied = self._stable_occupancy.get(region.region_id, False)
else:
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),
"mean_delta": round(mean_delta, 3),
"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],
"lighting_shift_suppressed": lighting_shift["active"],
}
zone_counts[region.region_id] = 1 if occupied else 0
trash_deposit_count = self._trash_deposit_count(frame, when, diagnostics)
return zone_counts, trash_deposit_count, diagnostics
@property
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:
continue
samples = self._baseline_samples.setdefault(region_id, [])
samples.append(metrics)
if len(samples) >= self.settings.baseline_frames:
self._baseline[region_id] = average_metrics(samples)
def _lighting_shift(self, metrics_by_region: dict[str, RegionMetrics]) -> dict[str, Any]:
if not self.settings.lighting_shift_guard_enabled:
return self._lighting_shift_diagnostics(False, None, 0, 0, 0)
eligible_region_count = 0
darker_count = 0
brighter_count = 0
for region in self.regions:
metrics = metrics_by_region.get(region.region_id)
baseline = self._baseline.get(region.region_id)
if metrics is None or baseline is None:
continue
eligible_region_count += 1
signed_delta = metrics.mean_luma - baseline.mean_luma
if abs(signed_delta) < self.settings.lighting_shift_mean_delta:
continue
if signed_delta < 0:
darker_count += 1
else:
brighter_count += 1
required_regions = max(
self.settings.lighting_shift_min_regions,
ceil(eligible_region_count * self.settings.lighting_shift_region_fraction),
)
active_direction: str | None = None
shifted_count = max(darker_count, brighter_count)
if eligible_region_count >= self.settings.lighting_shift_min_regions and shifted_count >= required_regions:
active_direction = "darker" if darker_count >= brighter_count else "brighter"
return self._lighting_shift_diagnostics(
active_direction is not None,
active_direction,
shifted_count,
eligible_region_count,
required_regions,
)
def _lighting_shift_diagnostics(
self,
active: bool,
direction: str | None,
shifted_regions: int,
eligible_regions: int,
required_regions: int,
) -> dict[str, Any]:
return {
"active": active,
"direction": direction,
"shifted_regions": shifted_regions,
"eligible_regions": eligible_regions,
"required_regions": required_regions,
"mean_delta_threshold": self.settings.lighting_shift_mean_delta,
}
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
metrics = region_metrics(frame, self.trash_region, self.settings.sample_stride_pixels)
previous = self._previous_trash_metrics
self._previous_trash_metrics = metrics
if previous is None:
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
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
class TrajectoryTracker:
def __init__(
self,
regions: list[Region],
trash_region: Region | None,
settings: RuntimeVisionSettings | None = None,
) -> None:
self.regions = regions
self.trash_region = trash_region
self.settings = settings or RuntimeVisionSettings()
self._previous_frame: Frame | None = None
self._previous_zone_counts: dict[str, int] = {}
self._candidates: list[_TrajectoryCandidate] = []
self._recent_source_motion: dict[str, _MotionPoint] = {}
self._emitted_track_signatures: set[tuple[tuple[float, float], ...]] = set()
@property
def has_active_candidates(self) -> bool:
return bool(self._candidates)
def observe(
self,
frame: Frame,
when: datetime,
zone_counts: dict[str, int],
) -> tuple[list[DisposalEvidence], dict[str, Any]]:
diagnostics: dict[str, Any] = {
"active_candidates": len(self._candidates),
"emitted_evidence": 0,
"expired_candidates": 0,
"rejected_candidates": 0,
"emitted": [],
"rejected": [],
"expired": [],
"disabled": False,
"reason": None,
}
if not self.settings.trajectory_enabled:
diagnostics["disabled"] = True
diagnostics["reason"] = "trajectory_disabled"
self._remember(frame, zone_counts)
return [], diagnostics
if self.trash_region is None:
diagnostics["disabled"] = True
diagnostics["reason"] = "missing_trash_region"
self._remember(frame, zone_counts)
return [], diagnostics
if self.settings.trajectory_backend != "motion":
diagnostics["disabled"] = True
diagnostics["reason"] = "unsupported_trajectory_backend"
self._remember(frame, zone_counts)
return [], diagnostics
blobs = self._motion_points(frame, when) if self._previous_frame is not None else []
self._remember_recent_source_motion(blobs, when, zone_counts)
self._open_candidates(when, zone_counts)
emitted: list[DisposalEvidence] = []
remaining: list[_TrajectoryCandidate] = []
consumed_blob_ids: set[int] = set()
emitted_track_signatures = set(self._emitted_track_signatures)
for candidate in self._candidates:
rejected_reason = self._sample_candidate(candidate, blobs, when, consumed_blob_ids)
if rejected_reason is not None:
diagnostics["rejected_candidates"] += 1
diagnostics["rejected"].append(self._candidate_event(candidate, rejected_reason))
continue
if when - candidate.opened_at > timedelta(seconds=self.settings.trajectory_window_seconds):
diagnostics["expired_candidates"] += 1
diagnostics["expired"].append(self._candidate_event(candidate, "expired"))
if not self._candidate_ready(candidate):
diagnostics["rejected_candidates"] += 1
diagnostics["rejected"].append(self._candidate_event(candidate, self._rejection_reason(candidate)))
else:
signature = self._track_signature(candidate)
if signature in emitted_track_signatures:
diagnostics["rejected_candidates"] += 1
diagnostics["rejected"].append(self._candidate_event(candidate, "ambiguous_motion_track"))
else:
emitted.append(self._evidence(candidate, when))
emitted_track_signatures.add(signature)
self._emitted_track_signatures.add(signature)
diagnostics["emitted"].append(self._candidate_event(candidate, "emitted"))
continue
if self._candidate_reached_trash(candidate):
if self._candidate_ready(candidate):
signature = self._track_signature(candidate)
if signature in emitted_track_signatures:
diagnostics["rejected_candidates"] += 1
diagnostics["rejected"].append(self._candidate_event(candidate, "ambiguous_motion_track"))
else:
emitted.append(self._evidence(candidate, when))
emitted_track_signatures.add(signature)
self._emitted_track_signatures.add(signature)
diagnostics["emitted"].append(self._candidate_event(candidate, "emitted"))
else:
diagnostics["rejected_candidates"] += 1
diagnostics["rejected"].append(self._candidate_event(candidate, self._rejection_reason(candidate)))
continue
remaining.append(candidate)
self._candidates = remaining
if not self._candidates:
self._emitted_track_signatures.clear()
diagnostics["emitted_evidence"] = len(emitted)
diagnostics["active_candidates"] = len(self._candidates)
diagnostics["motion_points"] = len(blobs)
self._remember(frame, zone_counts)
return emitted, diagnostics
def _remember(self, frame: Frame, zone_counts: dict[str, int]) -> None:
self._previous_frame = frame
self._previous_zone_counts = {region_id: max(0, int(count)) for region_id, count in zone_counts.items()}
def _open_candidates(self, when: datetime, zone_counts: dict[str, int]) -> None:
active_region_ids = {candidate.source_region.region_id for candidate in self._candidates}
used_seed_signatures: set[tuple[float, float, str]] = set()
for region in self.regions:
previous = self._previous_zone_counts.get(region.region_id, 0)
current = max(0, int(zone_counts.get(region.region_id, 0)))
if previous > 0 and current == 0 and region.region_id not in active_region_ids:
candidate = _TrajectoryCandidate(source_region=region, opened_at=when)
recent = self._recent_source_motion.get(region.region_id)
if recent is not None and when - recent.when <= timedelta(seconds=self.settings.trajectory_window_seconds):
signature = (round(recent.x, 4), round(recent.y, 4), recent.when.isoformat())
if signature in used_seed_signatures:
candidate.forced_rejection_reason = "ambiguous_motion_track"
else:
used_seed_signatures.add(signature)
candidate.points.append(recent)
candidate.source_motion_seen = True
candidate.source_seeded = True
candidate.last_sample_at = recent.when
self._candidates.append(candidate)
self._recent_source_motion.pop(region.region_id, None)
def _remember_recent_source_motion(self, blobs: list[_MotionPoint], when: datetime, zone_counts: dict[str, int]) -> None:
if not blobs:
return
for region in self.regions:
previous = self._previous_zone_counts.get(region.region_id, 0)
current = max(0, int(zone_counts.get(region.region_id, 0)))
if previous <= 0 and current <= 0:
continue
source_blobs = [blob for blob in blobs if region_contains(region, blob.x, blob.y)]
if not source_blobs:
continue
self._recent_source_motion[region.region_id] = _nearest_point(region_center(region), source_blobs)
def _motion_points(self, frame: Frame, when: datetime) -> list[_MotionPoint]:
previous = self._previous_frame
if previous is None or previous.width != frame.width or previous.height != frame.height:
return []
width = frame.width
height = frame.height
changed = bytearray(width * height)
threshold = self.settings.trajectory_motion_delta
for y in range(height):
for x in range(width):
offset = (y * width + x) * 3
current_luma = _luma(frame.rgb[offset], frame.rgb[offset + 1], frame.rgb[offset + 2])
previous_luma = _luma(previous.rgb[offset], previous.rgb[offset + 1], previous.rgb[offset + 2])
if abs(current_luma - previous_luma) >= threshold:
changed[y * width + x] = 1
min_area = max(1, int(self.settings.trajectory_min_blob_area))
max_area = max(min_area, int(width * height * self.settings.trajectory_max_blob_area_fraction))
points: list[_MotionPoint] = []
next_blob_id = 0
for start in range(width * height):
if not changed[start]:
continue
stack = [start]
changed[start] = 0
area = 0
sum_x = 0
sum_y = 0
while stack:
index = stack.pop()
x = index % width
y = index // width
area += 1
sum_x += x
sum_y += y
if x > 0:
neighbor = index - 1
if changed[neighbor]:
changed[neighbor] = 0
stack.append(neighbor)
if x + 1 < width:
neighbor = index + 1
if changed[neighbor]:
changed[neighbor] = 0
stack.append(neighbor)
if y > 0:
neighbor = index - width
if changed[neighbor]:
changed[neighbor] = 0
stack.append(neighbor)
if y + 1 < height:
neighbor = index + width
if changed[neighbor]:
changed[neighbor] = 0
stack.append(neighbor)
if min_area <= area <= max_area:
points.append(
_MotionPoint(
blob_id=next_blob_id,
x=(sum_x / area + 0.5) / width,
y=(sum_y / area + 0.5) / height,
area=area,
when=when,
)
)
next_blob_id += 1
return points
def _sample_candidate(
self,
candidate: _TrajectoryCandidate,
blobs: list[_MotionPoint],
when: datetime,
consumed_blob_ids: set[int],
) -> str | None:
if candidate.forced_rejection_reason is not None:
return candidate.forced_rejection_reason
if not blobs:
return None
if (
candidate.last_sample_at is not None
and (when - candidate.last_sample_at).total_seconds() < self.settings.trajectory_sample_interval_seconds
):
return None
if not candidate.source_motion_seen:
source_blobs = [
blob for blob in blobs if region_contains(candidate.source_region, blob.x, blob.y)
]
if source_blobs:
available_source_blobs = [blob for blob in source_blobs if blob.blob_id not in consumed_blob_ids]
if not available_source_blobs:
return "ambiguous_motion_track"
if candidate.pre_source_motion_seen:
return "motion_started_outside_source"
candidate.source_motion_seen = True
point = _nearest_point(region_center(candidate.source_region), available_source_blobs)
else:
available_blobs = [blob for blob in blobs if blob.blob_id not in consumed_blob_ids]
if not available_blobs:
return None
candidate.pre_source_motion_seen = True
if any(
region_contains(self.trash_region, blob.x, blob.y, margin=self.settings.trajectory_trash_entry_margin)
for blob in available_blobs
if self.trash_region is not None
):
return "motion_started_outside_source"
return None
else:
previous = candidate.points[-1] if candidate.points else None
available_blobs = [blob for blob in blobs if blob.blob_id not in consumed_blob_ids]
if not available_blobs:
return None
point = self._next_progress_point(candidate, available_blobs, previous)
if candidate.points and _distance((candidate.points[-1].x, candidate.points[-1].y), (point.x, point.y)) < 0.015:
return None
candidate.points.append(point)
consumed_blob_ids.add(point.blob_id)
candidate.last_sample_at = when
return None
def _next_progress_point(
self,
candidate: _TrajectoryCandidate,
blobs: list[_MotionPoint],
previous: _MotionPoint | None,
) -> _MotionPoint:
if self.trash_region is None:
return blobs[0]
source = region_center(candidate.source_region)
target = region_center(self.trash_region)
expected = (target[0] - source[0], target[1] - source[1])
expected_length = (expected[0] ** 2 + expected[1] ** 2) ** 0.5
if expected_length <= 1e-9:
origin = (previous.x, previous.y) if previous is not None else source
return _nearest_point(origin, blobs)
unit = (expected[0] / expected_length, expected[1] / expected_length)
origin = (previous.x, previous.y) if previous is not None else source
def score(point: _MotionPoint) -> float:
dx = point.x - origin[0]
dy = point.y - origin[1]
projection = dx * unit[0] + dy * unit[1]
perpendicular = abs(dx * unit[1] - dy * unit[0])
return projection - 0.25 * perpendicular
return max(blobs, key=score)
def _candidate_reached_trash(self, candidate: _TrajectoryCandidate) -> bool:
points = candidate.points or []
return any(
region_contains(self.trash_region, point.x, point.y, margin=self.settings.trajectory_trash_entry_margin)
for point in points
if self.trash_region is not None
)
def _candidate_ready(self, candidate: _TrajectoryCandidate) -> bool:
confidence = self._confidence(candidate)
return (
candidate.source_motion_seen
and self._candidate_reached_trash(candidate)
and self._has_enough_track_points(candidate)
and self._direction_score(candidate) >= 0.35
and confidence >= self.settings.trajectory_min_confidence
)
def _rejection_reason(self, candidate: _TrajectoryCandidate) -> str:
if not candidate.source_motion_seen:
return "missing_source_motion"
if not self._candidate_reached_trash(candidate):
return "did_not_reach_trash"
if not self._has_enough_track_points(candidate):
return "insufficient_points"
if self._direction_score(candidate) < 0.35:
return "bad_direction"
if self._confidence(candidate) < self.settings.trajectory_min_confidence:
return "low_confidence"
return "rejected"
def _has_enough_track_points(self, candidate: _TrajectoryCandidate) -> bool:
point_count = len(candidate.points or [])
if point_count >= self.settings.trajectory_min_points:
return True
return (
self.settings.trajectory_segmented_enabled
and point_count >= self.settings.trajectory_segmented_min_points
and candidate.source_motion_seen
and self._candidate_reached_trash(candidate)
)
def _candidate_event(self, candidate: _TrajectoryCandidate, reason: str) -> dict[str, Any]:
return {
"source_zone_id": candidate.source_region.region_id,
"reason": reason,
"point_count": len(candidate.points or []),
"confidence": round(self._confidence(candidate), 3),
"direction_score": round(self._direction_score(candidate), 3),
"segmented": self._is_segmented_track(candidate),
"source_seeded": candidate.source_seeded,
}
def _is_segmented_track(self, candidate: _TrajectoryCandidate) -> bool:
point_count = len(candidate.points or [])
return (
self.settings.trajectory_segmented_enabled
and self.settings.trajectory_segmented_min_points <= point_count < self.settings.trajectory_min_points
and candidate.source_motion_seen
and self._candidate_reached_trash(candidate)
)
def _track_signature(self, candidate: _TrajectoryCandidate) -> tuple[tuple[float, float], ...]:
return tuple((round(point.x, 4), round(point.y, 4)) for point in candidate.points or [])
def _confidence(self, candidate: _TrajectoryCandidate) -> float:
point_count = len(candidate.points or [])
point_score = min(1.0, point_count / max(1, self.settings.trajectory_min_points))
source_score = 1.0 if candidate.source_motion_seen else 0.0
trash_score = 1.0 if self._candidate_reached_trash(candidate) else 0.0
direction_score = max(0.0, self._direction_score(candidate))
return min(1.0, 0.20 * source_score + 0.35 * trash_score + 0.25 * direction_score + 0.20 * point_score)
def _direction_score(self, candidate: _TrajectoryCandidate) -> float:
points = candidate.points or []
if len(points) < 2 or self.trash_region is None:
return 0.0
start = points[0]
end = points[-1]
motion = (end.x - start.x, end.y - start.y)
motion_length = (motion[0] ** 2 + motion[1] ** 2) ** 0.5
if motion_length <= 1e-9:
return 0.0
source = region_center(candidate.source_region)
target = region_center(self.trash_region)
expected = (target[0] - source[0], target[1] - source[1])
expected_length = (expected[0] ** 2 + expected[1] ** 2) ** 0.5
if expected_length <= 1e-9:
return 0.0
return (motion[0] * expected[0] + motion[1] * expected[1]) / (motion_length * expected_length)
def _evidence(self, candidate: _TrajectoryCandidate, when: datetime) -> DisposalEvidence:
return DisposalEvidence(
source_zone_id=candidate.source_region.region_id,
target="trash",
confidence=round(self._confidence(candidate), 3),
method="motion",
track_points=[
{
"x": round(point.x, 4),
"y": round(point.y, 4),
"area": point.area,
"observed_at": point.when.isoformat(),
}
for point in candidate.points or []
],
item_class=None,
detector_score=None,
observed_at=when.isoformat(),
)
def load_regions(config: dict[str, Any]) -> tuple[list[Region], Region | None]:
regions: list[Region] = []
for zone in config.get("zones", []):
zone_id = str(zone.get("id", "")).strip()
polygon = normalize_polygon(zone.get("polygon", []))
if zone_id and len(polygon) >= 3:
regions.append(Region(zone_id, polygon))
trash_region = None
trash_polygon = normalize_polygon(config.get("trash", {}).get("roi", []))
if len(trash_polygon) >= 3:
trash_region = Region("trash", trash_polygon)
return regions, trash_region
def load_runtime_vision_settings(config: dict[str, Any]) -> RuntimeVisionSettings:
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", 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))),
lighting_shift_guard_enabled=bool(runtime.get("lighting_shift_guard_enabled", True)),
lighting_shift_min_regions=max(1, int(runtime.get("lighting_shift_min_regions", 3))),
lighting_shift_region_fraction=max(0.0, min(1.0, float(runtime.get("lighting_shift_region_fraction", 0.6)))),
lighting_shift_mean_delta=float(runtime.get("lighting_shift_mean_delta", 45.0)),
trash_motion_delta=float(runtime.get("trash_motion_delta", 18.0)),
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))),
trajectory_enabled=bool(runtime.get("trajectory_enabled", True)),
trajectory_window_seconds=max(1, int(runtime.get("trajectory_window_seconds", 8))),
trajectory_sample_interval_seconds=max(0.0, float(runtime.get("trajectory_sample_interval_seconds", 1.0))),
trajectory_min_points=max(1, int(runtime.get("trajectory_min_points", 3))),
trajectory_segmented_enabled=bool(runtime.get("trajectory_segmented_enabled", True)),
trajectory_segmented_min_points=max(2, int(runtime.get("trajectory_segmented_min_points", 2))),
trajectory_min_confidence=float(runtime.get("trajectory_min_confidence", 0.72)),
trajectory_motion_delta=float(runtime.get("trajectory_motion_delta", 20.0)),
trajectory_min_blob_area=max(1, int(runtime.get("trajectory_min_blob_area", 12))),
trajectory_max_blob_area_fraction=max(
0.0,
min(1.0, float(runtime.get("trajectory_max_blob_area_fraction", 0.35))),
),
trajectory_trash_entry_margin=max(0.0, float(runtime.get("trajectory_trash_entry_margin", 0.04))),
trajectory_backend=str(runtime.get("trajectory_backend", "motion")),
yolo_enabled=bool(runtime.get("yolo_enabled", False)),
yolo_model_path=str(runtime.get("yolo_model_path", "")),
yolo_min_confidence=float(runtime.get("yolo_min_confidence", 0.65)),
)
def normalize_polygon(value: Any) -> tuple[tuple[float, float], ...]:
points: list[tuple[float, float]] = []
if not isinstance(value, list):
return ()
for item in value:
if not isinstance(item, list | tuple) or len(item) != 2:
continue
points.append((min(1.0, max(0.0, float(item[0]))), min(1.0, max(0.0, float(item[1])))))
return tuple(points)
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))
max_x = min(frame.width - 1, int(max(xs) * frame.width))
min_y = max(0, int(min(ys) * frame.height))
max_y = min(frame.height - 1, int(max(ys) * frame.height))
values: list[float] = []
for y in range(min_y, max_y + 1, stride):
norm_y = (y + 0.5) / frame.height
for x in range(min_x, max_x + 1, stride):
norm_x = (x + 0.5) / frame.width
if not point_in_polygon(norm_x, norm_y, region.polygon):
continue
r, g, b = frame.pixel(x, y)
values.append(0.299 * r + 0.587 * g + 0.114 * b)
if not values:
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)
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:
return 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 region_center(region: Region) -> tuple[float, float]:
return (
sum(point[0] for point in region.polygon) / len(region.polygon),
sum(point[1] for point in region.polygon) / len(region.polygon),
)
def region_contains(region: Region, x: float, y: float, margin: float = 0.0) -> bool:
if margin <= 0:
return point_in_polygon(x, y, region.polygon)
xs = [point[0] for point in region.polygon]
ys = [point[1] for point in region.polygon]
if x < min(xs) - margin or x > max(xs) + margin or y < min(ys) - margin or y > max(ys) + margin:
return False
return point_in_polygon(x, y, region.polygon) or (
min(xs) - margin <= x <= max(xs) + margin and min(ys) - margin <= y <= max(ys) + margin
)
def _nearest_point(origin: tuple[float, float], points: list[_MotionPoint]) -> _MotionPoint:
return min(points, key=lambda point: _distance(origin, (point.x, point.y)))
def _distance(first: tuple[float, float], second: tuple[float, float]) -> float:
return ((first[0] - second[0]) ** 2 + (first[1] - second[1]) ** 2) ** 0.5
def _luma(r: int, g: int, b: int) -> float:
return 0.299 * r + 0.587 * g + 0.114 * b
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
)
def point_in_polygon(x: float, y: float, polygon: tuple[tuple[float, float], ...]) -> bool:
inside = False
j = len(polygon) - 1
for i, point in enumerate(polygon):
xi, yi = point
xj, yj = polygon[j]
intersects = (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-12) + xi
if intersects:
inside = not inside
j = i
return inside

View File

@@ -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,89 @@ 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 优化改造` 批次内的工作阶段,不代表拆分成独立批次。
## v1.2 轨迹识别
### Goal
`/Users/yoilun/Code/cold_display_guard` 中完成轨迹识别改造:保留现有 ROI 占用计时和垃圾桶动作兜底,新增轻量轨迹移动检测,输出可被未来 YOLO 物品识别模型复用的统一 `disposal_evidence`,让报警后移出的物品按来源区域确认是否进入垃圾桶。
### Stop Conditions
- [x] v1.2 所有阶段完成。
- [x] 必要 Python 测试通过。
- [x] 前端测试或构建在受影响时通过。
- [x] `docs/project.md` 记录 v1.2 架构、配置、运行方式和关键决策。
- [x] 没有 blocking bug 或未处理的高风险问题。
- [x] 如果同一问题连续 3 次修复仍失败,暂停并报告原因、已尝试方案和建议下一步。
### Phases
| Phase | Status | Goal | Acceptance Criteria |
| --- | --- | --- | --- |
| 1 | complete | 建立 `disposal_evidence` 数据契约并让状态机优先按来源区域丢弃 | `Observation` 支持 evidenceengine 能按 `source_zone_id` 精确关闭 pending batch同帧移除+evidence 有回归测试;旧 `trash_deposit_count` 仍可兜底 |
| 2 | complete | 实现无 YOLO 依赖的轻量轨迹检测 | synthetic frame 测试覆盖源区域到垃圾桶、非源区域运动、未到垃圾桶、单帧反光、多候选互不串扰;不引入模型依赖 |
| 3 | complete | 集成 runtime 配置、诊断和候选窗口加速采样 | `main.py` 写入 `disposal_evidence` 与 trajectory diagnostics配置默认 `trajectory_enabled=true``yolo_enabled=false`;候选活跃时使用更短采样间隔 |
| 4 | complete | 文档、全量验证和部署准备 | README/project/progress 更新Python 全量测试通过;前端测试/构建按影响范围验证;远端部署命令和风险记录清楚 |
### v1.2 Decisions
- 第一版使用 `MotionTrajectoryBackend`,不安装 YOLO、PyTorch、ONNX Runtime 或 OpenVINO。
- YOLO 作为后续 `YoloDetectionBackend` 接入统一 evidence contract不能绕过轨迹校验直接关闭业务事件。
- 状态机只消费 `disposal_evidence`,不依赖具体视觉后端。
- 轨迹 evidence 优先级高于 FIFO 垃圾桶动作兜底。
- 子 agent 派发必须使用标准上下文头:
```text
[项目: /Users/yoilun/Code/cold_display_guard]
[工作流批次: v1.2 轨迹识别]
[阶段: 阶段 x]
[角色: 对应智能体角色]
```

View File

@@ -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__":

View File

@@ -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()

View File

@@ -9,16 +9,39 @@ from cold_display_guard import BatchEngine, EngineSettings, Observation
UTC = timezone.utc
def obs(ts: datetime, counts: dict[str, int], trash: bool | int = False) -> Observation:
def obs(
ts: datetime,
counts: dict[str, int],
trash: bool | int = False,
disposal_evidence: list[dict[str, object]] | None = None,
) -> Observation:
return Observation.from_dict(
{
"ts": ts.isoformat(),
"zone_counts": counts,
"trash_deposit": trash,
"disposal_evidence": disposal_evidence or [],
}
)
def disposal_evidence(
source_zone_id: str,
confidence: float = 0.93,
target: str = "trash_bin",
) -> dict[str, object]:
return {
"source_zone_id": source_zone_id,
"target": target,
"confidence": confidence,
"method": "trajectory",
"track_points": [{"x": 101, "y": 202, "ts": "2026-04-27T10:20:01+00:00"}],
"item_class": "prepared_food",
"detector_score": 0.88,
"observed_at": "2026-04-27T10:20:02+00:00",
}
class BatchEngineTests(unittest.TestCase):
def setUp(self) -> None:
self.settings = EngineSettings(
@@ -36,6 +59,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 +72,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 +103,234 @@ 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_observation_from_dict_normalizes_disposal_evidence(self) -> None:
observation = Observation.from_dict(
{
"ts": self.t0.isoformat(),
"zone_counts": {"1": "2"},
"disposal_evidence": [
{
"source_zone_id": 1,
"target": "trash_bin",
"confidence": "0.83",
"method": "trajectory",
"track_points": [{"x": 1, "y": 2}],
"item_class": "prepared_food",
"detector_score": "0.91",
"observed_at": "2026-04-27T10:00:01+00:00",
}
],
}
)
evidence = observation.disposal_evidence[0]
self.assertEqual(evidence.source_zone_id, "1")
self.assertEqual(evidence.target, "trash_bin")
self.assertAlmostEqual(evidence.confidence, 0.83)
self.assertEqual(evidence.method, "trajectory")
self.assertEqual(evidence.track_points, [{"x": 1, "y": 2}])
self.assertEqual(evidence.item_class, "prepared_food")
self.assertAlmostEqual(evidence.detector_score, 0.91)
self.assertEqual(evidence.observed_at, "2026-04-27T10:00:01+00:00")
def test_observation_from_dict_preserves_null_optional_disposal_fields(self) -> None:
observation = Observation.from_dict(
{
"ts": self.t0.isoformat(),
"zone_counts": {"1": 1},
"disposal_evidence": [
{
"source_zone_id": "1",
"target": "trash_bin",
"confidence": 0.83,
"method": "trajectory",
"track_points": [],
"item_class": None,
"detector_score": None,
}
],
}
)
evidence = observation.disposal_evidence[0]
self.assertIsNone(evidence.item_class)
self.assertIsNone(evidence.detector_score)
def test_matching_disposal_evidence_discards_pending_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}))
engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0}))
events = engine.process(
obs(
self.t0 + timedelta(seconds=1310),
{"1": 0},
disposal_evidence=[disposal_evidence("1")],
)
)
self.assertEqual([event["event"] for event in events], ["batch_discarded"])
self.assertEqual(events[0]["zone_id"], "1")
self.assertEqual(engine.pending_disposal, [])
def test_disposal_evidence_for_another_zone_does_not_discard_wrong_pending_batch(self) -> None:
settings = EngineSettings(
camera_id="test_cam",
max_dwell_seconds=1200,
trash_confirmation_seconds=120,
zone_ids=("1", "4"),
)
engine = BatchEngine(settings)
engine.process(obs(self.t0, {"1": 1, "4": 0}))
engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1, "4": 0}))
engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0, "4": 0}))
events = engine.process(
obs(
self.t0 + timedelta(seconds=1310),
{"1": 0, "4": 0},
disposal_evidence=[disposal_evidence("4")],
)
)
self.assertEqual(events, [])
self.assertEqual([batch.zone_id for batch in engine.pending_disposal], ["1"])
def test_non_trash_disposal_evidence_target_is_ignored(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},
disposal_evidence=[disposal_evidence("1", target="customer_hand")],
)
)
self.assertEqual(events, [])
self.assertEqual([batch.zone_id for batch in engine.pending_disposal], ["1"])
def test_disposal_evidence_and_trash_count_do_not_double_consume_same_signal(self) -> None:
settings = EngineSettings(
camera_id="test_cam",
max_dwell_seconds=1200,
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=1200), {"1": 1, "4": 1}))
engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0, "4": 0}))
events = engine.process(
obs(
self.t0 + timedelta(seconds=1310),
{"1": 0, "4": 0},
trash=True,
disposal_evidence=[disposal_evidence("4")],
)
)
self.assertEqual([(event["event"], event["zone_id"]) for event in events], [("batch_discarded", "4")])
self.assertEqual([batch.zone_id for batch in engine.pending_disposal], ["1"])
def test_extra_trash_deposits_still_fallback_after_matching_disposal_evidence(self) -> None:
settings = EngineSettings(
camera_id="test_cam",
max_dwell_seconds=1200,
trash_confirmation_seconds=120,
zone_ids=("1", "2"),
)
engine = BatchEngine(settings)
engine.process(obs(self.t0, {"1": 1, "2": 1}))
engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1, "2": 1}))
engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0, "2": 0}))
events = engine.process(
obs(
self.t0 + timedelta(seconds=1310),
{"1": 0, "2": 0},
trash=2,
disposal_evidence=[disposal_evidence("1")],
)
)
self.assertEqual(
[(event["event"], event["zone_id"]) for event in events],
[("batch_discarded", "1"), ("batch_discarded", "2")],
)
self.assertEqual(engine.pending_disposal, [])
def test_same_observation_removal_and_disposal_evidence_discards_newly_pending_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},
disposal_evidence=[disposal_evidence("1")],
)
)
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]["zone_id"], "1")
self.assertEqual(later_events, [])
def test_low_confidence_disposal_evidence_is_ignored(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},
disposal_evidence=[disposal_evidence("1", confidence=0.71)],
)
)
self.assertEqual(events, [])
self.assertEqual([batch.zone_id for batch in engine.pending_disposal], ["1"])
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 +338,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 +360,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()

300
tests/test_main.py Normal file
View File

@@ -0,0 +1,300 @@
from __future__ import annotations
import json
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from unittest.mock import patch
from cold_display_guard.frame_source import FrameCaptureError
from cold_display_guard.main import run, restore_runtime_state
from cold_display_guard.models import DisposalEvidence
from cold_display_guard.vision import Frame
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)
class RuntimeLoopTests(unittest.TestCase):
def test_run_writes_disposal_evidence_and_trajectory_diagnostics(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
config_path, diagnostics_path = write_runtime_config(tmpdir)
captured_observations = []
tracker_calls = []
class FakeSource:
def __init__(self, **kwargs: object) -> None:
pass
def capture(self) -> Frame:
return Frame(width=2, height=2, rgb=bytes([0, 0, 0]) * 4)
class FakeDetector:
def __init__(self, *args: object) -> None:
pass
def observe(self, frame: Frame, when: datetime) -> tuple[dict[str, int], int, dict[str, object]]:
return {"1": 0}, 0, {"zones": {"1": {"occupied": False}}}
class FakeTracker:
def __init__(self, *args: object) -> None:
self.has_active_candidates = False
def observe(
self,
frame: Frame,
when: datetime,
zone_counts: dict[str, int],
) -> tuple[list[DisposalEvidence], dict[str, object]]:
tracker_calls.append(zone_counts)
return [
DisposalEvidence(
source_zone_id="1",
target="trash",
confidence=0.9,
method="motion",
track_points=[{"x": 0.1, "y": 0.2}],
item_class=None,
detector_score=None,
observed_at=when.isoformat(),
)
], {"active_candidates": 0, "emitted_evidence": 1}
class FakeEngine:
def __init__(self, settings: object) -> None:
pass
def process(self, observation: object) -> list[dict[str, object]]:
captured_observations.append(observation)
return []
with patch("cold_display_guard.main.RTSPFrameSource", FakeSource), patch(
"cold_display_guard.main.ZoneOccupancyDetector", FakeDetector
), patch("cold_display_guard.main.TrajectoryTracker", FakeTracker), patch(
"cold_display_guard.main.BatchEngine", FakeEngine
):
run(config_path, max_iterations=1)
diagnostics = [json.loads(line) for line in diagnostics_path.read_text(encoding="utf-8").splitlines()]
self.assertEqual(len(captured_observations), 1)
self.assertEqual(tracker_calls, [{"1": 0}])
self.assertEqual(captured_observations[0].disposal_evidence[0].source_zone_id, "1")
self.assertEqual(diagnostics[0]["disposal_evidence"][0]["source_zone_id"], "1")
self.assertEqual(diagnostics[0]["disposal_evidence"][0]["target"], "trash")
self.assertEqual(diagnostics[0]["diagnostics"]["trajectory"]["emitted_evidence"], 1)
def test_run_uses_trajectory_sample_interval_when_candidates_are_active(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
config_path, _ = write_runtime_config(tmpdir, sample_interval=5.0, trajectory_interval=1.0)
sleeps = []
tracker_calls = []
class FakeSource:
def __init__(self, **kwargs: object) -> None:
pass
def capture(self) -> Frame:
return Frame(width=2, height=2, rgb=bytes([0, 0, 0]) * 4)
class FakeDetector:
def __init__(self, *args: object) -> None:
pass
def observe(self, frame: Frame, when: datetime) -> tuple[dict[str, int], int, dict[str, object]]:
return {"1": 0}, 0, {}
class FakeTracker:
def __init__(self, *args: object) -> None:
self.has_active_candidates = False
def observe(
self,
frame: Frame,
when: datetime,
zone_counts: dict[str, int],
) -> tuple[list[DisposalEvidence], dict[str, object]]:
tracker_calls.append(zone_counts)
self.has_active_candidates = True
return [], {"active_candidates": 1}
with patch("cold_display_guard.main.RTSPFrameSource", FakeSource), patch(
"cold_display_guard.main.ZoneOccupancyDetector", FakeDetector
), patch("cold_display_guard.main.TrajectoryTracker", FakeTracker), patch(
"cold_display_guard.main.time.sleep", sleeps.append
):
run(config_path, max_iterations=2)
self.assertEqual(tracker_calls, [{"1": 0}, {"1": 0}])
self.assertEqual(sleeps, [1.0])
def test_capture_failure_diagnostics_keep_trajectory_schema(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
config_path, diagnostics_path = write_runtime_config(tmpdir)
class FailingSource:
def __init__(self, **kwargs: object) -> None:
pass
def capture(self) -> Frame:
raise FrameCaptureError("camera offline")
with patch("cold_display_guard.main.RTSPFrameSource", FailingSource):
run(config_path, max_iterations=1)
diagnostics = [json.loads(line) for line in diagnostics_path.read_text(encoding="utf-8").splitlines()]
self.assertEqual(diagnostics[0]["error"], "frame_capture_failed")
self.assertEqual(diagnostics[0]["disposal_evidence"], [])
self.assertEqual(diagnostics[0]["diagnostics"]["trajectory"]["reason"], "frame_capture_failed")
def write_runtime_config(
tmpdir: str,
sample_interval: float = 5.0,
trajectory_interval: float = 1.0,
) -> tuple[Path, Path]:
root = Path(tmpdir)
event_path = root / "events.jsonl"
diagnostics_path = root / "runtime_diagnostics.jsonl"
config_path = root / "config.toml"
config_path.write_text(
"\n".join(
[
'camera_id = "test-camera"',
'timezone = "UTC"',
"",
"[stream]",
'rtsp_url = "rtsp://example.invalid/stream"',
"",
"[thresholds]",
"max_dwell_seconds = 1200",
"trash_confirmation_seconds = 120",
"",
"[layout]",
"zone_count = 1",
'zone_ids = ["1"]',
"",
"[[zones]]",
'id = "1"',
"polygon = [[0.0, 0.0], [0.5, 0.0], [0.5, 0.5], [0.0, 0.5]]",
"",
"[trash]",
"roi = [[0.6, 0.6], [1.0, 0.6], [1.0, 1.0], [0.6, 1.0]]",
"",
"[runtime]",
f"sample_interval_seconds = {sample_interval}",
f"trajectory_sample_interval_seconds = {trajectory_interval}",
f'diagnostics_path = "{diagnostics_path}"',
"",
"[event_sink]",
f'path = "{event_path}"',
"",
]
),
encoding="utf-8",
)
return config_path, diagnostics_path
if __name__ == "__main__":
unittest.main()

View File

@@ -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,224 @@ 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:
root = Path(tmpdir)
config_path = root / "config" / "local.toml"
save_config_document(
config_path,
{
"runtime": {"diagnostics_path": "logs/runtime_diagnostics.jsonl"},
"event_sink": {"path": "logs/events.jsonl"},
"layout": {"rows": 1, "cols": 1, "zone_ids": ["r1c1"]},
},
)
diagnostics_path = root / "logs" / "runtime_diagnostics.jsonl"
diagnostics_path.parent.mkdir()
diagnostics_path.write_text(
json.dumps(
{
"ts": "2026-04-28T10:00:00+08:00",
"zone_counts": {"r1c1": 1},
"diagnostics": {"baseline_ready": True},
}
),
encoding="utf-8",
)
summary = build_summary(ManageContext(config_path=config_path, project_root=root))
self.assertEqual(summary["metrics"]["diagnostics_count"], 1)
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__":

766
tests/test_vision.py Normal file
View File

@@ -0,0 +1,766 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from cold_display_guard.vision import (
Frame,
Region,
RegionMetrics,
RuntimeVisionSettings,
TrajectoryTracker,
ZoneOccupancyDetector,
load_runtime_vision_settings,
point_in_polygon,
)
def solid_frame(width: int, height: int, value: int) -> Frame:
return Frame(width=width, height=height, rgb=bytes([value, value, value]) * width * height)
def patched_frame(width: int, height: int, base: int, patch: tuple[int, int, int, int, int]) -> Frame:
x1, y1, x2, y2, value = patch
pixels = bytearray(bytes([base, base, base]) * width * height)
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))
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))
def frame_with_motion_patch(width: int, height: int, top_left: tuple[int, int]) -> Frame:
x, y = top_left
return patched_frame(width, height, 40, (x, y, x + 8, y + 8, 180))
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))
self.assertTrue(point_in_polygon(0.5, 0.5, polygon))
self.assertFalse(point_in_polygon(1.5, 0.5, polygon))
def test_detector_reports_occupied_after_baseline_changes(self) -> None:
detector = ZoneOccupancyDetector(
[Region("r1c1", ((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=1,
empty_confirm_frames=1,
),
)
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
baseline_counts, _, _ = detector.observe(solid_frame(32, 32, 30), now)
changed_counts, _, _ = detector.observe(patched_frame(32, 32, 30, (0, 0, 32, 32, 90)), now)
self.assertEqual(baseline_counts, {"r1c1": 0})
self.assertEqual(changed_counts, {"r1c1": 1})
def test_detector_reports_trash_motion(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=10),
)
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)
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_detector_ignores_global_lighting_dimming_across_many_zones(self) -> None:
regions = [
Region(str(index + 1), ((index / 7, 0.0), ((index + 1) / 7, 0.0), ((index + 1) / 7, 1.0), (index / 7, 1.0)))
for index in range(7)
]
detector = ZoneOccupancyDetector(
regions,
trash_region=None,
settings=RuntimeVisionSettings(
baseline_frames=1,
sample_stride_pixels=2,
occupancy_mean_delta=55,
occupancy_texture_delta=18,
occupancy_confirm_frames=2,
empty_confirm_frames=2,
),
)
now = datetime(2026, 6, 1, 4, 55, tzinfo=timezone.utc)
detector.observe(solid_frame(70, 20, 180), now)
first_counts, _, first_diagnostics = detector.observe(solid_frame(70, 20, 100), now + timedelta(seconds=5))
second_counts, _, second_diagnostics = detector.observe(solid_frame(70, 20, 100), now + timedelta(seconds=10))
self.assertEqual(first_counts, {str(index): 0 for index in range(1, 8)})
self.assertEqual(second_counts, {str(index): 0 for index in range(1, 8)})
self.assertTrue(first_diagnostics["lighting_shift"]["active"])
self.assertTrue(second_diagnostics["lighting_shift"]["active"])
self.assertTrue(all(not zone["raw_occupied"] for zone in second_diagnostics["zones"].values()))
def test_detector_allows_single_zone_object_while_lighting_guard_is_available(self) -> None:
regions = [
Region(str(index + 1), ((index / 7, 0.0), ((index + 1) / 7, 0.0), ((index + 1) / 7, 1.0), (index / 7, 1.0)))
for index in range(7)
]
detector = ZoneOccupancyDetector(
regions,
trash_region=None,
settings=RuntimeVisionSettings(
baseline_frames=1,
sample_stride_pixels=2,
occupancy_mean_delta=55,
occupancy_texture_delta=100,
occupancy_dark_luma_threshold=80,
occupancy_dark_fraction=0.06,
occupancy_confirm_frames=2,
empty_confirm_frames=2,
),
)
now = datetime(2026, 6, 1, 10, 0, tzinfo=timezone.utc)
detector.observe(solid_frame(70, 20, 180), now)
object_frame = patched_frame(70, 20, 180, (0, 0, 10, 20, 20))
detector.observe(object_frame, now + timedelta(seconds=5))
counts, _, diagnostics = detector.observe(object_frame, now + timedelta(seconds=10))
self.assertEqual(counts["1"], 1)
self.assertEqual(sum(counts.values()), 1)
self.assertFalse(diagnostics["lighting_shift"]["active"])
self.assertTrue(diagnostics["zones"]["1"]["raw_occupied"])
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)
self.assertTrue(settings.lighting_shift_guard_enabled)
self.assertEqual(settings.lighting_shift_min_regions, 3)
self.assertAlmostEqual(settings.lighting_shift_region_fraction, 0.6)
self.assertEqual(settings.lighting_shift_mean_delta, 45.0)
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)
def test_motion_track_from_source_zone_to_trash_roi_emits_evidence(self) -> None:
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
tracker = TrajectoryTracker(
[source],
trash,
RuntimeVisionSettings(
trajectory_sample_interval_seconds=0.0,
trajectory_min_points=3,
trajectory_min_confidence=0.72,
trajectory_motion_delta=20.0,
trajectory_min_blob_area=12,
),
)
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1})
all_evidence: list[object] = []
emitted_evidence_count = 0
for index, point in enumerate([(16, 36), (28, 36), (42, 36), (58, 36), (66, 36)]):
evidence, diagnostics = tracker.observe(
frame_with_motion_patch(80, 80, point),
now + timedelta(seconds=index + 1),
{"source": 0},
)
all_evidence.extend(evidence)
emitted_evidence_count += diagnostics["emitted_evidence"]
self.assertTrue(all_evidence)
emitted = all_evidence[0]
self.assertEqual(emitted.source_zone_id, "source")
self.assertEqual(emitted.target, "trash")
self.assertEqual(emitted.method, "motion")
self.assertGreaterEqual(emitted.confidence, 0.72)
self.assertGreaterEqual(len(emitted.track_points), 3)
self.assertGreaterEqual(emitted_evidence_count, 1)
def test_segmented_source_to_trash_track_survives_temporary_occlusion(self) -> None:
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
tracker = TrajectoryTracker(
[source],
trash,
RuntimeVisionSettings(
trajectory_sample_interval_seconds=0.0,
trajectory_min_points=3,
trajectory_min_confidence=0.72,
trajectory_motion_delta=20.0,
trajectory_min_blob_area=12,
),
)
now = datetime(2026, 6, 1, 10, 55, tzinfo=timezone.utc)
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1})
source_motion_frame = frame_with_motion_patch(80, 80, (16, 36))
first_evidence, _ = tracker.observe(source_motion_frame, now + timedelta(seconds=1), {"source": 0})
occluded_evidence, occluded_diagnostics = tracker.observe(source_motion_frame, now + timedelta(seconds=2), {"source": 0})
trash_evidence, trash_diagnostics = tracker.observe(
frame_with_motion_patch(80, 80, (66, 36)),
now + timedelta(seconds=3),
{"source": 0},
)
self.assertEqual(first_evidence, [])
self.assertEqual(occluded_evidence, [])
self.assertEqual(occluded_diagnostics["active_candidates"], 1)
self.assertEqual(len(trash_evidence), 1)
emitted = trash_evidence[0]
self.assertEqual(emitted.source_zone_id, "source")
self.assertEqual(emitted.target, "trash")
self.assertEqual(emitted.method, "motion")
self.assertEqual(len(emitted.track_points), 2)
self.assertGreaterEqual(emitted.confidence, 0.72)
self.assertEqual(trash_diagnostics["emitted"][0]["reason"], "emitted")
self.assertTrue(trash_diagnostics["emitted"][0]["segmented"])
def test_recent_source_motion_seeds_track_when_empty_confirmation_lags(self) -> None:
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
tracker = TrajectoryTracker(
[source],
trash,
RuntimeVisionSettings(
trajectory_sample_interval_seconds=0.0,
trajectory_min_points=3,
trajectory_min_confidence=0.72,
trajectory_motion_delta=20.0,
trajectory_min_blob_area=12,
),
)
now = datetime(2026, 6, 1, 11, 25, tzinfo=timezone.utc)
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1})
tracker.observe(frame_with_motion_patch(80, 80, (16, 36)), now + timedelta(seconds=1), {"source": 1})
evidence, diagnostics = tracker.observe(
frame_with_motion_patch(80, 80, (66, 36)),
now + timedelta(seconds=2),
{"source": 0},
)
self.assertEqual(len(evidence), 1)
self.assertEqual(evidence[0].source_zone_id, "source")
self.assertEqual(len(evidence[0].track_points), 2)
self.assertTrue(diagnostics["emitted"][0]["segmented"])
self.assertTrue(diagnostics["emitted"][0]["source_seeded"])
def test_motion_that_starts_away_from_source_zone_is_rejected(self) -> None:
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
tracker = TrajectoryTracker(
[source],
trash,
RuntimeVisionSettings(trajectory_sample_interval_seconds=0.0, trajectory_min_points=3),
)
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
tracker.observe(frame_with_motion_patch(80, 80, (50, 10)), now, {"source": 1})
all_evidence: list[object] = []
rejected_candidates = 0
for index, point in enumerate([(52, 14), (56, 20), (60, 28), (66, 36)]):
evidence, diagnostics = tracker.observe(
frame_with_motion_patch(80, 80, point),
now + timedelta(seconds=index + 1),
{"source": 0},
)
all_evidence.extend(evidence)
rejected_candidates += diagnostics["rejected_candidates"]
self.assertEqual(all_evidence, [])
self.assertGreaterEqual(rejected_candidates, 1)
def test_motion_that_never_reaches_trash_roi_is_rejected(self) -> None:
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
tracker = TrajectoryTracker(
[source],
trash,
RuntimeVisionSettings(
trajectory_window_seconds=3,
trajectory_sample_interval_seconds=0.0,
trajectory_min_points=3,
),
)
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1})
all_evidence: list[object] = []
diagnostics = {}
for index, point in enumerate([(16, 36), (26, 36), (36, 36), (42, 36), (46, 36)]):
evidence, diagnostics = tracker.observe(
frame_with_motion_patch(80, 80, point),
now + timedelta(seconds=index + 1),
{"source": 0},
)
all_evidence.extend(evidence)
self.assertEqual(all_evidence, [])
self.assertGreaterEqual(diagnostics["expired_candidates"], 1)
self.assertGreaterEqual(diagnostics["rejected_candidates"], 1)
def test_one_frame_reflection_flash_is_rejected(self) -> None:
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
tracker = TrajectoryTracker(
[source],
trash,
RuntimeVisionSettings(trajectory_sample_interval_seconds=0.0, trajectory_min_points=3),
)
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
tracker.observe(solid_frame(80, 80, 40), now, {"source": 1})
flash_frame = patched_frame(80, 80, 40, (56, 28, 72, 52, 255))
evidence, diagnostics = tracker.observe(flash_frame, now + timedelta(seconds=1), {"source": 0})
later_evidence, later_diagnostics = tracker.observe(solid_frame(80, 80, 40), now + timedelta(seconds=2), {"source": 0})
self.assertEqual(evidence, [])
self.assertEqual(later_evidence, [])
self.assertEqual(diagnostics["emitted_evidence"], 0)
self.assertEqual(later_diagnostics["emitted_evidence"], 0)
self.assertEqual(later_diagnostics["rejected_candidates"], 0)
def test_multiple_active_candidates_do_not_cross_close_each_other(self) -> None:
left = Region("left", ((0.05, 0.15), (0.25, 0.15), (0.25, 0.35), (0.05, 0.35)))
right = Region("right", ((0.05, 0.65), (0.25, 0.65), (0.25, 0.85), (0.05, 0.85)))
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
tracker = TrajectoryTracker(
[left, right],
trash,
RuntimeVisionSettings(trajectory_sample_interval_seconds=0.0, trajectory_min_points=3),
)
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
tracker.observe(
multi_patched_frame(80, 80, 40, [(8, 18, 16, 26, 180), (8, 54, 16, 62, 180)]),
now,
{"left": 1, "right": 1},
)
all_evidence = []
frames = [
[(16, 20, 24, 28, 180), (18, 54, 26, 62, 180)],
[(28, 24, 36, 32, 180), (30, 52, 38, 60, 180)],
[(44, 30, 52, 38, 180), (44, 50, 52, 58, 180)],
[(60, 36, 68, 44, 180), (60, 50, 68, 58, 180)],
]
for index, patches in enumerate(frames):
evidence, _ = tracker.observe(
multi_patched_frame(80, 80, 40, patches),
now + timedelta(seconds=index + 1),
{"left": 0, "right": 0},
)
all_evidence.extend(evidence)
self.assertEqual({item.source_zone_id for item in all_evidence}, {"left", "right"})
self.assertEqual(len(all_evidence), 2)
def test_multiple_active_candidates_do_not_emit_same_motion_track(self) -> None:
first = Region("first", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
second = Region("second", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
tracker = TrajectoryTracker(
[first, second],
trash,
RuntimeVisionSettings(trajectory_sample_interval_seconds=0.0, trajectory_min_points=3),
)
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"first": 1, "second": 1})
all_evidence = []
rejected = []
for index, point in enumerate([(16, 36), (28, 36), (42, 36), (58, 36), (66, 36)]):
evidence, diagnostics = tracker.observe(
frame_with_motion_patch(80, 80, point),
now + timedelta(seconds=index + 1),
{"first": 0, "second": 0},
)
all_evidence.extend(evidence)
rejected.extend(diagnostics["rejected"])
tracks = [tuple((point["x"], point["y"]) for point in item.track_points) for item in all_evidence]
self.assertLessEqual(len(all_evidence), 1)
self.assertEqual(len(tracks), len(set(tracks)))
self.assertTrue(any(item["reason"] == "ambiguous_motion_track" for item in rejected))
def test_motion_inside_source_margin_but_outside_source_polygon_is_rejected(self) -> None:
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
tracker = TrajectoryTracker(
[source],
trash,
RuntimeVisionSettings(trajectory_sample_interval_seconds=0.0, trajectory_min_points=3),
)
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
tracker.observe(frame_with_motion_patch(80, 80, (20, 36)), now, {"source": 1})
all_evidence = []
rejected = []
for index, point in enumerate([(24, 36), (36, 36), (50, 36), (66, 36), (70, 36)]):
evidence, diagnostics = tracker.observe(
frame_with_motion_patch(80, 80, point),
now + timedelta(seconds=index + 1),
{"source": 0},
)
all_evidence.extend(evidence)
rejected.extend(diagnostics["rejected"])
self.assertEqual(all_evidence, [])
self.assertTrue(any(item["reason"] == "motion_started_outside_source" for item in rejected))
def test_motion_before_source_motion_cannot_seed_later_trash_evidence(self) -> None:
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
tracker = TrajectoryTracker(
[source],
trash,
RuntimeVisionSettings(trajectory_sample_interval_seconds=0.0, trajectory_min_points=3),
)
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
tracker.observe(solid_frame(80, 80, 40), now, {"source": 1})
all_evidence = []
rejected = []
frames = [
frame_with_motion_patch(80, 80, (34, 36)),
frame_with_motion_patch(80, 80, (16, 36)),
frame_with_motion_patch(80, 80, (34, 36)),
frame_with_motion_patch(80, 80, (50, 36)),
frame_with_motion_patch(80, 80, (66, 36)),
]
for index, frame in enumerate(frames):
evidence, diagnostics = tracker.observe(
frame,
now + timedelta(seconds=index + 1),
{"source": 0},
)
all_evidence.extend(evidence)
rejected.extend(diagnostics["rejected"])
self.assertEqual(all_evidence, [])
self.assertTrue(any(item["reason"] == "motion_started_outside_source" for item in rejected))
def test_trajectory_diagnostics_include_per_candidate_events(self) -> None:
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
tracker = TrajectoryTracker(
[source],
trash,
RuntimeVisionSettings(
trajectory_window_seconds=3,
trajectory_sample_interval_seconds=0.0,
trajectory_min_points=3,
),
)
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1})
emitted_diagnostics = {}
for index, point in enumerate([(16, 36), (28, 36), (42, 36), (58, 36)]):
_, emitted_diagnostics = tracker.observe(
frame_with_motion_patch(80, 80, point),
now + timedelta(seconds=index + 1),
{"source": 0},
)
emitted_event = emitted_diagnostics["emitted"][0]
self.assert_candidate_event(emitted_event, "source", "emitted")
tracker = TrajectoryTracker(
[source],
trash,
RuntimeVisionSettings(
trajectory_window_seconds=2,
trajectory_sample_interval_seconds=0.0,
trajectory_min_points=3,
),
)
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1})
rejected_diagnostics = {}
for index, point in enumerate([(16, 36), (26, 36), (36, 36), (42, 36)]):
_, rejected_diagnostics = tracker.observe(
frame_with_motion_patch(80, 80, point),
now + timedelta(seconds=index + 1),
{"source": 0},
)
expired_event = rejected_diagnostics["expired"][0]
rejected_event = rejected_diagnostics["rejected"][0]
self.assert_candidate_event(expired_event, "source", "expired")
self.assert_candidate_event(rejected_event, "source", "did_not_reach_trash")
def assert_candidate_event(self, event: dict[str, object], source_zone_id: str, reason: str) -> None:
self.assertEqual(event["source_zone_id"], source_zone_id)
self.assertEqual(event["reason"], reason)
self.assertIn("point_count", event)
self.assertIn("confidence", event)
self.assertIn("direction_score", event)
def test_runtime_vision_defaults_include_trajectory_and_yolo_fields(self) -> None:
settings = load_runtime_vision_settings({})
self.assertTrue(settings.trajectory_enabled)
self.assertEqual(settings.trajectory_window_seconds, 8)
self.assertEqual(settings.trajectory_sample_interval_seconds, 1.0)
self.assertEqual(settings.trajectory_min_points, 3)
self.assertTrue(settings.trajectory_segmented_enabled)
self.assertEqual(settings.trajectory_segmented_min_points, 2)
self.assertEqual(settings.trajectory_min_confidence, 0.72)
self.assertEqual(settings.trajectory_motion_delta, 20.0)
self.assertEqual(settings.trajectory_min_blob_area, 12)
self.assertEqual(settings.trajectory_max_blob_area_fraction, 0.35)
self.assertEqual(settings.trajectory_trash_entry_margin, 0.04)
self.assertEqual(settings.trajectory_backend, "motion")
self.assertFalse(settings.yolo_enabled)
self.assertEqual(settings.yolo_model_path, "")
self.assertEqual(settings.yolo_min_confidence, 0.65)
def test_runtime_vision_settings_read_trajectory_and_yolo_fields_from_config(self) -> None:
settings = load_runtime_vision_settings(
{
"runtime": {
"trajectory_enabled": False,
"trajectory_window_seconds": 11,
"trajectory_sample_interval_seconds": 0.5,
"trajectory_min_points": 4,
"trajectory_segmented_enabled": False,
"trajectory_segmented_min_points": 3,
"trajectory_min_confidence": 0.8,
"trajectory_motion_delta": 25.0,
"trajectory_min_blob_area": 20,
"trajectory_max_blob_area_fraction": 0.25,
"trajectory_trash_entry_margin": 0.02,
"trajectory_backend": "motion",
"yolo_enabled": True,
"yolo_model_path": "models/yolo.onnx",
"yolo_min_confidence": 0.7,
"lighting_shift_guard_enabled": False,
"lighting_shift_min_regions": 4,
"lighting_shift_region_fraction": 0.75,
"lighting_shift_mean_delta": 60.0,
}
}
)
self.assertFalse(settings.trajectory_enabled)
self.assertEqual(settings.trajectory_window_seconds, 11)
self.assertEqual(settings.trajectory_sample_interval_seconds, 0.5)
self.assertEqual(settings.trajectory_min_points, 4)
self.assertFalse(settings.trajectory_segmented_enabled)
self.assertEqual(settings.trajectory_segmented_min_points, 3)
self.assertEqual(settings.trajectory_min_confidence, 0.8)
self.assertEqual(settings.trajectory_motion_delta, 25.0)
self.assertEqual(settings.trajectory_min_blob_area, 20)
self.assertEqual(settings.trajectory_max_blob_area_fraction, 0.25)
self.assertEqual(settings.trajectory_trash_entry_margin, 0.02)
self.assertEqual(settings.trajectory_backend, "motion")
self.assertTrue(settings.yolo_enabled)
self.assertEqual(settings.yolo_model_path, "models/yolo.onnx")
self.assertEqual(settings.yolo_min_confidence, 0.7)
self.assertFalse(settings.lighting_shift_guard_enabled)
self.assertEqual(settings.lighting_shift_min_regions, 4)
self.assertAlmostEqual(settings.lighting_shift_region_fraction, 0.75)
self.assertEqual(settings.lighting_shift_mean_delta, 60.0)
if __name__ == "__main__":
unittest.main()

3
web/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.DS_Store

29
web/Dockerfile Normal file
View File

@@ -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;"]

20
web/nginx.conf Normal file
View File

@@ -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;
}
}

View File

@@ -1,82 +1,126 @@
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 = `
<div class="shell">
<header class="header">
<header class="console-header">
<div class="brand">
<div class="brand-mark">CD</div>
<div>
<div class="brand-mark">CG</div>
<div class="brand-copy">
<div class="brand-kicker">COLD DISPLAY GUARD</div>
<div class="brand-title">冷藏展示柜管理</div>
<div class="brand-subtitle">标定、配置、事件数据</div>
<div class="brand-subtitle">区域标定 / 运行监控 / 合规事件</div>
</div>
</div>
<nav class="tabs">
<button data-tab="calibration">区域标定</button>
<button data-tab="events">事件数据</button>
<button data-tab="settings">运行配置</button>
</nav>
<div class="header-actions">
<div class="status-pill" id="statusPill">
<span class="status-dot"></span>
<span id="statusText"></span>
</div>
<button id="refreshRuntimeData" class="secondary-action" type="button">刷新运行数据</button>
</div>
</header>
<main class="main">
<section class="status-line">
<span id="statusText"></span>
<button id="refreshAll" type="button">刷新</button>
<section id="calibrationView" class="view">
<section class="view-head">
<div>
<p class="view-kicker">CALIBRATION</p>
<h1>冷柜区域标定</h1>
</div>
<p class="view-note">抓取一帧后,在画面中依次点击每个格口和垃圾桶 ROI 的边界点。</p>
</section>
<section id="calibrationView" class="view">
<section class="toolbar">
<label>
RTSP 地址
<section class="command-bar">
<label class="field rtsp-field">
<span>RTSP 地址(用于抓帧)</span>
<input id="rtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
</label>
<button id="saveConfig" type="button">保存配置和标定</button>
<button id="captureSnapshot" type="button">抓取一帧</button>
<button id="saveCalibration" type="button">保存标定</button>
<div class="command-actions">
<button id="captureSnapshot" class="primary-action" type="button">抓取一帧</button>
<button id="saveCalibration" class="success-action" type="button">保存标定</button>
</div>
</section>
<section class="calibration-grid">
<aside class="panel">
<div class="panel-title">区域</div>
<section class="calibration-layout">
<aside class="panel zone-panel">
<div class="panel-meta">ZONE MATRIX</div>
<div class="panel-title">区域选择</div>
<label class="field zone-count-field">
<span>食品区域数量1-10</span>
<input id="foodZoneCount" type="number" min="1" max="10" step="1">
</label>
<div id="regionList" class="region-list"></div>
<div class="button-stack">
<div class="tool-stack">
<button id="undoPoint" type="button">撤销点</button>
<button id="clearRegion" type="button">清空当前区域</button>
<button id="loadConfigPolygons" type="button">载入当前配置区域</button>
<button id="loadConfigPolygons" type="button">载入已保存标定</button>
</div>
</aside>
<section class="canvas-panel">
<div class="canvas-toolbar">
<span>FRAME INSPECTION</span>
<strong id="activeRegionBadge">区域 1</strong>
</div>
<canvas id="canvas" width="1280" height="720"></canvas>
</section>
<aside class="panel">
<aside class="panel inspection-panel">
<div class="panel-meta">POLYGON STATUS</div>
<div class="panel-title">标定结果</div>
<div id="regionSummary" class="region-summary"></div>
</aside>
@@ -84,34 +128,73 @@ app.innerHTML = `
</section>
<section id="eventsView" class="view hidden">
<section class="view-head">
<div>
<p class="view-kicker">RUNTIME</p>
<h1>事件与运行状态</h1>
</div>
<p class="view-note">从运行进程写入的事件和诊断数据中读取最近状态。</p>
</section>
<section id="runtimeOverview" class="runtime-overview"></section>
<section class="metrics" id="metrics"></section>
<section class="panel">
<section class="panel progress-panel">
<div class="panel-meta">DWELL TIMER</div>
<div class="panel-title">计时进度</div>
<div id="runtimeProgress" class="runtime-progress"></div>
</section>
<section class="panel event-panel">
<div class="panel-meta">EVENT LOG</div>
<div class="panel-title">最近事件</div>
<div id="eventsTable" class="events-table"></div>
</section>
</section>
<section id="settingsView" class="view hidden">
<section class="settings-grid">
<label>
Camera ID
<section class="view-head">
<div>
<p class="view-kicker">CONFIGURATION</p>
<h1>运行配置</h1>
</div>
<p class="view-note">保存后写入后端配置;运行进程需要按配置重新读取。</p>
</section>
<section class="settings-layout">
<section class="panel settings-panel">
<div class="panel-meta">CAMERA INPUT</div>
<div class="settings-grid">
<label class="field">
<span>RTSP 地址</span>
<input id="settingsRtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
</label>
<label class="field">
<span>Camera ID</span>
<input id="cameraId" type="text">
</label>
<label>
时区
<label class="field">
<span>时区</span>
<input id="timezone" type="text">
</label>
<label>
最大放置秒数
<input id="maxDwell" type="number" min="1">
<label class="field">
<span>报警阈值(分钟)</span>
<input id="maxDwell" type="number" min="1" step="1">
</label>
<label>
垃圾桶确认秒数
<label class="field">
<span>垃圾桶确认秒数</span>
<input id="trashWindow" type="number" min="1">
</label>
</div>
<div class="settings-actions">
<button id="saveConfig" class="success-action" type="button">保存运行配置</button>
<button id="reloadConfig" type="button">重新载入后端配置</button>
</div>
</section>
<section class="panel preview-panel">
<div class="panel-meta">LIVE CONFIG PREVIEW</div>
<pre id="configPreview" class="config-preview"></pre>
</section>
</section>
</section>
</main>
</div>
`;
@@ -120,31 +203,42 @@ const els = {
statusText: document.querySelector("#statusText"),
canvas: document.querySelector("#canvas"),
regionList: document.querySelector("#regionList"),
foodZoneCount: document.querySelector("#foodZoneCount"),
rtspUrl: document.querySelector("#rtspUrl"),
settingsRtspUrl: document.querySelector("#settingsRtspUrl"),
cameraId: document.querySelector("#cameraId"),
timezone: document.querySelector("#timezone"),
maxDwell: document.querySelector("#maxDwell"),
trashWindow: document.querySelector("#trashWindow"),
configPreview: document.querySelector("#configPreview"),
regionSummary: document.querySelector("#regionSummary"),
runtimeOverview: document.querySelector("#runtimeOverview"),
runtimeProgress: document.querySelector("#runtimeProgress"),
metrics: document.querySelector("#metrics"),
eventsTable: document.querySelector("#eventsTable"),
statusPill: document.querySelector("#statusPill"),
activeRegionBadge: document.querySelector("#activeRegionBadge"),
};
const ctx = els.canvas.getContext("2d");
function boot() {
wireEvents();
loadDraftPolygons();
renderRegionList();
refreshAll();
render();
loadInitialData().finally(startRuntimeTimers);
}
function startRuntimeTimers() {
window.setInterval(renderRuntimeSections, runtimeClockMs);
window.setInterval(refreshRuntimeDataSilently, runtimePollMs);
}
function wireEvents() {
document.querySelectorAll(".tabs button").forEach((button) => {
button.addEventListener("click", () => setTab(button.dataset.tab));
});
document.querySelector("#refreshAll").addEventListener("click", refreshAll);
document.querySelector("#refreshRuntimeData").addEventListener("click", refreshRuntimeData);
document.querySelector("#saveConfig").addEventListener("click", saveConfig);
document.querySelector("#reloadConfig").addEventListener("click", reloadConfig);
document.querySelector("#captureSnapshot").addEventListener("click", captureSnapshot);
document.querySelector("#saveCalibration").addEventListener("click", saveCalibration);
document.querySelector("#undoPoint").addEventListener("click", undoPoint);
@@ -152,47 +246,122 @@ function wireEvents() {
document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig);
els.canvas.addEventListener("click", addPoint);
window.addEventListener("resize", drawCanvas);
els.foodZoneCount.addEventListener("input", () => updateFoodZoneCount(els.foodZoneCount.value));
[els.rtspUrl, els.settingsRtspUrl, els.cameraId, els.timezone, els.maxDwell, els.trashWindow].forEach((input) => {
input.addEventListener("input", () => {
state.configDirty = true;
if (input === els.rtspUrl) {
els.settingsRtspUrl.value = els.rtspUrl.value;
}
if (input === els.settingsRtspUrl) {
els.rtspUrl.value = els.settingsRtspUrl.value;
}
renderConfigPreview();
});
});
}
async function refreshAll() {
async function loadInitialData() {
try {
setStatus("正在读取配置和事件...");
const [config, summary, events] = await Promise.all([
apiJson("/api/manage/config"),
apiJson("/api/manage/summary"),
apiJson("/api/manage/events?limit=200"),
]);
setStatus("正在读取配置和运行数据...");
const config = await apiJson("/api/manage/config");
state.config = config;
state.summary = summary;
state.events = events.items || [];
applyConfigRegions(config, {useDraft: true});
await loadRuntimeData();
fillForm();
if (!hasAnyPolygon()) {
loadPolygonsFromConfig(false);
}
state.configDirty = false;
render();
setStatus("已连接后端 19080");
} catch (error) {
state.runtimeDemoReason = `后端连接失败:${error.message}`;
render();
setStatus(`连接失败:${error.message}`);
}
}
async function refreshRuntimeData() {
try {
setStatus("正在刷新运行数据...");
await loadRuntimeData();
render();
setStatus(state.runtimeDemoReason ? `运行数据已刷新,部分接口失败:${state.runtimeDemoReason}` : "运行数据已刷新");
} catch (error) {
state.runtimeDemoReason = `运行数据刷新失败:${error.message}`;
render();
setStatus(`刷新运行数据失败:${error.message}`);
}
}
async function refreshRuntimeDataSilently() {
if (runtimeRefreshInFlight) {
return;
}
runtimeRefreshInFlight = true;
try {
await loadRuntimeData();
renderRuntimeSections();
} catch (error) {
state.runtimeDemoReason = `运行数据刷新失败:${error.message}`;
renderRuntimeSections();
} finally {
runtimeRefreshInFlight = false;
}
}
async function loadRuntimeData() {
const [summaryResult, eventsResult] = await Promise.allSettled([
apiJson("/api/manage/summary"),
apiJson("/api/manage/events?limit=1000"),
]);
const errors = [];
if (summaryResult.status === "fulfilled") {
state.summary = summaryResult.value;
} else {
state.summary = null;
errors.push(`summary ${errorMessage(summaryResult.reason)}`);
}
if (eventsResult.status === "fulfilled") {
state.events = eventsResult.value.items || [];
} else {
state.events = [];
errors.push(`events ${errorMessage(eventsResult.reason)}`);
}
state.runtimeDemoReason = errors.length ? errors.join("") : "";
}
async function reloadConfig() {
if (state.configDirty && !window.confirm("当前运行配置有未保存修改。确认放弃修改并重新载入后端配置?")) {
return;
}
try {
setStatus("正在重新载入后端配置...");
state.config = await apiJson("/api/manage/config");
applyConfigRegions(state.config, {useDraft: false});
fillForm();
state.configDirty = false;
render();
setStatus("后端配置已重新载入");
} catch (error) {
setStatus(`重新载入配置失败:${error.message}`);
}
}
async function saveConfig() {
try {
const payload = {
camera_id: els.cameraId.value.trim(),
timezone: els.timezone.value.trim(),
rtsp_url: els.rtspUrl.value.trim(),
rtsp_url: els.settingsRtspUrl.value.trim(),
thresholds: {
max_dwell_seconds: Number(els.maxDwell.value),
max_dwell_seconds: alarmMinutesToSeconds(els.maxDwell.value),
trash_confirmation_seconds: Number(els.trashWindow.value),
},
};
state.config = await apiJson("/api/manage/config", {method: "PUT", body: payload});
const calibrationSaved = await persistCalibration({requireAny: false});
saveDraftPolygons();
state.configDirty = false;
fillForm();
renderConfigPreview();
setStatus(calibrationSaved ? "配置和标定已保存" : "配置已保存;当前没有可保存的标定点");
setStatus("运行配置已保存");
} catch (error) {
setStatus(`保存配置失败:${error.message}`);
}
@@ -233,6 +402,7 @@ async function saveCalibration() {
try {
const saved = await persistCalibration({requireAny: true});
if (saved) {
state.calibrationDirty = false;
saveDraftPolygons();
render();
setStatus("标定已保存到项目配置");
@@ -243,15 +413,8 @@ async function saveCalibration() {
}
async function persistCalibration({requireAny}) {
const zones = zoneIds
.map((id) => ({id, polygon: state.polygons[id]}))
.filter((zone) => zone.polygon.length >= 3);
const trashPolygon = state.polygons.trash;
const payload = {zones, trash: {}};
if (trashPolygon.length >= 3) {
payload.trash.roi = trashPolygon;
}
if (!zones.length && !payload.trash.roi) {
const payload = buildCalibrationPayload(state.foodZones, state.polygons);
if (!payload.zones.length && !payload.trash.roi) {
if (requireAny) {
setStatus("当前没有可保存的标定点;每个区域至少需要 3 个点");
}
@@ -276,25 +439,55 @@ function setTab(tab) {
function fillForm() {
const config = state.config || {};
const alarmSeconds = config.thresholds?.max_dwell_seconds || 10800;
els.rtspUrl.value = config.stream?.rtsp_url || "";
els.settingsRtspUrl.value = config.stream?.rtsp_url || "";
els.cameraId.value = config.camera_id || "";
els.timezone.value = config.timezone || "";
els.maxDwell.value = config.thresholds?.max_dwell_seconds || 10800;
els.maxDwell.value = secondsToAlarmMinutes(alarmSeconds);
els.trashWindow.value = config.thresholds?.trash_confirmation_seconds || 120;
els.foodZoneCount.value = String(state.foodZoneCount);
}
function applyConfigRegions(config, {useDraft}) {
const foodZones = deriveFoodZones(config);
const draft = useDraft ? readDraftPolygons() : {};
state.foodZones = foodZones;
state.foodZoneCount = foodZones.length;
state.polygons = buildPolygonMap(foodZones, draft, config?.trash?.roi || []);
if (!allRegionIds().includes(state.activeRegion)) {
state.activeRegion = foodZones[0]?.id || TRASH_REGION_ID;
}
}
function updateFoodZoneCount(value) {
const nextCount = clampZoneCount(value);
if (nextCount === state.foodZoneCount) {
els.foodZoneCount.value = String(nextCount);
return;
}
state.foodZoneCount = nextCount;
const existingPolygons = state.polygons;
state.foodZones = deriveFoodZones({layout: {zone_count: nextCount}});
state.polygons = buildPolygonMap(state.foodZones, existingPolygons, existingPolygons[TRASH_REGION_ID]);
if (!allRegionIds().includes(state.activeRegion)) {
state.activeRegion = state.foodZones.at(-1)?.id || TRASH_REGION_ID;
}
state.calibrationDirty = true;
els.foodZoneCount.value = String(nextCount);
saveDraftPolygons();
render();
}
function loadPolygonsFromConfig(updateStatus = true) {
if (!state.config) {
return;
}
for (const zone of state.config.zones || []) {
if (zone.id && Array.isArray(zone.polygon)) {
state.polygons[zone.id] = zone.polygon.map(([x, y]) => ({x, y}));
}
}
if (Array.isArray(state.config.trash?.roi)) {
state.polygons.trash = state.config.trash.roi.map(([x, y]) => ({x, y}));
if (updateStatus && state.calibrationDirty && !window.confirm("当前标定有未保存草稿。确认放弃草稿并载入已保存标定?")) {
return;
}
applyConfigRegions(state.config, {useDraft: false});
state.calibrationDirty = false;
saveDraftPolygons();
render();
if (updateStatus) {
@@ -306,19 +499,48 @@ function render() {
renderRegionList();
drawCanvas();
renderRegionSummary();
renderMetrics();
renderEvents();
renderRuntimeSections();
renderConfigPreview();
setTab(state.activeTab);
}
function buildRuntimeModel() {
return buildRuntimeDisplayModel({
summary: state.summary,
events: state.events,
config: state.config,
foodZones: state.foodZones,
demoReason: state.runtimeDemoReason,
now: new Date(),
});
}
function renderRuntimeSections() {
const runtimeModel = buildRuntimeModel();
renderRuntimeOverview(runtimeModel);
renderMetrics(runtimeModel);
renderRuntimeProgress(runtimeModel);
renderEvents(runtimeModel);
}
function renderRegionList() {
els.regionList.innerHTML = "";
for (const id of allRegions) {
for (const id of allRegionIds()) {
const button = document.createElement("button");
const complete = (state.polygons[id] || []).length >= 3;
button.type = "button";
button.textContent = `${id}${state.polygons[id].length >= 3 ? " ✓" : ""}`;
button.className = id === state.activeRegion ? "active" : "";
button.className = [
"region-button",
id === state.activeRegion ? "active" : "",
complete ? "complete" : "",
].filter(Boolean).join(" ");
button.style.setProperty("--region-color", getRegionColor(id));
button.innerHTML = `
<span class="region-swatch"></span>
<span class="region-name">${escapeHtml(getRegionLabel(id))}</span>
<span class="region-code">${id === TRASH_REGION_ID ? "ROI" : escapeHtml(id)}</span>
<span class="region-points">${(state.polygons[id] || []).length}</span>
`;
button.addEventListener("click", () => {
state.activeRegion = id;
render();
@@ -341,7 +563,11 @@ function addPoint(event) {
}
const x = clamp(rawX / imageRect.width);
const y = clamp(rawY / imageRect.height);
if (!state.polygons[state.activeRegion]) {
state.polygons[state.activeRegion] = [];
}
state.polygons[state.activeRegion].push({x: round(x), y: round(y)});
state.calibrationDirty = true;
saveDraftPolygons();
render();
}
@@ -369,42 +595,51 @@ function getCanvasImageRect() {
}
function undoPoint() {
state.polygons[state.activeRegion].pop();
(state.polygons[state.activeRegion] || []).pop();
state.calibrationDirty = true;
saveDraftPolygons();
render();
}
function clearRegion() {
state.polygons[state.activeRegion] = [];
state.calibrationDirty = true;
saveDraftPolygons();
render();
}
function hasAnyPolygon() {
return allRegions.some((id) => state.polygons[id].length > 0);
return allRegionIds().some((id) => (state.polygons[id] || []).length > 0);
}
function saveDraftPolygons() {
localStorage.setItem(draftStorageKey, JSON.stringify(state.polygons));
localStorage.setItem(draftStorageKey, JSON.stringify({
zone_count: state.foodZoneCount,
polygons: state.polygons,
}));
}
function loadDraftPolygons() {
function readDraftPolygons() {
const raw = localStorage.getItem(draftStorageKey);
if (!raw) {
return;
return {};
}
try {
const draft = JSON.parse(raw);
for (const id of allRegions) {
if (!Array.isArray(draft[id])) {
const polygons = draft.polygons && typeof draft.polygons === "object" ? draft.polygons : draft;
const normalized = {};
for (const id of Object.keys(polygons)) {
if (!Array.isArray(polygons[id])) {
continue;
}
state.polygons[id] = draft[id]
normalized[id] = polygons[id]
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
.map((point) => ({x: clamp(point.x), y: clamp(point.y)}));
}
return normalized;
} catch {
localStorage.removeItem(draftStorageKey);
return {};
}
}
@@ -420,8 +655,8 @@ function drawCanvas() {
ctx.textAlign = "center";
ctx.fillText("输入 RTSP 地址并抓取一帧", els.canvas.width / 2, els.canvas.height / 2);
}
for (const id of allRegions) {
drawPolygon(id, state.polygons[id]);
for (const id of allRegionIds()) {
drawPolygon(id, state.polygons[id] || []);
}
}
@@ -429,7 +664,7 @@ function drawPolygon(id, points) {
if (!points.length) {
return;
}
const color = palette[id] || "#ffffff";
const color = getRegionColor(id);
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
@@ -466,59 +701,187 @@ function drawPolygon(id, points) {
const first = points[0];
ctx.font = id === state.activeRegion ? "bold 18px system-ui" : "14px system-ui";
ctx.textAlign = "left";
ctx.fillText(id, first.x * els.canvas.width + 8, first.y * els.canvas.height + 18);
ctx.fillText(getRegionLabel(id), first.x * els.canvas.width + 8, first.y * els.canvas.height + 18);
ctx.restore();
}
function renderRegionSummary() {
els.regionSummary.innerHTML = allRegions
els.regionSummary.innerHTML = allRegionIds()
.map((id) => {
const count = state.polygons[id].length;
return `<div><strong>${id}</strong><span>${count >= 3 ? `${count} 点,已标定` : `${count}`}</span></div>`;
const count = (state.polygons[id] || []).length;
const complete = count >= 3;
return `
<div class="summary-row ${complete ? "complete" : "pending"}">
<span class="summary-dot" style="--region-color:${getRegionColor(id)}"></span>
<strong>${escapeHtml(getRegionLabel(id))}</strong>
<span>${complete ? `${count} 点 / 已标定` : `${count} 点 / 待完成`}</span>
</div>
`;
})
.join("");
els.activeRegionBadge.textContent = getRegionLabel(state.activeRegion);
}
function renderRuntimeOverview(model) {
const labels = [
model.hasSummary ? "运行摘要来自后端" : "暂无运行摘要",
model.progressRows.length ? "计时进度来自事件" : "暂无计时进度",
model.hasEvents ? "事件表来自后端" : "暂无事件数据",
];
els.runtimeOverview.innerHTML = `
<div class="runtime-banner real">
<div>
<span>LIVE DATA</span>
<strong>${model.hasSummary || model.hasEvents ? "实时态:正在显示后端返回的运行数据" : "实时态:暂无真实运行数据"}</strong>
</div>
<p>${escapeHtml(labels.join(" / "))}${model.demoReason ? `${escapeHtml(model.demoReason)}` : ""}</p>
</div>
`;
}
function renderMetrics(model) {
const metrics = model.summary?.metrics || {};
const alertCount = metrics.alert_count ?? 0;
const warningCount = metrics.warning_count ?? 0;
const violationCount = metrics.violation_count ?? 0;
const baselineReady = Boolean(metrics.baseline_ready);
const metricLabel = (label) => label;
const cards = [
{label: metricLabel("事件总数"), value: metrics.event_count ?? 0, tone: "neutral"},
{label: metricLabel("时间报警"), value: alertCount, tone: alertCount > 0 ? "alarm" : "good"},
{label: metricLabel("升级警告"), value: warningCount, tone: warningCount > 0 ? "warning" : "good"},
{label: metricLabel("违规事件"), value: violationCount, tone: violationCount > 0 ? "danger" : "good"},
{label: metricLabel("诊断帧数"), value: metrics.diagnostics_count ?? 0, tone: "neutral"},
{label: metricLabel("基线状态"), value: baselineReady ? "ready" : "learning", tone: baselineReady ? "good" : "warning"},
{label: metricLabel("最新报警"), value: metrics.latest_alert_time || "-", tone: metrics.latest_alert_time ? "danger" : "neutral"},
{label: metricLabel("事件文件"), value: metrics.events_path || "-", tone: "path"},
];
const zoneCounts = metrics.latest_zone_counts || {};
const zoneSummary = Object.keys(zoneCounts).length
? `<div class="metric wide zone-state"><span>${escapeHtml(metricLabel("最新区域状态"))}</span><strong>${Object.entries(zoneCounts)
.map(([zoneId, count]) => escapeHtml(`${zoneId}:${count}`))
.join(" ")}</strong></div>`
: "";
els.metrics.innerHTML = cards
.map((card) => `
<div class="metric ${card.tone}">
<span>${escapeHtml(card.label)}</span>
<strong>${escapeHtml(String(card.value))}</strong>
</div>
`)
.join("") + zoneSummary;
}
function renderRuntimeProgress(model) {
if (!model.progressRows.length) {
els.runtimeProgress.innerHTML = `<div class="empty">暂无可显示的计时进度</div>`;
return;
}
els.runtimeProgress.innerHTML = model.progressRows
.map((row) => {
const statusLabel = row.status === "warning" ? "警告" : row.status === "alarm" ? "报警" : "正常";
return `
<div class="progress-row ${row.status}">
<div class="progress-zone">
<span class="zone-number">${escapeHtml(String(row.zoneIndex))}</span>
<strong>${escapeHtml(row.zoneLabel)}</strong>
</div>
<div class="progress-track" aria-label="${escapeHtml(`${row.zoneLabel} 停留 ${row.dwellSeconds}`)}">
<span style="width:${row.progressPct}%"></span>
</div>
<div class="progress-meta">
<strong>${escapeHtml(formatDuration(row.dwellSeconds))}</strong>
<span>${escapeHtml(statusLabel)}</span>
</div>
</div>
`;
})
.join("");
}
function renderMetrics() {
const metrics = state.summary?.metrics || {};
const cards = [
["事件总数", metrics.event_count ?? 0],
["违规事件", metrics.violation_count ?? 0],
["最新报警", metrics.latest_alert_time || "-"],
["事件文件", metrics.events_path || "-"],
];
els.metrics.innerHTML = cards.map(([label, value]) => `<div class="metric"><span>${label}</span><strong>${value}</strong></div>`).join("");
}
function renderEvents() {
if (!state.events.length) {
function renderEvents(model) {
const events = model.displayEvents || model.events;
if (!events.length) {
els.eventsTable.innerHTML = `<div class="empty">还没有事件数据</div>`;
return;
}
els.eventsTable.innerHTML = `
<table>
<thead><tr><th>时间</th><th>事件</th><th>区域</th><th>批次</th><th>停留秒数</th></tr></thead>
<thead><tr><th>时间</th><th>来源</th><th>级别</th><th>事件</th><th>区域序号</th><th>区域</th><th>批次</th><th>停留秒数</th></tr></thead>
<tbody>
${state.events
${events
.slice()
.reverse()
.map((event) => `
<tr>
.map((event) => {
const eventName = event.event || "";
const meta = classifyEvent(event);
return `
<tr class="event-row ${meta.tone}">
<td>${escapeHtml(event.ts || "")}</td>
<td>${escapeHtml(event.event || "")}</td>
<td>${escapeHtml(event.zone_id || "")}</td>
<td><span class="event-source real">真实</span></td>
<td><span class="event-severity ${meta.tone}">${escapeHtml(meta.severity)}</span></td>
<td><span class="event-name ${meta.tone}">${escapeHtml(eventName)}</span></td>
<td>${escapeHtml(meta.zoneIndex ? String(meta.zoneIndex) : "")}</td>
<td>${escapeHtml(meta.zoneLabel || "")}</td>
<td>${escapeHtml(event.batch_id || "")}</td>
<td>${escapeHtml(String(event.dwell_seconds ?? ""))}</td>
<td>${escapeHtml(String(event.displayDwellSeconds ?? event.dwell_seconds ?? ""))}</td>
</tr>
`)
`;
})
.join("")}
</tbody>
</table>
`;
}
function formatDuration(seconds) {
const value = Number(seconds);
if (!Number.isFinite(value) || value <= 0) {
return "0s";
}
if (value < 60) {
return `${Math.round(value)}s`;
}
const minutes = Math.floor(value / 60);
const rest = Math.round(value % 60);
return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
}
function errorMessage(reason) {
return reason?.message || String(reason || "unknown error");
}
function renderConfigPreview() {
els.configPreview.textContent = JSON.stringify(state.config || {}, null, 2);
const preview = {
...(state.config || {}),
stream: {
...((state.config || {}).stream || {}),
rtsp_url: els.settingsRtspUrl.value,
},
camera_id: els.cameraId.value,
timezone: els.timezone.value,
thresholds: {
max_dwell_seconds: alarmMinutesToSeconds(els.maxDwell.value || 0),
trash_confirmation_seconds: Number(els.trashWindow.value || 0),
},
layout: {
zone_count: state.foodZoneCount,
zone_ids: state.foodZones.map((zone) => zone.id),
},
zones: state.foodZones.map((zone) => ({
id: zone.id,
label: zone.label,
polygon: state.polygons[zone.id] || [],
})),
trash: {
roi: state.polygons[TRASH_REGION_ID] || [],
},
ui_state: {
config_dirty: state.configDirty,
calibration_dirty: state.calibrationDirty,
},
};
els.configPreview.textContent = JSON.stringify(preview, null, 2);
}
async function apiJson(path, options = {}) {
@@ -538,6 +901,12 @@ async function apiJson(path, options = {}) {
function setStatus(message) {
state.status = message;
els.statusText.textContent = message;
const tone = message.includes("失败") || message.includes("错误")
? "error"
: message.includes("正在")
? "busy"
: "ready";
els.statusPill.className = `status-pill ${tone}`;
}
function clamp(value) {
@@ -548,14 +917,25 @@ function round(value) {
return Math.round(value * 1000000) / 1000000;
}
function escapeHtml(value) {
return value.replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
function allRegionIds() {
return [...state.foodZones.map((zone) => zone.id), TRASH_REGION_ID];
}
boot();
try {
boot();
} catch (error) {
showFatalError(error);
}
function showFatalError(error) {
const message = error?.message || String(error || "unknown error");
console.error(error);
const target = document.querySelector("#app");
if (!target || target.querySelector(".fatal-error")) {
return;
}
const banner = document.createElement("div");
banner.className = "fatal-error";
banner.textContent = `前端初始化失败:${message}`;
target.prepend(banner);
}

File diff suppressed because it is too large Load Diff

502
web/src/zone-state.js Normal file
View File

@@ -0,0 +1,502 @@
export const TRASH_REGION_ID = "trash";
export const MIN_FOOD_ZONE_COUNT = 1;
export const MAX_FOOD_ZONE_COUNT = 10;
export const DEFAULT_FOOD_ZONE_COUNT = 8;
const DEFAULT_RUNTIME_THRESHOLD_SECONDS = 1200;
const zonePalette = [
"#d92d20",
"#b54708",
"#4e5ba6",
"#008a5a",
"#0077a3",
"#155eef",
"#7f56d9",
"#c11574",
"#4f7f1f",
"#8c5a00",
];
export function deriveFoodZones(config = {}) {
const layout = config.layout || {};
const sourceZones = config.zones || [];
const configuredIds = normalizeZoneIds(layout.zone_ids);
const numericIds = configuredIds.filter(isNumericId);
const sourceZonesById = new Map(sourceZones.map((zone) => [String(zone.id || ""), zone]));
const count = deriveZoneCount(layout, configuredIds, sourceZones);
const legacyIds = deriveLegacySourceIds(layout, configuredIds, sourceZones, count);
return numericZoneIds(count).map((id, index) => {
const legacySourceId = legacyIds[index];
const numericSourceId = numericIds.includes(id) ? id : "";
const source = sourceZonesById.get(numericSourceId) || sourceZonesById.get(legacySourceId) || {};
return {
id,
label: `区域 ${id}`,
sourceId: String(source.id || numericSourceId || legacySourceId || id),
polygon: normalizePolygon(source.polygon),
};
});
}
export function deriveZoneCount(layout = {}, configuredIds = normalizeZoneIds(layout.zone_ids), zones = []) {
if (configuredIds.length) {
return clampZoneCount(configuredIds.length);
}
if (layout.zone_count !== undefined) {
return clampZoneCount(layout.zone_count);
}
const rows = Number(layout.rows);
const cols = Number(layout.cols);
if (Number.isFinite(rows) && Number.isFinite(cols) && rows > 0 && cols > 0) {
return clampZoneCount(rows * cols);
}
if (Array.isArray(zones) && zones.length) {
return clampZoneCount(zones.length);
}
return DEFAULT_FOOD_ZONE_COUNT;
}
export function clampZoneCount(value, fallback = DEFAULT_FOOD_ZONE_COUNT) {
const parsed = Number(value);
const count = Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
return Math.min(MAX_FOOD_ZONE_COUNT, Math.max(MIN_FOOD_ZONE_COUNT, count));
}
export function numericZoneIds(count) {
return Array.from({length: clampZoneCount(count)}, (_, index) => String(index + 1));
}
export function createEmptyPolygonMap(foodZones) {
return Object.fromEntries([...foodZones.map((zone) => [zone.id, []]), [TRASH_REGION_ID, []]]);
}
export function buildPolygonMap(foodZones, existing = {}, trashRoi = []) {
const polygons = createEmptyPolygonMap(foodZones);
for (const zone of foodZones) {
const existingPolygon = normalizePolygon(existing[zone.id]);
polygons[zone.id] = existingPolygon.length ? existingPolygon : normalizePolygon(zone.polygon);
}
const existingTrash = normalizePolygon(existing[TRASH_REGION_ID]);
polygons[TRASH_REGION_ID] = existingTrash.length ? existingTrash : normalizePolygon(trashRoi);
return polygons;
}
export function buildCalibrationPayload(foodZones, polygons) {
const zones = foodZones
.map((zone) => ({
id: zone.id,
label: getRegionLabel(zone.id),
polygon: serializePolygon(polygons[zone.id]),
}))
.filter((zone) => zone.polygon.length >= 3);
const trashPolygon = serializePolygon(polygons[TRASH_REGION_ID]);
return {
layout: {
zone_count: foodZones.length,
zone_ids: foodZones.map((zone) => zone.id),
},
zones,
trash: trashPolygon.length >= 3 ? {roi: trashPolygon} : {},
};
}
export function classifyEvent(event = {}) {
const eventName = String(event.event || "");
const severity = String(event.severity || defaultSeverity(eventName)).toLowerCase();
const zoneIndex = deriveEventZoneIndex(event);
const zoneLabel = String(event.zone_label || (zoneIndex ? `区域 ${zoneIndex}` : event.zone_id || ""));
const isAlert = severity === "alarm" || eventName === "time_alarm";
const isWarning = severity === "warning" || eventName === "warning_escalated" || eventName.endsWith("_violation");
const isViolation = eventName === "warning_escalated" || eventName.endsWith("_violation") || event.state === "warning";
return {
severity,
tone: isWarning ? "warning" : isAlert ? "alarm" : "info",
zoneIndex,
zoneLabel,
isAlert,
isWarning,
isViolation,
};
}
export function buildRuntimeDisplayModel({
summary = null,
events = [],
config = {},
foodZones = deriveFoodZones(config),
demoReason = "",
now = new Date(),
} = {}) {
const safeConfig = config || {};
const realEvents = (Array.isArray(events) ? events : []).filter((event) => !isDemoRuntimeEvent(event));
const hasEvents = realEvents.length > 0;
const hasSummary = hasRuntimeSummary(summary) && !isDemoRuntimeSummary(summary);
const thresholdSeconds = runtimeThresholdSeconds(safeConfig, realEvents);
const displaySummary = hasSummary ? summary : createEmptyRuntimeSummary(thresholdSeconds);
const displayEvents = buildDisplayEvents(realEvents, now);
const latestZoneCounts = displaySummary?.metrics?.latest_zone_counts || {};
const configuredZoneIndexes = new Set(foodZones.map((zone) => Number(zone.id)).filter((id) => Number.isFinite(id)));
const progressRows = hasEvents
? buildProgressRowsFromEvents(realEvents, thresholdSeconds, now)
.filter((row) => configuredZoneIndexes.size === 0 || configuredZoneIndexes.has(row.zoneIndex))
.filter((row) => zoneCurrentlyOccupied(latestZoneCounts, row.zoneIndex))
: [];
return {
isDemo: false,
summaryIsDemo: false,
eventsAreDemo: false,
progressIsDemo: false,
hasSummary,
hasEvents,
demoReason,
summary: displaySummary,
events: realEvents,
displayEvents,
progressRows,
};
}
export function getRegionColor(id) {
if (id === TRASH_REGION_ID) {
return "#111827";
}
const index = Number(id) - 1;
return zonePalette[index] || "#667085";
}
export function getRegionLabel(id) {
if (id === TRASH_REGION_ID) {
return "垃圾桶";
}
if (isNumericId(id)) {
return `区域 ${id}`;
}
const match = String(id).match(/^r(\d)c(\d)$/);
return match ? `${match[1]}${match[2]}` : String(id);
}
export function secondsToAlarmMinutes(seconds) {
const parsed = Number(seconds);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 1;
}
return Math.max(1, Math.round(parsed / 60));
}
export function alarmMinutesToSeconds(minutes) {
const parsed = Number(minutes);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 60;
}
return Math.max(60, Math.round(parsed * 60));
}
export function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
}
function hasRuntimeSummary(summary) {
const metrics = summary?.metrics;
return Boolean(metrics && typeof metrics === "object");
}
function isDemoRuntimeSummary(summary) {
return containsDemoMarker(summary?.result_type) || containsDemoMarker(summary?.headline);
}
function isDemoRuntimeEvent(event) {
return event?.demo === true || containsDemoMarker(event?.camera_id) || containsDemoMarker(event?.batch_id);
}
function containsDemoMarker(value) {
const text = String(value || "").toLowerCase();
return text.includes("demo") || text.includes("演示");
}
function createEmptyRuntimeSummary(thresholdSeconds) {
return {
result_type: "cold_display_guard",
headline: "暂无事件数据",
last_result_time: "",
metrics: {
event_counts: {},
event_count: 0,
alert_count: 0,
warning_count: 0,
violation_count: 0,
latest_alert_time: "",
events_path: "-",
diagnostics_path: "-",
diagnostics_count: 0,
latest_zone_counts: {},
baseline_ready: false,
max_dwell_seconds: thresholdSeconds,
},
};
}
function buildDisplayEvents(events, now) {
const liveEventOrdersByBatch = latestLiveEventOrdersByBatch(events);
return events.map((event, order) => ({
...event,
displayDwellSeconds: displayEventDwellSeconds(event, order, liveEventOrdersByBatch, now),
}));
}
function displayEventDwellSeconds(event, order, liveEventOrdersByBatch, now) {
const fallbackSeconds = normalizeSeconds(event.dwell_seconds);
const batchId = String(event.batch_id || "");
if (!batchId || liveEventOrdersByBatch.get(batchId) !== order) {
return fallbackSeconds;
}
return liveDwellSeconds(event, fallbackSeconds, now);
}
function latestLiveEventOrdersByBatch(events) {
const latestByBatch = new Map();
events.forEach((event, order) => {
const batchId = String(event.batch_id || "");
if (!batchId) {
return;
}
const candidate = {
event,
eventTime: eventTimestamp(event),
order,
};
const existing = latestByBatch.get(batchId);
if (!existing || isNewerEventCandidate(candidate, existing)) {
latestByBatch.set(batchId, candidate);
}
});
const liveOrders = new Map();
latestByBatch.forEach((candidate, batchId) => {
if (isLiveBatchEvent(candidate.event)) {
liveOrders.set(batchId, candidate.order);
}
});
return liveOrders;
}
function zoneCurrentlyOccupied(latestZoneCounts, zoneIndex) {
if (!latestZoneCounts || typeof latestZoneCounts !== "object") {
return true;
}
if (Object.keys(latestZoneCounts).length === 0) {
return true;
}
const count = latestZoneCounts[String(zoneIndex)];
if (count === undefined) {
return false;
}
return Number(count) > 0;
}
function buildProgressRowsFromEvents(events, thresholdSeconds, now) {
const candidatesByZone = new Map();
events.forEach((event, order) => {
const meta = classifyEvent(event);
if (!meta.zoneIndex) {
return;
}
const dwellSeconds = liveDwellSeconds(event, normalizeSeconds(event.dwell_seconds), now);
const threshold = normalizeSeconds(event.max_dwell_seconds) || thresholdSeconds;
const existing = candidatesByZone.get(meta.zoneIndex);
const row = {
zoneIndex: meta.zoneIndex,
zoneLabel: meta.zoneLabel || `区域 ${meta.zoneIndex}`,
dwellSeconds,
thresholdSeconds: threshold,
progressPct: progressPct(dwellSeconds, threshold),
status: progressStatus(event, dwellSeconds, threshold),
source: "real",
};
const candidate = {
row,
eventTime: eventTimestamp(event),
order,
};
if (!existing || isNewerEventCandidate(candidate, existing)) {
candidatesByZone.set(meta.zoneIndex, candidate);
}
});
return [...candidatesByZone.values()].map((candidate) => candidate.row).sort((a, b) => a.zoneIndex - b.zoneIndex);
}
function runtimeThresholdSeconds(config = {}, events = []) {
const fromConfig = normalizeSeconds(config.thresholds?.max_dwell_seconds);
if (fromConfig > 0) {
return fromConfig;
}
const fromEvent = events.map((event) => normalizeSeconds(event.max_dwell_seconds)).find((seconds) => seconds > 0);
return fromEvent || DEFAULT_RUNTIME_THRESHOLD_SECONDS;
}
function progressStatus(event, dwellSeconds, thresholdSeconds) {
const meta = classifyEvent(event);
if (meta.isWarning || meta.isViolation) {
return "warning";
}
if (meta.isAlert || dwellSeconds >= thresholdSeconds) {
return "alarm";
}
return "normal";
}
function progressPct(dwellSeconds, thresholdSeconds) {
if (!thresholdSeconds) {
return 0;
}
return Math.min(100, Math.round((dwellSeconds / thresholdSeconds) * 100));
}
function normalizeSeconds(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0;
}
function eventTimestamp(event) {
const parsed = timestampMillis(event.ts);
return Number.isFinite(parsed) ? parsed : null;
}
function liveDwellSeconds(event, fallbackSeconds, now) {
if (!isLiveBatchEvent(event)) {
return fallbackSeconds;
}
const startedAt = timestampMillis(event.started_at);
const nowAt = timestampMillis(now);
if (!Number.isFinite(startedAt) || !Number.isFinite(nowAt) || nowAt < startedAt) {
return fallbackSeconds;
}
return Math.max(fallbackSeconds, Math.round((nowAt - startedAt) / 1000));
}
function isLiveBatchEvent(event = {}) {
const terminalEvents = new Set([
"batch_consumed",
"batch_pending_disposal",
"batch_discarded",
"warning_escalated",
"overdue_return_violation",
]);
const terminalStates = new Set(["consumed", "pending_disposal", "discarded", "warning"]);
const eventName = String(event.event || "");
const state = String(event.state || "").toLowerCase();
return Boolean(event.started_at)
&& !event.ended_at
&& !terminalEvents.has(eventName)
&& !terminalStates.has(state);
}
function timestampMillis(value) {
if (value instanceof Date) {
return value.getTime();
}
const parsed = Date.parse(String(value || ""));
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
function isNewerEventCandidate(next, existing) {
if (next.eventTime !== null && existing.eventTime !== null && next.eventTime !== existing.eventTime) {
return next.eventTime > existing.eventTime;
}
return next.order > existing.order;
}
export function normalizePolygon(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((point) => Array.isArray(point) || (point && typeof point === "object"))
.map((point) => {
const x = Array.isArray(point) ? point[0] : point.x;
const y = Array.isArray(point) ? point[1] : point.y;
return {x: round(clamp(Number(x))), y: round(clamp(Number(y)))};
})
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y));
}
function serializePolygon(points) {
return normalizePolygon(points).map((point) => [point.x, point.y]);
}
function normalizeZoneIds(value) {
if (!Array.isArray(value)) {
return [];
}
return value.map((id) => String(id).trim()).filter(Boolean);
}
function deriveLegacySourceIds(layout, configuredIds, zones, count) {
const configuredLegacyIds = configuredIds.filter((id) => !isNumericId(id));
if (configuredLegacyIds.length) {
return configuredLegacyIds;
}
if (!configuredIds.length) {
const rowColIds = rowColumnZoneIds(layout).slice(0, count);
if (rowColIds.length) {
return rowColIds;
}
}
return zones.map((zone) => String(zone.id || "")).filter((id) => id && !isNumericId(id));
}
function rowColumnZoneIds(layout) {
const rows = Number(layout.rows);
const cols = Number(layout.cols);
if (!Number.isFinite(rows) || !Number.isFinite(cols) || rows <= 0 || cols <= 0) {
return [];
}
const ids = [];
for (let row = 1; row <= Math.trunc(rows); row += 1) {
for (let col = 1; col <= Math.trunc(cols); col += 1) {
ids.push(`r${row}c${col}`);
}
}
return ids;
}
function isNumericId(id) {
return /^\d+$/.test(String(id));
}
function deriveEventZoneIndex(event) {
const explicit = Number(event.zone_index);
if (Number.isInteger(explicit) && explicit > 0) {
return explicit;
}
const zoneId = String(event.zone_id || "");
if (isNumericId(zoneId)) {
return Number(zoneId);
}
return null;
}
function defaultSeverity(eventName) {
if (eventName === "time_alarm") {
return "alarm";
}
if (eventName === "warning_escalated" || eventName.endsWith("_violation")) {
return "warning";
}
return "info";
}
function clamp(value) {
return Math.min(1, Math.max(0, value));
}
function round(value) {
return Math.round(value * 1000000) / 1000000;
}

630
web/test/zone-state.test.js Normal file
View File

@@ -0,0 +1,630 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
TRASH_REGION_ID,
alarmMinutesToSeconds,
buildCalibrationPayload,
buildPolygonMap,
buildRuntimeDisplayModel,
classifyEvent,
deriveFoodZones,
escapeHtml,
secondsToAlarmMinutes,
} from "../src/zone-state.js";
test("deriveFoodZones creates numeric zones from legacy grid config", () => {
const zones = deriveFoodZones({
layout: {zone_ids: ["r1c1", "r1c2"]},
zones: [
{id: "r1c1", label: "1排1列", polygon: [[0, 0], [0.4, 0], [0.4, 0.4]]},
{id: "r1c2", polygon: [[0.4, 0], [0.8, 0], [0.8, 0.4]]},
],
});
assert.deepEqual(zones.map((zone) => zone.id), ["1", "2"]);
assert.deepEqual(zones.map((zone) => zone.label), ["区域 1", "区域 2"]);
assert.deepEqual(zones[1].polygon, [
{x: 0.4, y: 0},
{x: 0.8, y: 0},
{x: 0.8, y: 0.4},
]);
});
test("deriveFoodZones maps legacy rows and columns without explicit zone ids", () => {
const zones = deriveFoodZones({
layout: {rows: 1, cols: 2},
zones: [
{id: "r1c1", polygon: [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]},
{id: "r1c2", polygon: [[0.4, 0.1], [0.6, 0.1], [0.6, 0.3]]},
],
});
assert.deepEqual(zones.map((zone) => zone.id), ["1", "2"]);
assert.deepEqual(zones.map((zone) => zone.label), ["区域 1", "区域 2"]);
assert.deepEqual(zones[0].polygon, [
{x: 0.1, y: 0.1},
{x: 0.3, y: 0.1},
{x: 0.3, y: 0.3},
]);
assert.deepEqual(zones[1].polygon, [
{x: 0.4, y: 0.1},
{x: 0.6, y: 0.1},
{x: 0.6, y: 0.3},
]);
});
test("deriveFoodZones honors numeric zone count and clamps to ten", () => {
const zones = deriveFoodZones({
layout: {
zone_count: 11,
zone_ids: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"],
},
});
assert.equal(zones.length, 10);
assert.equal(zones.at(-1).id, "10");
});
test("buildCalibrationPayload keeps trash roi separate from food zones", () => {
const payload = buildCalibrationPayload(
[
{id: "1", label: "1排1列"},
{id: "2", label: "区域 2"},
],
{
1: [{x: 0, y: 0}, {x: 0.2, y: 0}, {x: 0.2, y: 0.2}],
2: [{x: 0.2, y: 0}, {x: 0.4, y: 0}],
[TRASH_REGION_ID]: [{x: 0.8, y: 0.8}, {x: 1, y: 0.8}, {x: 1, y: 1}],
},
);
assert.deepEqual(payload.zones, [
{
id: "1",
label: "区域 1",
polygon: [[0, 0], [0.2, 0], [0.2, 0.2]],
},
]);
assert.deepEqual(payload.layout, {zone_count: 2, zone_ids: ["1", "2"]});
assert.deepEqual(payload.trash.roi, [[0.8, 0.8], [1, 0.8], [1, 1]]);
assert.equal(payload.zones.some((zone) => zone.id === TRASH_REGION_ID), false);
});
test("buildPolygonMap keeps saved config polygons when draft entries are empty", () => {
const foodZones = deriveFoodZones({
layout: {zone_count: 1, zone_ids: ["1"]},
zones: [{id: "1", polygon: [[0, 0], [0.5, 0], [0.5, 0.5]]}],
trash: {roi: [[0.8, 0.8], [1, 0.8], [1, 1]]},
});
const polygons = buildPolygonMap(foodZones, {1: [], [TRASH_REGION_ID]: []}, [[0.8, 0.8], [1, 0.8], [1, 1]]);
assert.deepEqual(polygons["1"], [
{x: 0, y: 0},
{x: 0.5, y: 0},
{x: 0.5, y: 0.5},
]);
assert.deepEqual(polygons[TRASH_REGION_ID], [
{x: 0.8, y: 0.8},
{x: 1, y: 0.8},
{x: 1, y: 1},
]);
});
test("classifyEvent exposes alarm and warning event display data", () => {
assert.deepEqual(classifyEvent({event: "time_alarm", zone_id: "2"}), {
severity: "alarm",
tone: "alarm",
zoneIndex: 2,
zoneLabel: "区域 2",
isAlert: true,
isWarning: false,
isViolation: false,
});
assert.deepEqual(classifyEvent({event: "warning_escalated", severity: "warning", zone_index: 3}), {
severity: "warning",
tone: "warning",
zoneIndex: 3,
zoneLabel: "区域 3",
isAlert: false,
isWarning: true,
isViolation: true,
});
});
test("alarm minute helpers round trip to backend seconds", () => {
assert.equal(secondsToAlarmMinutes(1200), 20);
assert.equal(secondsToAlarmMinutes(10800), 180);
assert.equal(alarmMinutesToSeconds(20), 1200);
});
test("escapeHtml neutralizes dynamic HTML before innerHTML rendering", () => {
assert.equal(
escapeHtml('<img src=x onerror=alert(1)> & "zone"'),
"&lt;img src=x onerror=alert(1)&gt; &amp; &quot;zone&quot;",
);
});
test("buildRuntimeDisplayModel does not synthesize demo runtime data", () => {
const model = buildRuntimeDisplayModel({
summary: null,
events: [],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 8}}),
demoReason: "接口不可用",
});
assert.equal(model.isDemo, false);
assert.equal(model.summaryIsDemo, false);
assert.equal(model.eventsAreDemo, false);
assert.equal(model.progressIsDemo, false);
assert.equal(model.demoReason, "接口不可用");
assert.equal(model.summary.metrics.event_count, 0);
assert.deepEqual(model.events, []);
assert.deepEqual(model.progressRows, []);
});
test("buildRuntimeDisplayModel tolerates null config before backend config loads", () => {
const model = buildRuntimeDisplayModel({
summary: null,
events: [],
config: null,
foodZones: deriveFoodZones({layout: {zone_count: 2}}),
});
assert.equal(model.isDemo, false);
assert.deepEqual(model.events, []);
assert.deepEqual(model.progressRows, []);
assert.equal(model.summary.metrics.max_dwell_seconds, 1200);
});
test("buildRuntimeDisplayModel keeps diagnostics-only runtime data without demo fallback", () => {
const summary = {
metrics: {
event_count: 0,
alert_count: 0,
warning_count: 0,
violation_count: 0,
diagnostics_count: 8,
latest_zone_counts: {},
},
};
const model = buildRuntimeDisplayModel({
summary,
events: [],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 3}}),
});
assert.equal(model.summaryIsDemo, false);
assert.equal(model.eventsAreDemo, false);
assert.equal(model.progressIsDemo, false);
assert.equal(model.summary, summary);
assert.deepEqual(model.events, []);
assert.deepEqual(model.progressRows, []);
});
test("buildRuntimeDisplayModel filters legacy demo events and summaries", () => {
const model = buildRuntimeDisplayModel({
summary: {
result_type: "cold_display_guard_demo",
metrics: {
event_count: 4,
alert_count: 1,
warning_count: 2,
violation_count: 1,
},
},
events: [
{
demo: true,
event: "time_alarm",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
dwell_seconds: 1200,
},
{
event: "batch_started",
zone_id: "2",
zone_index: 2,
zone_label: "区域 2",
dwell_seconds: 0,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 3}}),
});
assert.equal(model.hasSummary, false);
assert.equal(model.summary.metrics.event_count, 0);
assert.deepEqual(model.events.map((event) => event.zone_id), ["2"]);
assert.deepEqual(model.progressRows.map((row) => row.zoneIndex), [2]);
});
test("buildRuntimeDisplayModel keeps real summary and events ahead of demo data", () => {
const realSummary = {
metrics: {
event_count: 1,
alert_count: 1,
warning_count: 0,
violation_count: 0,
diagnostics_count: 2,
baseline_ready: true,
latest_alert_time: "2026-05-26T14:40:00+08:00",
},
};
const realEvents = [{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-26T14:40:00+08:00",
zone_id: "2",
zone_index: 2,
zone_label: "区域 2",
batch_id: "batch_real",
dwell_seconds: 1300,
max_dwell_seconds: 1200,
}];
const model = buildRuntimeDisplayModel({
summary: realSummary,
events: realEvents,
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
});
assert.equal(model.isDemo, false);
assert.equal(model.summaryIsDemo, false);
assert.equal(model.eventsAreDemo, false);
assert.equal(model.progressIsDemo, false);
assert.equal(model.summary, realSummary);
assert.deepEqual(model.events, realEvents);
assert.deepEqual(model.progressRows, [{
zoneIndex: 2,
zoneLabel: "区域 2",
dwellSeconds: 1300,
thresholdSeconds: 1200,
progressPct: 100,
status: "alarm",
source: "real",
}]);
});
test("buildRuntimeDisplayModel uses latest real event for zone progress", () => {
const model = buildRuntimeDisplayModel({
summary: {
metrics: {
event_count: 2,
alert_count: 1,
warning_count: 0,
violation_count: 0,
},
},
events: [
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-26T14:40:00+08:00",
zone_id: "2",
zone_index: 2,
zone_label: "区域 2",
batch_id: "old_batch",
dwell_seconds: 1300,
max_dwell_seconds: 1200,
},
{
event: "batch_started",
severity: "info",
ts: "2026-05-26T14:50:00+08:00",
zone_id: "2",
zone_index: 2,
zone_label: "区域 2",
batch_id: "new_batch",
current_count: 2,
dwell_seconds: 0,
max_dwell_seconds: 1200,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
});
assert.deepEqual(model.progressRows, [{
zoneIndex: 2,
zoneLabel: "区域 2",
dwellSeconds: 0,
thresholdSeconds: 1200,
progressPct: 0,
status: "normal",
source: "real",
}]);
});
test("buildRuntimeDisplayModel keeps active dwell timer moving from started_at", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 1, alert_count: 1}},
events: [
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-27T09:43:48+08:00",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
batch_id: "batch_active",
state: "alerted",
started_at: "2026-05-27T09:23:43+08:00",
alerted_at: "2026-05-27T09:43:48+08:00",
dwell_seconds: 1205,
max_dwell_seconds: 1200,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 2}}),
now: "2026-05-27T09:50:00+08:00",
});
assert.equal(model.progressRows[0].dwellSeconds, 1577);
assert.equal(model.progressRows[0].progressPct, 100);
assert.equal(model.progressRows[0].status, "alarm");
});
test("buildRuntimeDisplayModel exposes live dwell seconds for event table rows", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 1, alert_count: 1}},
events: [
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-27T09:43:48+08:00",
zone_id: "6",
zone_index: 6,
zone_label: "区域 6",
batch_id: "batch_active",
state: "alerted",
started_at: "2026-05-27T09:23:49+08:00",
alerted_at: "2026-05-27T09:43:54+08:00",
dwell_seconds: 1204,
max_dwell_seconds: 1200,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 8}}),
now: "2026-05-27T11:03:49+08:00",
});
assert.equal(model.events[0].dwell_seconds, 1204);
assert.equal(model.displayEvents[0].displayDwellSeconds, 6000);
});
test("buildRuntimeDisplayModel does not keep batch_started row ticking after removal", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 2, latest_zone_counts: {"1": 0}}},
events: [
{
event: "batch_started",
severity: "info",
ts: "2026-05-29T09:59:49+08:00",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
batch_id: "batch_done",
state: "active",
started_at: "2026-05-29T09:59:49+08:00",
dwell_seconds: 0,
max_dwell_seconds: 300,
},
{
event: "batch_consumed",
severity: "info",
ts: "2026-05-29T10:00:53+08:00",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
batch_id: "batch_done",
state: "consumed",
started_at: "2026-05-29T09:59:49+08:00",
ended_at: "2026-05-29T10:00:53+08:00",
dwell_seconds: 64,
max_dwell_seconds: 300,
},
],
config: {thresholds: {max_dwell_seconds: 300}},
foodZones: deriveFoodZones({layout: {zone_count: 8}}),
now: "2026-05-29T10:05:00+08:00",
});
assert.equal(model.displayEvents[0].displayDwellSeconds, 0);
assert.equal(model.displayEvents[1].displayDwellSeconds, 64);
});
test("buildRuntimeDisplayModel hides live progress for zones currently empty in diagnostics", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 2, alert_count: 2, latest_zone_counts: {"1": 1, "3": 0}}},
events: [
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-27T09:43:48+08:00",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
batch_id: "batch_real",
state: "alerted",
started_at: "2026-05-27T09:23:43+08:00",
dwell_seconds: 1204,
max_dwell_seconds: 1200,
},
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-27T10:13:55+08:00",
zone_id: "3",
zone_index: 3,
zone_label: "区域 3",
batch_id: "batch_reflection",
state: "alerted",
started_at: "2026-05-27T09:53:51+08:00",
dwell_seconds: 1204,
max_dwell_seconds: 1200,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
now: "2026-05-27T11:03:51+08:00",
});
assert.deepEqual(model.progressRows.map((row) => row.zoneIndex), [1]);
});
test("buildRuntimeDisplayModel hides historical zones outside current configuration", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 3, latest_zone_counts: {"4": 1}}},
events: [
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-29T09:50:00+08:00",
zone_id: "4",
zone_index: 4,
zone_label: "区域 4",
batch_id: "batch_current",
started_at: "2026-05-29T09:45:00+08:00",
dwell_seconds: 300,
max_dwell_seconds: 300,
},
{
event: "batch_consumed",
severity: "info",
ts: "2026-05-28T08:31:53+08:00",
zone_id: "9",
zone_index: 9,
zone_label: "区域 9",
batch_id: "batch_old_9",
started_at: "2026-05-28T08:13:48+08:00",
ended_at: "2026-05-28T08:31:53+08:00",
dwell_seconds: 1085,
max_dwell_seconds: 1200,
},
{
event: "batch_consumed",
severity: "info",
ts: "2026-05-28T08:31:53+08:00",
zone_id: "10",
zone_index: 10,
zone_label: "区域 10",
batch_id: "batch_old_10",
started_at: "2026-05-28T08:13:48+08:00",
ended_at: "2026-05-28T08:31:53+08:00",
dwell_seconds: 1085,
max_dwell_seconds: 1200,
},
],
config: {layout: {zone_count: 8, zone_ids: ["1", "2", "3", "4", "5", "6", "7", "8"]}},
foodZones: deriveFoodZones({layout: {zone_count: 8, zone_ids: ["1", "2", "3", "4", "5", "6", "7", "8"]}}),
});
assert.deepEqual(model.progressRows.map((row) => row.zoneIndex), [4]);
});
test("buildRuntimeDisplayModel does not advance ended batch dwell timer", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 1}},
events: [
{
event: "batch_consumed",
severity: "info",
ts: "2026-05-27T09:25:00+08:00",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
batch_id: "batch_done",
state: "consumed",
started_at: "2026-05-27T09:23:43+08:00",
ended_at: "2026-05-27T09:25:00+08:00",
dwell_seconds: 77,
max_dwell_seconds: 1200,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 2}}),
now: "2026-05-27T09:50:00+08:00",
});
assert.equal(model.progressRows[0].dwellSeconds, 77);
assert.equal(model.progressRows[0].progressPct, 6);
assert.equal(model.progressRows[0].status, "normal");
});
test("buildRuntimeDisplayModel falls back to event order when latest event has no timestamp", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 2, alert_count: 1}},
events: [
{
event: "time_alarm",
severity: "alarm",
ts: "2026-05-26T14:40:00+08:00",
zone_id: "2",
zone_index: 2,
zone_label: "区域 2",
batch_id: "old_batch",
dwell_seconds: 1300,
max_dwell_seconds: 1200,
},
{
event: "batch_started",
severity: "info",
zone_id: "2",
zone_index: 2,
zone_label: "区域 2",
batch_id: "new_batch",
current_count: 2,
dwell_seconds: 0,
},
],
config: {thresholds: {max_dwell_seconds: 1200}},
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
});
assert.deepEqual(model.progressRows, [{
zoneIndex: 2,
zoneLabel: "区域 2",
dwellSeconds: 0,
thresholdSeconds: 1200,
progressPct: 0,
status: "normal",
source: "real",
}]);
});
test("buildRuntimeDisplayModel uses config threshold when event omits threshold", () => {
const model = buildRuntimeDisplayModel({
summary: {metrics: {event_count: 1}},
events: [
{
event: "batch_count_changed",
severity: "info",
ts: "2026-05-26T14:40:00+08:00",
zone_id: "1",
zone_index: 1,
zone_label: "区域 1",
batch_id: "batch_1",
dwell_seconds: 700,
},
],
config: {thresholds: {max_dwell_seconds: 600}},
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
});
assert.deepEqual(model.progressRows, [{
zoneIndex: 1,
zoneLabel: "区域 1",
dwellSeconds: 700,
thresholdSeconds: 600,
progressPct: 100,
status: "alarm",
source: "real",
}]);
});