feat: stabilize cold display runtime deployment
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.textClipping
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.venv/
|
.venv/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal 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"]
|
||||||
70
README_zh.md
70
README_zh.md
@@ -1,19 +1,22 @@
|
|||||||
# 冷藏展示柜食品批次计时报警
|
# 冷藏展示柜食品批次计时报警
|
||||||
|
|
||||||
这是一个独立项目,用于单摄像头监控冷藏展示柜和同画面垃圾桶,记录每个展示区域内食品批次的放置时长,并发现 3 小时到期后的违规行为。
|
这是一个独立项目,用于单摄像头监控冷藏展示柜和同画面垃圾桶,记录每个展示区域内食品批次的放置时长,并发现超过自定义报警时间后的异常处理行为。
|
||||||
|
|
||||||
## 已确认业务规则
|
## 已确认业务规则
|
||||||
|
|
||||||
- 摄像头同时看到展示柜和垃圾桶。
|
- 摄像头同时看到展示柜和垃圾桶。
|
||||||
- 展示柜初始布局为横向 4 列、竖向 2 行。
|
- 展示柜食品区域支持 1 到 10 个自定义区域。
|
||||||
- 布局后期可以通过配置调整。
|
- 食品区域使用阿拉伯数字标注:`1`、`2`、`3` ...
|
||||||
|
- 垃圾桶 ROI 独立标定,不占用食品区域编号。
|
||||||
- 每个区域可以放多份食品,但这些食品按同一批次计时。
|
- 每个区域可以放多份食品,但这些食品按同一批次计时。
|
||||||
- 同一区域不允许混批,必须清空后才能放入新批次。
|
- 同一区域不允许混批,必须清空后才能放入新批次。
|
||||||
- 食品放入区域时记录开始时间。
|
- 食品放入区域时记录开始时间。
|
||||||
- 区域清空时记录结束时间。
|
- 区域清空时记录结束时间。
|
||||||
- 未满 3 小时清空视为正常消耗。
|
- 未达到报警阈值前清空视为正常消耗。
|
||||||
- 超过 3 小时清空后必须在确认窗口内看到垃圾桶投放动作。
|
- 食品在区域内达到 `max_dwell_seconds` 时先产生 `time_alarm`。
|
||||||
- 超过 3 小时的食品拿出后又放回展示柜,触发报警。
|
- 已报警食品从区域移出后,必须在确认窗口内看到垃圾桶投放动作。
|
||||||
|
- 如果已报警食品移出后没有丢到垃圾桶里,报警事件升级为 `warning_escalated` 警告事件。
|
||||||
|
- 已报警食品拿出后又放回展示柜,触发违规事件。
|
||||||
|
|
||||||
## 当前实现范围
|
## 当前实现范围
|
||||||
|
|
||||||
@@ -23,8 +26,8 @@
|
|||||||
{
|
{
|
||||||
"ts": "2026-04-27T10:00:00+08:00",
|
"ts": "2026-04-27T10:00:00+08:00",
|
||||||
"zone_counts": {
|
"zone_counts": {
|
||||||
"r1c1": 3,
|
"1": 1,
|
||||||
"r1c2": 0
|
"2": 0
|
||||||
},
|
},
|
||||||
"trash_deposit": false
|
"trash_deposit": false
|
||||||
}
|
}
|
||||||
@@ -33,12 +36,13 @@
|
|||||||
程序会输出 JSONL 事件,例如:
|
程序会输出 JSONL 事件,例如:
|
||||||
|
|
||||||
- `batch_started`
|
- `batch_started`
|
||||||
|
- `time_alarm`
|
||||||
- `batch_consumed`
|
- `batch_consumed`
|
||||||
- `batch_pending_disposal`
|
- `batch_pending_disposal`
|
||||||
- `batch_discarded`
|
- `batch_discarded`
|
||||||
|
- `warning_escalated`
|
||||||
- `mixed_batch_violation`
|
- `mixed_batch_violation`
|
||||||
- `overdue_return_violation`
|
- `overdue_return_violation`
|
||||||
- `missing_disposal_violation`
|
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
@@ -46,9 +50,25 @@
|
|||||||
|
|
||||||
默认阈值:
|
默认阈值:
|
||||||
|
|
||||||
- 最大放置时间:`10800` 秒,也就是 3 小时
|
- 时间报警阈值:`10800` 秒,也就是 3 小时;管理页按分钟输入,例如 20 分钟会保存为 `1200` 秒
|
||||||
- 垃圾桶投放确认窗口:`120` 秒
|
- 垃圾桶投放确认窗口:`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`。
|
项目现在有正式管理页,前端默认 `23000`,后端默认 `19080`。
|
||||||
@@ -73,10 +93,10 @@ http://127.0.0.1:23000
|
|||||||
|
|
||||||
- 配置 RTSP 地址和阈值
|
- 配置 RTSP 地址和阈值
|
||||||
- 从 RTSP 拉取一帧截图
|
- 从 RTSP 拉取一帧截图
|
||||||
- 标定 `r1c1` 到 `r2c4` 的 8 个格口
|
- 设置 1 到 10 个食品区域
|
||||||
- 标定垃圾桶区域
|
- 标定数字食品区域和垃圾桶 ROI
|
||||||
- 直接保存标定结果到项目配置文件
|
- 直接保存标定结果到项目配置文件
|
||||||
- 查看事件汇总和最近 JSONL 事件
|
- 查看事件汇总、区域序号、停留时间、报警和警告事件
|
||||||
|
|
||||||
项目仍保留 `tools/calibrator` 作为轻量单页标定工具,但正式使用建议走 `23000` 管理页。
|
项目仍保留 `tools/calibrator` 作为轻量单页标定工具,但正式使用建议走 `23000` 管理页。
|
||||||
|
|
||||||
@@ -131,11 +151,22 @@ frame_width = 640
|
|||||||
frame_height = 360
|
frame_height = 360
|
||||||
capture_timeout_seconds = 12.0
|
capture_timeout_seconds = 12.0
|
||||||
baseline_frames = 3
|
baseline_frames = 3
|
||||||
sample_stride_pixels = 8
|
sample_stride_pixels = 4
|
||||||
occupancy_mean_delta = 24.0
|
occupancy_mean_delta = 55.0
|
||||||
occupancy_texture_delta = 18.0
|
occupancy_texture_delta = 18.0
|
||||||
|
occupancy_dark_luma_threshold = 80.0
|
||||||
|
occupancy_dark_fraction = 0.06
|
||||||
|
occupancy_texture_dark_fraction = 0.04
|
||||||
|
occupancy_bright_luma_threshold = 220.0
|
||||||
|
occupancy_bright_reflection_fraction = 0.18
|
||||||
|
occupancy_reflection_dark_fraction = 0.10
|
||||||
|
occupancy_reflection_bright_dark_ratio = 2.0
|
||||||
|
occupancy_confirm_frames = 2
|
||||||
|
empty_confirm_frames = 2
|
||||||
trash_motion_delta = 18.0
|
trash_motion_delta = 18.0
|
||||||
trash_motion_cooldown_seconds = 8
|
trash_sustained_motion_delta = 8.0
|
||||||
|
trash_sustained_motion_frames = 2
|
||||||
|
trash_motion_cooldown_seconds = 3
|
||||||
diagnostics_path = "logs/runtime_diagnostics.jsonl"
|
diagnostics_path = "logs/runtime_diagnostics.jsonl"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -144,3 +175,10 @@ diagnostics_path = "logs/runtime_diagnostics.jsonl"
|
|||||||
```bash
|
```bash
|
||||||
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
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
99
agent.md
Normal 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.
|
||||||
@@ -1,52 +1,56 @@
|
|||||||
camera_id = "cold_display_cam_01"
|
camera_id = "1"
|
||||||
timezone = "Asia/Shanghai"
|
timezone = "Asia/Shanghai"
|
||||||
|
|
||||||
[stream]
|
[stream]
|
||||||
rtsp_url = "rtsp://admin:Zxjp2026@192.168.8.9:554/h264/ch1/main/av_stream"
|
rtsp_url = ""
|
||||||
|
|
||||||
[thresholds]
|
[thresholds]
|
||||||
max_dwell_seconds = 10800
|
max_dwell_seconds = 1200
|
||||||
trash_confirmation_seconds = 120
|
trash_confirmation_seconds = 120
|
||||||
|
|
||||||
[layout]
|
[layout]
|
||||||
rows = 2
|
zone_count = 4
|
||||||
cols = 4
|
zone_ids = ["1", "2", "3", "4"]
|
||||||
zone_ids = ["r1c1", "r1c2", "r1c3", "r1c4", "r2c1", "r2c2", "r2c3", "r2c4"]
|
|
||||||
|
|
||||||
[[zones]]
|
[[zones]]
|
||||||
id = "r1c1"
|
id = "1"
|
||||||
polygon = [[0.441053, 0.344678], [0.475789, 0.372749], [0.453684, 0.455088], [0.404211, 0.428889]]
|
label = "区域 1"
|
||||||
|
polygon = [[0.241988, 0.289459], [0.323741, 0.306900], [0.319817, 0.438286], [0.256377, 0.420845]]
|
||||||
|
|
||||||
[[zones]]
|
[[zones]]
|
||||||
id = "r1c2"
|
id = "2"
|
||||||
polygon = [[0.486316, 0.367135], [0.520000, 0.397076], [0.503158, 0.468187], [0.467368, 0.451345]]
|
label = "区域 2"
|
||||||
|
polygon = [[0.354480, 0.320852], [0.423152, 0.330154], [0.419228, 0.470842], [0.378025, 0.454564], [0.357096, 0.446425]]
|
||||||
|
|
||||||
[[zones]]
|
[[zones]]
|
||||||
id = "r1c3"
|
id = "3"
|
||||||
|
label = "区域 3"
|
||||||
polygon = [[0.545263, 0.400819], [0.587368, 0.417661], [0.554737, 0.500000], [0.509474, 0.483158]]
|
polygon = [[0.545263, 0.400819], [0.587368, 0.417661], [0.554737, 0.500000], [0.509474, 0.483158]]
|
||||||
|
|
||||||
[[zones]]
|
[[zones]]
|
||||||
id = "r1c4"
|
id = "4"
|
||||||
|
label = "区域 4"
|
||||||
polygon = [[0.581255, 0.408928], [0.717971, 0.468544], [0.711092, 0.574018], [0.556320, 0.500645]]
|
polygon = [[0.581255, 0.408928], [0.717971, 0.468544], [0.711092, 0.574018], [0.556320, 0.500645]]
|
||||||
|
|
||||||
[[zones]]
|
|
||||||
id = "r2c1"
|
|
||||||
polygon = [[0.396842, 0.475673], [0.487368, 0.543041], [0.472632, 0.612281], [0.373684, 0.584211]]
|
|
||||||
|
|
||||||
[[zones]]
|
|
||||||
id = "r2c2"
|
|
||||||
polygon = [[0.502105, 0.528070], [0.535789, 0.546784], [0.516842, 0.660936], [0.477895, 0.632865]]
|
|
||||||
|
|
||||||
[[zones]]
|
|
||||||
id = "r2c3"
|
|
||||||
polygon = [[0.555789, 0.552398], [0.602105, 0.569240], [0.580000, 0.657193], [0.535789, 0.645965]]
|
|
||||||
|
|
||||||
[[zones]]
|
|
||||||
id = "r2c4"
|
|
||||||
polygon = [[0.602105, 0.567368], [0.700000, 0.606667], [0.689474, 0.722690], [0.581053, 0.683392]]
|
|
||||||
|
|
||||||
[trash]
|
[trash]
|
||||||
roi = [[0.776842, 0.486901], [0.896842, 0.522456], [0.841053, 0.857427], [0.716842, 0.853684]]
|
roi = [[0.776842, 0.486901], [0.896842, 0.522456], [0.841053, 0.857427], [0.716842, 0.853684]]
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
sample_stride_pixels = 4
|
||||||
|
occupancy_mean_delta = 55.0
|
||||||
|
occupancy_dark_luma_threshold = 80.0
|
||||||
|
occupancy_dark_fraction = 0.06
|
||||||
|
occupancy_texture_dark_fraction = 0.04
|
||||||
|
occupancy_bright_luma_threshold = 220.0
|
||||||
|
occupancy_bright_reflection_fraction = 0.18
|
||||||
|
occupancy_reflection_dark_fraction = 0.10
|
||||||
|
occupancy_reflection_bright_dark_ratio = 2.0
|
||||||
|
occupancy_confirm_frames = 2
|
||||||
|
empty_confirm_frames = 2
|
||||||
|
trash_motion_delta = 18.0
|
||||||
|
trash_sustained_motion_delta = 8.0
|
||||||
|
trash_sustained_motion_frames = 2
|
||||||
|
trash_motion_cooldown_seconds = 3
|
||||||
|
|
||||||
[event_sink]
|
[event_sink]
|
||||||
path = "logs/events.jsonl"
|
path = "logs/events.jsonl"
|
||||||
|
|||||||
7
deploy/cold-display-guard.env
Normal file
7
deploy/cold-display-guard.env
Normal 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
71
deploy/docker-compose.yml
Normal 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
|
||||||
84
docs/project.md
Normal file
84
docs/project.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Cold Display Guard Project Documentation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`cold-display-guard` monitors refrigerated display food batches by camera region. It tracks how long each configured food region remains occupied, raises a configurable time alarm, and escalates alarmed food to a warning if it is removed without a matching trash-bin deposit.
|
||||||
|
|
||||||
|
The `v1.1 优化改造` batch upgrades the product from a fixed 8-zone layout to a configurable 1-10 zone workflow with numeric region labels and editable trash ROI calibration. All v1.1 items are part of one batch; backend, API, frontend, and documentation are implementation workstreams inside that same batch.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- Backend package: `src/cold_display_guard/`
|
||||||
|
- `models.py`: settings, observations, and batch dataclasses.
|
||||||
|
- `engine.py`: deterministic batch state machine.
|
||||||
|
- `config.py`: TOML config load/save, calibration merge, and project path resolution.
|
||||||
|
- `manage_api.py`: standard-library HTTP management API.
|
||||||
|
- `main.py`: RTSP runtime loop connecting frame capture, vision, state engine, and JSONL sinks.
|
||||||
|
- `vision.py`: heuristic ROI occupancy and trash-motion detection.
|
||||||
|
- Frontend package: `web/`
|
||||||
|
- Vite + vanilla JavaScript management console.
|
||||||
|
- Default web port `23000`.
|
||||||
|
- API proxy target `http://127.0.0.1:19080`.
|
||||||
|
- Runtime home view falls back to clearly marked demo data when no real events exist, so the operational layout still shows summary cards, dwell timers, and event rows.
|
||||||
|
- Runtime data:
|
||||||
|
- Events JSONL default path `logs/events.jsonl`.
|
||||||
|
- Diagnostics JSONL default path `logs/runtime_diagnostics.jsonl`.
|
||||||
|
- Deployment:
|
||||||
|
- Root Python Docker image for API/runtime.
|
||||||
|
- `web/Dockerfile` for static web console.
|
||||||
|
- `deploy/docker-compose.yml` wires API, runtime, and web services.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- Main config path: `config/example.toml`.
|
||||||
|
- Camera identity: `camera_id`.
|
||||||
|
- Timezone default: `Asia/Shanghai`.
|
||||||
|
- RTSP input: `[stream] rtsp_url`.
|
||||||
|
- Thresholds:
|
||||||
|
- `max_dwell_seconds`: v1.1 time-alarm threshold. Default can remain 10800 seconds; users can set values such as 1200 seconds for 20 minutes.
|
||||||
|
- `trash_confirmation_seconds`: window after an alarmed batch is removed where a trash deposit must be observed before warning escalation.
|
||||||
|
- Food zones:
|
||||||
|
- v1.1 food zone IDs are numeric strings from `"1"` through `"10"`.
|
||||||
|
- The configured zone count must be between 1 and 10.
|
||||||
|
- If both `zone_count` and numeric `zone_ids` are present, they must agree.
|
||||||
|
- Each `[[zones]]` polygon must have at least 3 normalized points.
|
||||||
|
- Trash ROI:
|
||||||
|
- Stored under `[trash] roi`.
|
||||||
|
- Does not use a food zone number.
|
||||||
|
|
||||||
|
## Event Model
|
||||||
|
|
||||||
|
- `batch_started`: a food region changes from empty to occupied.
|
||||||
|
- `time_alarm`: an active batch reaches `max_dwell_seconds` while still in the display region.
|
||||||
|
- `batch_count_changed`: count decreases while the region remains occupied.
|
||||||
|
- `mixed_batch_violation`: count increases before the region clears.
|
||||||
|
- `batch_consumed`: a non-alarmed batch clears before the threshold.
|
||||||
|
- `batch_pending_disposal`: an alarmed batch clears and waits for trash confirmation.
|
||||||
|
- `batch_discarded`: a pending alarmed batch is matched to a trash deposit.
|
||||||
|
- `warning_escalated`: a pending alarmed batch is not matched to a trash deposit before the confirmation deadline.
|
||||||
|
|
||||||
|
Events should include `zone_id`, `zone_index`, `zone_label`, `started_at`, `dwell_seconds`, and relevant alarm/removal/deadline timestamps when available.
|
||||||
|
|
||||||
|
## Runbook
|
||||||
|
|
||||||
|
- Python tests:
|
||||||
|
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`
|
||||||
|
- Management API:
|
||||||
|
- `scripts/run_manage_api.sh`
|
||||||
|
- Health: `curl http://127.0.0.1:19080/api/manage/health`
|
||||||
|
- Web console:
|
||||||
|
- `scripts/run_web.sh`
|
||||||
|
- Build: `cd web && pnpm build`
|
||||||
|
- Frontend logic tests: `node --test web/test/zone-state.test.js`
|
||||||
|
- Docker web URL: `http://127.0.0.1:23000`
|
||||||
|
- Runtime monitor:
|
||||||
|
- `scripts/run_runtime.sh`
|
||||||
|
- One-frame smoke test when camera and `ffmpeg` are available:
|
||||||
|
- `PYTHONPATH=src python3 -m cold_display_guard.main --config config/example.toml --once`
|
||||||
|
|
||||||
|
## Known Risks
|
||||||
|
|
||||||
|
- The current vision detector is heuristic and reports binary occupancy, not item counts.
|
||||||
|
- If food is already present during baseline collection, those regions may be treated as empty baseline until visual changes occur.
|
||||||
|
- Changing calibration while the runtime process has active batches can create operational ambiguity; v1.1 should document or enforce a pause/restart expectation.
|
||||||
|
- Historical events must keep the zone index at the time of emission so later region reordering does not reinterpret old logs.
|
||||||
81
findings.md
81
findings.md
@@ -14,3 +14,84 @@
|
|||||||
- If a batch is removed after the maximum dwell threshold, the system expects a trash-bin deposit event within a configurable window.
|
- If a 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 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.
|
- If food is added while a zone is already occupied, that is a mixed-batch violation.
|
||||||
|
|
||||||
|
## v1.1 优化改造 Findings
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- The current backend already accepts configured `layout.zone_ids`, so the engine does not require a fixed 2x4 grid internally.
|
||||||
|
- The current frontend is the main fixed-grid constraint: `web/src/main.js` hard-codes `r1c1` through `r2c4` and draws those region controls.
|
||||||
|
- `merge_calibration()` already accepts arbitrary zone IDs and clamps polygon points, but it does not enforce the new 1-10 numeric region policy.
|
||||||
|
- The runtime vision layer consumes `[[zones]]` from config, so it can follow numeric zones once config and frontend write them.
|
||||||
|
- `BatchEngine` only emits events when a zone changes or pending disposal expires; a time alarm while the batch remains occupied requires a new periodic active-batch check.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Food regions must be numbered `1` through `N`, with `N` between 1 and 10.
|
||||||
|
- Trash ROI is a separate region under `[trash]` and must not consume a food zone number.
|
||||||
|
- The management API should preserve standard-library HTTP behavior.
|
||||||
|
- Existing JSONL consumers may still expect `event`, `zone_id`, `dwell_seconds`, and timestamps; v1.1 should add fields rather than remove core fields.
|
||||||
|
- The frontend remains Vite + vanilla JavaScript; no framework migration in this batch.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Keep `max_dwell_seconds` as the configurable time-alarm threshold to avoid introducing two competing dwell thresholds.
|
||||||
|
- Add `time_alarm` when an active batch reaches the threshold, while keeping the batch active until the zone clears.
|
||||||
|
- Once an alarmed batch clears, put it into pending trash confirmation and emit `batch_pending_disposal`.
|
||||||
|
- If no trash deposit occurs before the confirmation deadline, emit `warning_escalated` with severity `warning`; retain compatibility by using the same pending-disposal mechanism.
|
||||||
|
- Emit `zone_index` and `zone_label` on every zone event when the zone ID is numeric.
|
||||||
|
- The backend planning agent suggested `batch_dwell_alert` and `missing_disposal_warning`, but the accepted prototype and user phrasing use `time_alarm` and `warning_escalated`; implementation should follow the accepted prototype names.
|
||||||
|
- Every future subagent dispatch must begin with the standard context header requested by the user:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[项目: /Users/yoilun/Code/cold_display_guard]
|
||||||
|
[工作流批次: v1.1 优化改造]
|
||||||
|
[阶段: 阶段 x]
|
||||||
|
[角色: 对应智能体角色]
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `阶段 x` only as a workflow stage inside the same `v1.1 优化改造` batch.
|
||||||
|
|
||||||
|
## Backend Planning Notes
|
||||||
|
|
||||||
|
- `EngineSettings.zone_ids` should remain config driven; numeric zones are preferred for new configs, but old `r1c1` style IDs should continue loading.
|
||||||
|
- v1.1 can derive `zone_index` from numeric `zone_id` when possible.
|
||||||
|
- Suggested batch additions:
|
||||||
|
- `alerted_at`
|
||||||
|
- an indicator that the time alarm has already been emitted
|
||||||
|
- Suggested state flow:
|
||||||
|
- `active`: region occupied before threshold.
|
||||||
|
- `alerted`: region occupied after `time_alarm`.
|
||||||
|
- `pending_disposal`: alarmed food removed and waiting for trash deposit.
|
||||||
|
- `discarded`: pending batch matched to trash.
|
||||||
|
- `warning`: alarmed food removed and no trash deposit before deadline.
|
||||||
|
- `consumed`: non-alarmed food removed.
|
||||||
|
- `build_summary()` currently counts only `*_violation`; v1.1 should include `severity: alert|warning` in alert summaries.
|
||||||
|
- FIFO matching of pending batches to trash deposits remains acceptable but should stay covered by tests.
|
||||||
|
|
||||||
|
## Frontend Planning Notes
|
||||||
|
|
||||||
|
- Replace the fixed `zoneIds` array in `web/src/main.js` with dynamic food-zone state derived from config, constrained to 1-10 zones.
|
||||||
|
- Keep `trashRoi` separate from `foodZones`; never include it in `zone_index` numbering.
|
||||||
|
- Suggested frontend state groups:
|
||||||
|
- `foodZoneCount`
|
||||||
|
- `foodZones: [{id, label, points}]`
|
||||||
|
- `trashRoi`
|
||||||
|
- active edit target type/index
|
||||||
|
- config form values including alarm threshold
|
||||||
|
- normalized event rows for display
|
||||||
|
- Current target must be visually obvious before canvas clicks add points.
|
||||||
|
- Coordinates must remain normalized relative to the displayed frame/image.
|
||||||
|
- Reducing zone count should truncate removed zones only after a clear confirmation or obvious UI affordance.
|
||||||
|
- Re-capturing an RTSP frame must not discard unsaved point edits.
|
||||||
|
- Event table must safely render old and new events, including `time_alarm` and `warning_escalated`.
|
||||||
|
- Frontend validation should cover 1/10 zones, food/trash editing, save/reload recovery, old 8-zone config compatibility, and threshold validation.
|
||||||
|
|
||||||
|
## Homepage Demo Runtime Notes
|
||||||
|
|
||||||
|
- Docker runtime can write diagnostics while no events exist, especially when the configured RTSP stream is unreachable.
|
||||||
|
- A diagnostics-only summary is not enough to represent the accepted prototype; the home runtime view should show a clearly marked demo state until real events exist.
|
||||||
|
- Demo runtime content must never look like a real alarm: banner, metrics labels, event source tags, and event file text identify it as demo data.
|
||||||
|
- Real events take precedence over demo data. Per-zone progress uses the latest event by timestamp when both candidates have timestamps; otherwise it falls back to event order.
|
||||||
|
- Event-derived progress uses the configured dwell threshold when an event omits `max_dwell_seconds`.
|
||||||
|
- Any runtime summary value rendered through `innerHTML`, including `latest_zone_counts`, must be HTML-escaped.
|
||||||
|
|||||||
172
progress.md
172
progress.md
@@ -11,3 +11,175 @@
|
|||||||
- Initialized git repository and created the initial project commit.
|
- Initialized git repository and created the initial project commit.
|
||||||
- Added RTSP single-frame calibration tool under `tools/calibrator`.
|
- Added RTSP single-frame calibration tool under `tools/calibrator`.
|
||||||
- Added formal management API on port `19080` and Vite frontend on port `23000`.
|
- 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 |
|
||||||
|
|||||||
1110
prototype/custom-zones/index.html
Normal file
1110
prototype/custom-zones/index.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ from cold_display_guard.models import DEFAULT_ZONE_IDS, EngineSettings
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_CONFIG_PATH = Path("config/example.toml")
|
DEFAULT_CONFIG_PATH = Path("config/example.toml")
|
||||||
|
MAX_CUSTOM_FOOD_ZONES = 10
|
||||||
|
|
||||||
|
|
||||||
def load_settings(path: str | Path) -> EngineSettings:
|
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", {})
|
thresholds: dict[str, Any] = data.get("thresholds", {})
|
||||||
layout: dict[str, Any] = data.get("layout", {})
|
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:
|
if not zone_ids:
|
||||||
zone_ids = DEFAULT_ZONE_IDS
|
zone_ids = DEFAULT_ZONE_IDS
|
||||||
|
|
||||||
@@ -56,24 +57,53 @@ def merge_calibration(
|
|||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
zones: list[dict[str, Any]],
|
zones: list[dict[str, Any]],
|
||||||
trash_roi: list[list[float]] | None,
|
trash_roi: list[list[float]] | None,
|
||||||
|
layout_update: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
merged = deepcopy(data)
|
merged = deepcopy(data)
|
||||||
|
incoming_numeric_zone_ids = _incoming_numeric_zone_ids(layout_update)
|
||||||
valid_zones: dict[str, dict[str, Any]] = {}
|
valid_zones: dict[str, dict[str, Any]] = {}
|
||||||
for zone in zones:
|
for zone in zones:
|
||||||
zone_id = str(zone.get("id", "")).strip()
|
zone_id = str(zone.get("id", "")).strip()
|
||||||
|
if zone_id.lower() == "trash":
|
||||||
|
continue
|
||||||
polygon = _normalize_points(zone.get("polygon", []))
|
polygon = _normalize_points(zone.get("polygon", []))
|
||||||
if not zone_id or len(polygon) < 3:
|
if not zone_id or len(polygon) < 3:
|
||||||
continue
|
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 = {
|
existing_by_id = {
|
||||||
str(zone.get("id", "")).strip(): zone
|
str(zone.get("id", "")).strip(): zone
|
||||||
for zone in merged.get("zones", [])
|
for zone in merged.get("zones", [])
|
||||||
if str(zone.get("id", "")).strip()
|
if str(zone.get("id", "")).strip()
|
||||||
}
|
}
|
||||||
existing_by_id.update(valid_zones)
|
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]
|
zone_order = [str(item) for item in layout.get("zone_ids", []) if str(item) in existing_by_id]
|
||||||
for zone_id in valid_zones:
|
for zone_id in valid_zones:
|
||||||
if zone_id not in zone_order:
|
if zone_id not in zone_order:
|
||||||
@@ -123,10 +153,16 @@ def format_config_document(data: dict[str, Any]) -> str:
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
layout = data.get("layout", {})
|
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))
|
rows = int(layout.get("rows", 2))
|
||||||
cols = int(layout.get("cols", 4))
|
cols = int(layout.get("cols", 4))
|
||||||
lines.append("[layout]")
|
|
||||||
lines.append(f"rows = {rows}")
|
lines.append(f"rows = {rows}")
|
||||||
lines.append(f"cols = {cols}")
|
lines.append(f"cols = {cols}")
|
||||||
lines.append(f"zone_ids = {_format_string_array(zone_ids)}")
|
lines.append(f"zone_ids = {_format_string_array(zone_ids)}")
|
||||||
@@ -139,6 +175,9 @@ def format_config_document(data: dict[str, Any]) -> str:
|
|||||||
continue
|
continue
|
||||||
lines.append("[[zones]]")
|
lines.append("[[zones]]")
|
||||||
lines.append(f'id = "{_escape(zone_id)}"')
|
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(f"polygon = {_format_points(polygon)}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -156,6 +195,17 @@ def format_config_document(data: dict[str, Any]) -> str:
|
|||||||
return "\n".join(lines)
|
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, ...]:
|
def _zone_ids_from_rows_cols(layout: dict[str, Any]) -> tuple[str, ...]:
|
||||||
rows = int(layout.get("rows", 0))
|
rows = int(layout.get("rows", 0))
|
||||||
cols = int(layout.get("cols", 0))
|
cols = int(layout.get("cols", 0))
|
||||||
@@ -164,6 +214,108 @@ def _zone_ids_from_rows_cols(layout: dict[str, Any]) -> tuple[str, ...]:
|
|||||||
return tuple(f"r{row}c{col}" for row in range(1, rows + 1) for col in range(1, cols + 1))
|
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]]:
|
def _normalize_points(value: Any) -> list[list[float]]:
|
||||||
points: list[list[float]] = []
|
points: list[list[float]] = []
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
|
|||||||
@@ -18,9 +18,13 @@ class BatchEngine:
|
|||||||
def process(self, observation: Observation) -> list[dict[str, Any]]:
|
def process(self, observation: Observation) -> list[dict[str, Any]]:
|
||||||
events: list[dict[str, Any]] = []
|
events: list[dict[str, Any]] = []
|
||||||
zone_counts = self._normalized_counts(observation.zone_counts)
|
zone_counts = self._normalized_counts(observation.zone_counts)
|
||||||
|
previous_zone_counts = dict(self._zone_counts)
|
||||||
|
remaining_trash_deposits = observation.trash_deposit_count
|
||||||
|
|
||||||
events.extend(self._expire_pending_disposal(observation.ts))
|
events.extend(self._expire_pending_disposal(observation.ts))
|
||||||
events.extend(self._apply_trash_deposits(observation.ts, observation.trash_deposit_count))
|
trash_events = self._apply_trash_deposits(observation.ts, remaining_trash_deposits)
|
||||||
|
remaining_trash_deposits = max(0, remaining_trash_deposits - len(trash_events))
|
||||||
|
events.extend(trash_events)
|
||||||
|
|
||||||
appeared_zones = [
|
appeared_zones = [
|
||||||
zone_id
|
zone_id
|
||||||
@@ -30,6 +34,9 @@ class BatchEngine:
|
|||||||
if appeared_zones and self.pending_disposal:
|
if appeared_zones and self.pending_disposal:
|
||||||
events.extend(self._mark_pending_as_returned(observation.ts, appeared_zones))
|
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():
|
for zone_id, new_count in zone_counts.items():
|
||||||
previous_count = self._zone_counts.get(zone_id, 0)
|
previous_count = self._zone_counts.get(zone_id, 0)
|
||||||
if previous_count == 0 and new_count > 0:
|
if previous_count == 0 and new_count > 0:
|
||||||
@@ -59,6 +66,11 @@ class BatchEngine:
|
|||||||
|
|
||||||
self._zone_counts[zone_id] = new_count
|
self._zone_counts[zone_id] = new_count
|
||||||
|
|
||||||
|
newly_pending_count = max(0, len(self.pending_disposal) - pending_count_before_zone_transitions)
|
||||||
|
trash_deposits_to_apply = remaining_trash_deposits
|
||||||
|
if remaining_trash_deposits > 0 and newly_pending_count > 1:
|
||||||
|
trash_deposits_to_apply = max(remaining_trash_deposits, newly_pending_count)
|
||||||
|
events.extend(self._apply_trash_deposits(observation.ts, trash_deposits_to_apply))
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def _normalized_counts(self, incoming: dict[str, int]) -> dict[str, int]:
|
def _normalized_counts(self, incoming: dict[str, int]) -> dict[str, int]:
|
||||||
@@ -72,6 +84,59 @@ class BatchEngine:
|
|||||||
self._next_batch_index += 1
|
self._next_batch_index += 1
|
||||||
return batch_id
|
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]:
|
def _start_batch(self, zone_id: str, count: int, when: datetime) -> dict[str, Any]:
|
||||||
batch = Batch(
|
batch = Batch(
|
||||||
batch_id=self._next_batch_id(),
|
batch_id=self._next_batch_id(),
|
||||||
@@ -90,16 +155,37 @@ class BatchEngine:
|
|||||||
batch.dwell_seconds = batch.current_dwell_seconds(when)
|
batch.dwell_seconds = batch.current_dwell_seconds(when)
|
||||||
batch.ended_at = 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.state = "pending_disposal"
|
||||||
batch.pending_since = when
|
batch.pending_since = when
|
||||||
batch.disposal_deadline = when + self.settings.trash_confirmation_window
|
batch.disposal_deadline = when + self.settings.trash_confirmation_window
|
||||||
self.pending_disposal.append(batch)
|
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"
|
batch.state = "consumed"
|
||||||
self.closed_batches.append(batch)
|
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(
|
def _mark_mixed_batch(
|
||||||
self,
|
self,
|
||||||
@@ -149,7 +235,7 @@ class BatchEngine:
|
|||||||
batch = self.pending_disposal.pop(0)
|
batch = self.pending_disposal.pop(0)
|
||||||
batch.state = "discarded"
|
batch.state = "discarded"
|
||||||
self.closed_batches.append(batch)
|
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
|
deposit_count -= 1
|
||||||
return events
|
return events
|
||||||
|
|
||||||
@@ -158,15 +244,16 @@ class BatchEngine:
|
|||||||
still_pending: list[Batch] = []
|
still_pending: list[Batch] = []
|
||||||
for batch in self.pending_disposal:
|
for batch in self.pending_disposal:
|
||||||
if batch.disposal_deadline is not None and when > batch.disposal_deadline:
|
if batch.disposal_deadline is not None and when > batch.disposal_deadline:
|
||||||
batch.state = "violation"
|
batch.state = "warning"
|
||||||
batch.violation_reasons.add("missing_disposal")
|
batch.violation_reasons.add("missing_disposal")
|
||||||
self.closed_batches.append(batch)
|
self.closed_batches.append(batch)
|
||||||
events.append(
|
events.append(
|
||||||
self._event(
|
self._event(
|
||||||
"missing_disposal_violation",
|
"warning_escalated",
|
||||||
when,
|
when,
|
||||||
batch,
|
batch,
|
||||||
reason="trash_deposit_not_observed_before_deadline",
|
severity="warning",
|
||||||
|
reason="alarmed_batch_removed_without_trash_deposit",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -177,14 +264,22 @@ class BatchEngine:
|
|||||||
def _event(self, event_name: str, when: datetime, batch: Batch, **extra: Any) -> dict[str, Any]:
|
def _event(self, event_name: str, when: datetime, batch: Batch, **extra: Any) -> dict[str, Any]:
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"event": event_name,
|
"event": event_name,
|
||||||
|
"severity": self._event_severity(event_name),
|
||||||
"ts": when.isoformat(),
|
"ts": when.isoformat(),
|
||||||
"camera_id": self.settings.camera_id,
|
"camera_id": self.settings.camera_id,
|
||||||
"zone_id": batch.zone_id,
|
"zone_id": batch.zone_id,
|
||||||
|
"zone_label": self._zone_label(batch.zone_id),
|
||||||
"batch_id": batch.batch_id,
|
"batch_id": batch.batch_id,
|
||||||
"state": batch.state,
|
"state": batch.state,
|
||||||
"started_at": batch.started_at.isoformat(),
|
"started_at": batch.started_at.isoformat(),
|
||||||
"dwell_seconds": batch.current_dwell_seconds(when),
|
"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:
|
if batch.ended_at is not None:
|
||||||
payload["ended_at"] = batch.ended_at.isoformat()
|
payload["ended_at"] = batch.ended_at.isoformat()
|
||||||
if batch.disposal_deadline is not None:
|
if batch.disposal_deadline is not None:
|
||||||
@@ -193,3 +288,41 @@ class BatchEngine:
|
|||||||
payload["violation_reasons"] = sorted(batch.violation_reasons)
|
payload["violation_reasons"] = sorted(batch.violation_reasons)
|
||||||
payload.update(extra)
|
payload.update(extra)
|
||||||
return payload
|
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
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ from cold_display_guard.config import load_config_document, load_settings, resol
|
|||||||
from cold_display_guard.engine import BatchEngine
|
from cold_display_guard.engine import BatchEngine
|
||||||
from cold_display_guard.frame_source import FrameCaptureError, RTSPFrameSource
|
from cold_display_guard.frame_source import FrameCaptureError, RTSPFrameSource
|
||||||
from cold_display_guard.models import Observation
|
from cold_display_guard.models import Observation
|
||||||
from cold_display_guard.vision import ZoneOccupancyDetector, load_regions, load_runtime_vision_settings
|
from cold_display_guard.vision import (
|
||||||
|
RegionMetrics,
|
||||||
|
ZoneOccupancyDetector,
|
||||||
|
load_regions,
|
||||||
|
load_runtime_vision_settings,
|
||||||
|
metrics_indicate_occupied,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -57,8 +63,15 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
|
|||||||
height=frame_height,
|
height=frame_height,
|
||||||
timeout_seconds=capture_timeout_seconds,
|
timeout_seconds=capture_timeout_seconds,
|
||||||
)
|
)
|
||||||
detector = ZoneOccupancyDetector(regions, trash_region, load_runtime_vision_settings(config))
|
vision_settings = load_runtime_vision_settings(config)
|
||||||
|
detector = ZoneOccupancyDetector(regions, trash_region, vision_settings)
|
||||||
engine = BatchEngine(settings)
|
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)
|
event_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
diagnostics_path.parent.mkdir(parents=True, exist_ok=True)
|
diagnostics_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -118,5 +131,88 @@ def append_jsonl(path: Path, payloads: list[dict]) -> None:
|
|||||||
handle.write("\n")
|
handle.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def restore_runtime_state(diagnostics_path: Path, config: dict) -> tuple[dict[str, RegionMetrics], dict[str, int]]:
|
||||||
|
latest = load_jsonl_tail(diagnostics_path, 1)
|
||||||
|
if not latest:
|
||||||
|
return {}, {}
|
||||||
|
item = latest[-1]
|
||||||
|
diagnostics = item.get("diagnostics")
|
||||||
|
if not isinstance(diagnostics, dict):
|
||||||
|
return {}, latest_zone_counts_from_item(item)
|
||||||
|
zones = diagnostics.get("zones")
|
||||||
|
if not isinstance(zones, dict):
|
||||||
|
return {}, latest_zone_counts_from_item(item)
|
||||||
|
|
||||||
|
settings = load_runtime_vision_settings(config)
|
||||||
|
baselines: dict[str, RegionMetrics] = {}
|
||||||
|
zone_counts: dict[str, int] = {}
|
||||||
|
for zone_id, metrics in zones.items():
|
||||||
|
if not isinstance(metrics, dict):
|
||||||
|
continue
|
||||||
|
region_id = str(zone_id)
|
||||||
|
baseline_mean = numeric_metric(metrics.get("baseline_mean_luma"))
|
||||||
|
baseline_texture = numeric_metric(metrics.get("baseline_texture"))
|
||||||
|
baseline_dark_fraction = numeric_metric(metrics.get("baseline_dark_fraction")) or 0.0
|
||||||
|
baseline_bright_fraction = numeric_metric(metrics.get("baseline_bright_fraction")) or 0.0
|
||||||
|
if baseline_mean is not None and baseline_texture is not None:
|
||||||
|
baselines[region_id] = RegionMetrics(
|
||||||
|
mean_luma=baseline_mean,
|
||||||
|
texture=baseline_texture,
|
||||||
|
sample_count=1,
|
||||||
|
dark_fraction=baseline_dark_fraction,
|
||||||
|
bright_fraction=baseline_bright_fraction,
|
||||||
|
)
|
||||||
|
|
||||||
|
stable_occupied = metrics.get("occupied")
|
||||||
|
if isinstance(stable_occupied, bool):
|
||||||
|
zone_counts[region_id] = 1 if stable_occupied else 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
mean_delta = numeric_metric(metrics.get("mean_delta"))
|
||||||
|
texture_delta = numeric_metric(metrics.get("texture_delta"))
|
||||||
|
if mean_delta is None or texture_delta is None:
|
||||||
|
continue
|
||||||
|
dark_fraction = numeric_metric(metrics.get("dark_fraction"))
|
||||||
|
bright_fraction = numeric_metric(metrics.get("bright_fraction")) or 0.0
|
||||||
|
occupied = metrics_indicate_occupied(
|
||||||
|
settings,
|
||||||
|
mean_delta,
|
||||||
|
texture_delta,
|
||||||
|
dark_fraction=dark_fraction,
|
||||||
|
baseline_dark_fraction=baseline_dark_fraction,
|
||||||
|
bright_fraction=bright_fraction,
|
||||||
|
)
|
||||||
|
zone_counts[region_id] = 1 if occupied else 0
|
||||||
|
return baselines, zone_counts or latest_zone_counts_from_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_zone_counts_from_item(item: dict) -> dict[str, int]:
|
||||||
|
zone_counts = item.get("zone_counts")
|
||||||
|
if not isinstance(zone_counts, dict):
|
||||||
|
return {}
|
||||||
|
return {str(zone_id): max(0, int(count)) for zone_id, count in zone_counts.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def numeric_metric(value: object) -> float | None:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_jsonl_tail(path: Path, limit: int) -> list[dict]:
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
items: list[dict] = []
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines()[-limit:]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
items.append(payload)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from cold_display_guard.config import (
|
|||||||
resolve_project_root,
|
resolve_project_root,
|
||||||
save_config_document,
|
save_config_document,
|
||||||
)
|
)
|
||||||
|
from cold_display_guard.vision import load_runtime_vision_settings, metrics_indicate_occupied
|
||||||
|
|
||||||
|
|
||||||
PROJECT_TYPE = "cold_display_guard"
|
PROJECT_TYPE = "cold_display_guard"
|
||||||
@@ -119,9 +120,17 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
|
|||||||
if not isinstance(zones, list):
|
if not isinstance(zones, list):
|
||||||
self._send_json({"error": "zones must be a list"}, HTTPStatus.BAD_REQUEST)
|
self._send_json({"error": "zones must be a list"}, HTTPStatus.BAD_REQUEST)
|
||||||
return
|
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
|
trash_roi = trash.get("roi") if isinstance(trash, dict) else None
|
||||||
data = load_config_document(ctx.config_path)
|
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)
|
save_config_document(ctx.config_path, merged)
|
||||||
self._send_json(config_payload(ctx))
|
self._send_json(config_payload(ctx))
|
||||||
|
|
||||||
@@ -238,24 +247,40 @@ def config_payload(ctx: ManageContext) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def build_summary(ctx: ManageContext) -> dict[str, Any]:
|
def build_summary(ctx: ManageContext) -> dict[str, Any]:
|
||||||
|
config = load_config_document(ctx.config_path)
|
||||||
events = load_events(ctx, MAX_EVENT_LINES)
|
events = load_events(ctx, MAX_EVENT_LINES)
|
||||||
diagnostics = load_diagnostics(ctx, MAX_EVENT_LINES)
|
diagnostics = load_diagnostics(ctx, MAX_EVENT_LINES)
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
last_event_time = ""
|
last_event_time = ""
|
||||||
latest_alert = ""
|
latest_alert = ""
|
||||||
|
alert_count = 0
|
||||||
|
warning_count = 0
|
||||||
|
violation_count = 0
|
||||||
for event in events:
|
for event in events:
|
||||||
event_name = str(event.get("event", "unknown"))
|
event_name = str(event.get("event", "unknown"))
|
||||||
|
severity = str(event.get("severity", "")).lower()
|
||||||
counts[event_name] = counts.get(event_name, 0) + 1
|
counts[event_name] = counts.get(event_name, 0) + 1
|
||||||
ts = str(event.get("ts", ""))
|
ts = str(event.get("ts", ""))
|
||||||
if ts:
|
if ts:
|
||||||
last_event_time = 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
|
latest_alert = ts
|
||||||
|
|
||||||
active_alert_count = sum(counts.get(name, 0) for name in counts if name.endswith("_violation"))
|
|
||||||
headline = "No batch events yet"
|
headline = "No batch events yet"
|
||||||
if events:
|
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 {
|
return {
|
||||||
"result_type": PROJECT_TYPE,
|
"result_type": PROJECT_TYPE,
|
||||||
@@ -264,12 +289,14 @@ def build_summary(ctx: ManageContext) -> dict[str, Any]:
|
|||||||
"metrics": {
|
"metrics": {
|
||||||
"event_counts": counts,
|
"event_counts": counts,
|
||||||
"event_count": len(events),
|
"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,
|
"latest_alert_time": latest_alert,
|
||||||
"events_path": str(event_sink_path(ctx)),
|
"events_path": str(event_sink_path(ctx)),
|
||||||
"diagnostics_path": str(diagnostics_path(ctx)),
|
"diagnostics_path": str(diagnostics_path(ctx)),
|
||||||
"diagnostics_count": len(diagnostics),
|
"diagnostics_count": len(diagnostics),
|
||||||
"latest_zone_counts": latest_zone_counts(diagnostics),
|
"latest_zone_counts": latest_zone_counts(diagnostics, config),
|
||||||
"baseline_ready": latest_baseline_ready(diagnostics),
|
"baseline_ready": latest_baseline_ready(diagnostics),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -318,14 +345,83 @@ def diagnostics_path(ctx: ManageContext, data: dict[str, Any] | None = None) ->
|
|||||||
return path.resolve()
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
def latest_zone_counts(diagnostics: list[dict[str, Any]]) -> dict[str, int]:
|
def latest_zone_counts(diagnostics: list[dict[str, Any]], config: dict[str, Any] | None = None) -> dict[str, int]:
|
||||||
for item in reversed(diagnostics):
|
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")
|
zone_counts = item.get("zone_counts")
|
||||||
if isinstance(zone_counts, dict):
|
if isinstance(zone_counts, dict):
|
||||||
return {str(key): int(value) for key, value in zone_counts.items()}
|
return {str(key): int(value) for key, value in zone_counts.items()}
|
||||||
return {}
|
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:
|
def latest_baseline_ready(diagnostics: list[dict[str, Any]]) -> bool:
|
||||||
for item in reversed(diagnostics):
|
for item in reversed(diagnostics):
|
||||||
diagnostics_payload = item.get("diagnostics")
|
diagnostics_payload = item.get("diagnostics")
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class Batch:
|
|||||||
started_at: datetime
|
started_at: datetime
|
||||||
last_count: int
|
last_count: int
|
||||||
state: str = "active"
|
state: str = "active"
|
||||||
|
alerted_at: datetime | None = None
|
||||||
ended_at: datetime | None = None
|
ended_at: datetime | None = None
|
||||||
pending_since: datetime | None = None
|
pending_since: datetime | None = None
|
||||||
disposal_deadline: datetime | None = None
|
disposal_deadline: datetime | None = None
|
||||||
|
|||||||
@@ -25,11 +25,22 @@ class Region:
|
|||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class RuntimeVisionSettings:
|
class RuntimeVisionSettings:
|
||||||
baseline_frames: int = 3
|
baseline_frames: int = 3
|
||||||
sample_stride_pixels: int = 8
|
sample_stride_pixels: int = 4
|
||||||
occupancy_mean_delta: float = 24.0
|
occupancy_mean_delta: float = 55.0
|
||||||
occupancy_texture_delta: float = 18.0
|
occupancy_texture_delta: float = 18.0
|
||||||
|
occupancy_dark_luma_threshold: float = 80.0
|
||||||
|
occupancy_dark_fraction: float = 0.06
|
||||||
|
occupancy_texture_dark_fraction: float = 0.04
|
||||||
|
occupancy_bright_luma_threshold: float = 220.0
|
||||||
|
occupancy_bright_reflection_fraction: float = 0.18
|
||||||
|
occupancy_reflection_dark_fraction: float = 0.10
|
||||||
|
occupancy_reflection_bright_dark_ratio: float = 2.0
|
||||||
|
occupancy_confirm_frames: int = 2
|
||||||
|
empty_confirm_frames: int = 2
|
||||||
trash_motion_delta: float = 18.0
|
trash_motion_delta: float = 18.0
|
||||||
trash_motion_cooldown_seconds: int = 8
|
trash_sustained_motion_delta: float = 8.0
|
||||||
|
trash_sustained_motion_frames: int = 2
|
||||||
|
trash_motion_cooldown_seconds: int = 3
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -37,6 +48,8 @@ class RegionMetrics:
|
|||||||
mean_luma: float
|
mean_luma: float
|
||||||
texture: float
|
texture: float
|
||||||
sample_count: int
|
sample_count: int
|
||||||
|
dark_fraction: float = 0.0
|
||||||
|
bright_fraction: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class ZoneOccupancyDetector:
|
class ZoneOccupancyDetector:
|
||||||
@@ -51,13 +64,26 @@ class ZoneOccupancyDetector:
|
|||||||
self.settings = settings or RuntimeVisionSettings()
|
self.settings = settings or RuntimeVisionSettings()
|
||||||
self._baseline: dict[str, RegionMetrics] = {}
|
self._baseline: dict[str, RegionMetrics] = {}
|
||||||
self._baseline_samples: dict[str, list[RegionMetrics]] = {region.region_id: [] for region in regions}
|
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:
|
if trash_region is not None:
|
||||||
self._baseline_samples[trash_region.region_id] = []
|
self._baseline_samples[trash_region.region_id] = []
|
||||||
self._previous_trash_metrics: RegionMetrics | None = None
|
self._previous_trash_metrics: RegionMetrics | None = None
|
||||||
self._last_trash_motion_at: datetime | 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]]:
|
def observe(self, frame: Frame, when: datetime) -> tuple[dict[str, int], int, dict[str, Any]]:
|
||||||
metrics_by_region = {region.region_id: region_metrics(frame, region, self.settings.sample_stride_pixels) for region in self.regions}
|
metrics_by_region = {
|
||||||
|
region.region_id: region_metrics(
|
||||||
|
frame,
|
||||||
|
region,
|
||||||
|
self.settings.sample_stride_pixels,
|
||||||
|
self.settings.occupancy_dark_luma_threshold,
|
||||||
|
self.settings.occupancy_bright_luma_threshold,
|
||||||
|
)
|
||||||
|
for region in self.regions
|
||||||
|
}
|
||||||
self._update_baseline(metrics_by_region)
|
self._update_baseline(metrics_by_region)
|
||||||
|
|
||||||
zone_counts: dict[str, int] = {}
|
zone_counts: dict[str, int] = {}
|
||||||
@@ -69,10 +95,8 @@ class ZoneOccupancyDetector:
|
|||||||
if baseline is not None:
|
if baseline is not None:
|
||||||
mean_delta = abs(metrics.mean_luma - baseline.mean_luma)
|
mean_delta = abs(metrics.mean_luma - baseline.mean_luma)
|
||||||
texture_delta = metrics.texture - baseline.texture
|
texture_delta = metrics.texture - baseline.texture
|
||||||
occupied = (
|
raw_occupied = self._raw_occupied(metrics, baseline, mean_delta, texture_delta)
|
||||||
mean_delta >= self.settings.occupancy_mean_delta
|
occupied = self._confirmed_occupancy(region.region_id, raw_occupied)
|
||||||
or texture_delta >= self.settings.occupancy_texture_delta
|
|
||||||
)
|
|
||||||
diagnostics["zones"][region.region_id] = {
|
diagnostics["zones"][region.region_id] = {
|
||||||
"mean_luma": round(metrics.mean_luma, 3),
|
"mean_luma": round(metrics.mean_luma, 3),
|
||||||
"baseline_mean_luma": round(baseline.mean_luma, 3),
|
"baseline_mean_luma": round(baseline.mean_luma, 3),
|
||||||
@@ -80,7 +104,15 @@ class ZoneOccupancyDetector:
|
|||||||
"texture": round(metrics.texture, 3),
|
"texture": round(metrics.texture, 3),
|
||||||
"baseline_texture": round(baseline.texture, 3),
|
"baseline_texture": round(baseline.texture, 3),
|
||||||
"texture_delta": round(texture_delta, 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": occupied,
|
||||||
|
"occupied_streak": self._occupied_streaks[region.region_id],
|
||||||
|
"empty_streak": self._empty_streaks[region.region_id],
|
||||||
}
|
}
|
||||||
zone_counts[region.region_id] = 1 if occupied else 0
|
zone_counts[region.region_id] = 1 if occupied else 0
|
||||||
|
|
||||||
@@ -91,6 +123,37 @@ class ZoneOccupancyDetector:
|
|||||||
def baseline_ready(self) -> bool:
|
def baseline_ready(self) -> bool:
|
||||||
return all(region.region_id in self._baseline for region in self.regions)
|
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:
|
def _update_baseline(self, metrics_by_region: dict[str, RegionMetrics]) -> None:
|
||||||
for region_id, metrics in metrics_by_region.items():
|
for region_id, metrics in metrics_by_region.items():
|
||||||
if region_id in self._baseline:
|
if region_id in self._baseline:
|
||||||
@@ -100,6 +163,19 @@ class ZoneOccupancyDetector:
|
|||||||
if len(samples) >= self.settings.baseline_frames:
|
if len(samples) >= self.settings.baseline_frames:
|
||||||
self._baseline[region_id] = average_metrics(samples)
|
self._baseline[region_id] = average_metrics(samples)
|
||||||
|
|
||||||
|
def _confirmed_occupancy(self, region_id: str, raw_occupied: bool) -> bool:
|
||||||
|
if raw_occupied:
|
||||||
|
self._occupied_streaks[region_id] = self._occupied_streaks.get(region_id, 0) + 1
|
||||||
|
self._empty_streaks[region_id] = 0
|
||||||
|
if self._occupied_streaks[region_id] >= self.settings.occupancy_confirm_frames:
|
||||||
|
self._stable_occupancy[region_id] = True
|
||||||
|
else:
|
||||||
|
self._empty_streaks[region_id] = self._empty_streaks.get(region_id, 0) + 1
|
||||||
|
self._occupied_streaks[region_id] = 0
|
||||||
|
if self._empty_streaks[region_id] >= self.settings.empty_confirm_frames:
|
||||||
|
self._stable_occupancy[region_id] = False
|
||||||
|
return self._stable_occupancy.get(region_id, False)
|
||||||
|
|
||||||
def _trash_deposit_count(self, frame: Frame, when: datetime, diagnostics: dict[str, Any]) -> int:
|
def _trash_deposit_count(self, frame: Frame, when: datetime, diagnostics: dict[str, Any]) -> int:
|
||||||
if self.trash_region is None:
|
if self.trash_region is None:
|
||||||
return 0
|
return 0
|
||||||
@@ -108,17 +184,29 @@ class ZoneOccupancyDetector:
|
|||||||
previous = self._previous_trash_metrics
|
previous = self._previous_trash_metrics
|
||||||
self._previous_trash_metrics = metrics
|
self._previous_trash_metrics = metrics
|
||||||
if previous is None:
|
if previous is None:
|
||||||
diagnostics["trash"] = {"motion_delta": 0.0, "deposit": False}
|
diagnostics["trash"] = {"motion_delta": 0.0, "motion_streak": 0, "deposit": False}
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
motion_delta = abs(metrics.mean_luma - previous.mean_luma) + abs(metrics.texture - previous.texture)
|
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)
|
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
|
in_cooldown = self._last_trash_motion_at is not None and when - self._last_trash_motion_at < cooldown
|
||||||
deposit = motion_delta >= self.settings.trash_motion_delta and not in_cooldown
|
strong_motion = motion_delta >= self.settings.trash_motion_delta
|
||||||
|
sustained_motion = self._trash_motion_streak >= self.settings.trash_sustained_motion_frames
|
||||||
|
deposit = (strong_motion or sustained_motion) and not in_cooldown
|
||||||
|
motion_streak = self._trash_motion_streak
|
||||||
if deposit:
|
if deposit:
|
||||||
self._last_trash_motion_at = when
|
self._last_trash_motion_at = when
|
||||||
|
self._trash_motion_streak = 0
|
||||||
diagnostics["trash"] = {
|
diagnostics["trash"] = {
|
||||||
"motion_delta": round(motion_delta, 3),
|
"motion_delta": round(motion_delta, 3),
|
||||||
|
"motion_streak": motion_streak,
|
||||||
|
"strong_motion": strong_motion,
|
||||||
|
"sustained_motion": sustained_motion,
|
||||||
|
"in_cooldown": in_cooldown,
|
||||||
"deposit": deposit,
|
"deposit": deposit,
|
||||||
}
|
}
|
||||||
return 1 if deposit else 0
|
return 1 if deposit else 0
|
||||||
@@ -143,11 +231,22 @@ def load_runtime_vision_settings(config: dict[str, Any]) -> RuntimeVisionSetting
|
|||||||
runtime = config.get("runtime", {})
|
runtime = config.get("runtime", {})
|
||||||
return RuntimeVisionSettings(
|
return RuntimeVisionSettings(
|
||||||
baseline_frames=max(1, int(runtime.get("baseline_frames", 3))),
|
baseline_frames=max(1, int(runtime.get("baseline_frames", 3))),
|
||||||
sample_stride_pixels=max(1, int(runtime.get("sample_stride_pixels", 8))),
|
sample_stride_pixels=max(1, int(runtime.get("sample_stride_pixels", 4))),
|
||||||
occupancy_mean_delta=float(runtime.get("occupancy_mean_delta", 24.0)),
|
occupancy_mean_delta=float(runtime.get("occupancy_mean_delta", 55.0)),
|
||||||
occupancy_texture_delta=float(runtime.get("occupancy_texture_delta", 18.0)),
|
occupancy_texture_delta=float(runtime.get("occupancy_texture_delta", 18.0)),
|
||||||
|
occupancy_dark_luma_threshold=float(runtime.get("occupancy_dark_luma_threshold", 80.0)),
|
||||||
|
occupancy_dark_fraction=float(runtime.get("occupancy_dark_fraction", 0.06)),
|
||||||
|
occupancy_texture_dark_fraction=float(runtime.get("occupancy_texture_dark_fraction", 0.04)),
|
||||||
|
occupancy_bright_luma_threshold=float(runtime.get("occupancy_bright_luma_threshold", 220.0)),
|
||||||
|
occupancy_bright_reflection_fraction=float(runtime.get("occupancy_bright_reflection_fraction", 0.18)),
|
||||||
|
occupancy_reflection_dark_fraction=float(runtime.get("occupancy_reflection_dark_fraction", 0.10)),
|
||||||
|
occupancy_reflection_bright_dark_ratio=float(runtime.get("occupancy_reflection_bright_dark_ratio", 2.0)),
|
||||||
|
occupancy_confirm_frames=max(1, int(runtime.get("occupancy_confirm_frames", 2))),
|
||||||
|
empty_confirm_frames=max(1, int(runtime.get("empty_confirm_frames", 2))),
|
||||||
trash_motion_delta=float(runtime.get("trash_motion_delta", 18.0)),
|
trash_motion_delta=float(runtime.get("trash_motion_delta", 18.0)),
|
||||||
trash_motion_cooldown_seconds=max(0, int(runtime.get("trash_motion_cooldown_seconds", 8))),
|
trash_sustained_motion_delta=float(runtime.get("trash_sustained_motion_delta", 8.0)),
|
||||||
|
trash_sustained_motion_frames=max(1, int(runtime.get("trash_sustained_motion_frames", 2))),
|
||||||
|
trash_motion_cooldown_seconds=max(0, int(runtime.get("trash_motion_cooldown_seconds", 3))),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +261,13 @@ def normalize_polygon(value: Any) -> tuple[tuple[float, float], ...]:
|
|||||||
return tuple(points)
|
return tuple(points)
|
||||||
|
|
||||||
|
|
||||||
def region_metrics(frame: Frame, region: Region, stride: int) -> RegionMetrics:
|
def region_metrics(
|
||||||
|
frame: Frame,
|
||||||
|
region: Region,
|
||||||
|
stride: int,
|
||||||
|
dark_luma_threshold: float = 80.0,
|
||||||
|
bright_luma_threshold: float = 220.0,
|
||||||
|
) -> RegionMetrics:
|
||||||
xs = [point[0] for point in region.polygon]
|
xs = [point[0] for point in region.polygon]
|
||||||
ys = [point[1] for point in region.polygon]
|
ys = [point[1] for point in region.polygon]
|
||||||
min_x = max(0, int(min(xs) * frame.width))
|
min_x = max(0, int(min(xs) * frame.width))
|
||||||
@@ -184,7 +289,15 @@ def region_metrics(frame: Frame, region: Region, stride: int) -> RegionMetrics:
|
|||||||
return RegionMetrics(mean_luma=0.0, texture=0.0, sample_count=0)
|
return RegionMetrics(mean_luma=0.0, texture=0.0, sample_count=0)
|
||||||
mean = sum(values) / len(values)
|
mean = sum(values) / len(values)
|
||||||
variance = sum((value - mean) ** 2 for value in values) / len(values)
|
variance = sum((value - mean) ** 2 for value in values) / len(values)
|
||||||
return RegionMetrics(mean_luma=mean, texture=variance ** 0.5, sample_count=len(values))
|
dark_fraction = sum(value < dark_luma_threshold for value in values) / len(values)
|
||||||
|
bright_fraction = sum(value > bright_luma_threshold for value in values) / len(values)
|
||||||
|
return RegionMetrics(
|
||||||
|
mean_luma=mean,
|
||||||
|
texture=variance ** 0.5,
|
||||||
|
sample_count=len(values),
|
||||||
|
dark_fraction=dark_fraction,
|
||||||
|
bright_fraction=bright_fraction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def average_metrics(samples: list[RegionMetrics]) -> RegionMetrics:
|
def average_metrics(samples: list[RegionMetrics]) -> RegionMetrics:
|
||||||
@@ -192,6 +305,42 @@ def average_metrics(samples: list[RegionMetrics]) -> RegionMetrics:
|
|||||||
mean_luma=sum(item.mean_luma for item in samples) / len(samples),
|
mean_luma=sum(item.mean_luma for item in samples) / len(samples),
|
||||||
texture=sum(item.texture 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),
|
sample_count=min(item.sample_count for item in samples),
|
||||||
|
dark_fraction=sum(item.dark_fraction for item in samples) / len(samples),
|
||||||
|
bright_fraction=sum(item.bright_fraction for item in samples) / len(samples),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def metrics_indicate_occupied(
|
||||||
|
settings: RuntimeVisionSettings,
|
||||||
|
mean_delta: float,
|
||||||
|
texture_delta: float,
|
||||||
|
dark_fraction: float | None = None,
|
||||||
|
baseline_dark_fraction: float = 0.0,
|
||||||
|
bright_fraction: float = 0.0,
|
||||||
|
) -> bool:
|
||||||
|
if dark_fraction is None:
|
||||||
|
return mean_delta >= settings.occupancy_mean_delta or texture_delta >= settings.occupancy_texture_delta
|
||||||
|
|
||||||
|
dark_delta = dark_fraction - baseline_dark_fraction
|
||||||
|
bright_reflection = is_bright_reflection(settings, dark_delta, bright_fraction)
|
||||||
|
dark_occupied = dark_delta >= settings.occupancy_dark_fraction and not bright_reflection
|
||||||
|
mean_occupied = mean_delta >= settings.occupancy_mean_delta and not bright_reflection
|
||||||
|
texture_occupied = (
|
||||||
|
texture_delta >= settings.occupancy_texture_delta
|
||||||
|
and dark_delta >= settings.occupancy_texture_dark_fraction
|
||||||
|
and not bright_reflection
|
||||||
|
)
|
||||||
|
return dark_occupied or mean_occupied or texture_occupied
|
||||||
|
|
||||||
|
|
||||||
|
def is_bright_reflection(settings: RuntimeVisionSettings, dark_delta: float, bright_fraction: float) -> bool:
|
||||||
|
if bright_fraction < settings.occupancy_bright_reflection_fraction:
|
||||||
|
return False
|
||||||
|
if dark_delta < settings.occupancy_texture_dark_fraction:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
dark_delta < settings.occupancy_reflection_dark_fraction
|
||||||
|
and bright_fraction >= dark_delta * settings.occupancy_reflection_bright_dark_ratio
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
55
task_plan.md
55
task_plan.md
@@ -2,20 +2,20 @@
|
|||||||
|
|
||||||
## Goal
|
## 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
|
## Confirmed Decisions
|
||||||
|
|
||||||
- The trash bin is visible in the same camera frame.
|
- 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.
|
- A zone may contain multiple food items.
|
||||||
- Items in the same zone are treated as one batch.
|
- 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.
|
- 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`.
|
- 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`. |
|
| Create project skeleton | complete | Built under `~/Code/cold_display_guard`. |
|
||||||
| Write design and implementation plan | complete | Saved in `docs/plans/`. |
|
| Write design and implementation plan | complete | Saved in `docs/plans/`. |
|
||||||
@@ -29,3 +29,50 @@ Create an independent git project under `~/Code` for monitoring food batches in
|
|||||||
| Error | Attempt | Resolution |
|
| Error | Attempt | Resolution |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Ended batches reported `0` dwell seconds | First `unittest` run | Calculate dwell seconds before assigning `ended_at`. |
|
| 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 优化改造` 批次内的工作阶段,不代表拆分成独立批次。
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ class CliTests(unittest.TestCase):
|
|||||||
events = [json.loads(line) for line in output.getvalue().splitlines()]
|
events = [json.loads(line) for line in output.getvalue().splitlines()]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[event["event"] for event in events],
|
[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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
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):
|
class ConfigTests(unittest.TestCase):
|
||||||
@@ -33,6 +33,95 @@ cols = 2
|
|||||||
self.assertEqual(settings.trash_confirmation_seconds, 4)
|
self.assertEqual(settings.trash_confirmation_seconds, 4)
|
||||||
self.assertEqual(settings.zone_ids, ("r1c1", "r1c2"))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class BatchEngineTests(unittest.TestCase):
|
|||||||
self.assertEqual([event["event"] for event in events], ["batch_started"])
|
self.assertEqual([event["event"] for event in events], ["batch_started"])
|
||||||
self.assertEqual(events[0]["zone_id"], "r1c1")
|
self.assertEqual(events[0]["zone_id"], "r1c1")
|
||||||
self.assertEqual(events[0]["current_count"], 3)
|
self.assertEqual(events[0]["current_count"], 3)
|
||||||
|
self.assertEqual(events[0]["severity"], "info")
|
||||||
|
|
||||||
def test_consumes_batch_when_removed_before_threshold(self) -> None:
|
def test_consumes_batch_when_removed_before_threshold(self) -> None:
|
||||||
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
||||||
@@ -48,9 +49,29 @@ class BatchEngineTests(unittest.TestCase):
|
|||||||
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
||||||
events = self.engine.process(obs(self.t0 + timedelta(seconds=10), {"r1c1": 0}))
|
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([event["event"] for event in events], ["time_alarm", "batch_pending_disposal"])
|
||||||
self.assertEqual(events[0]["dwell_seconds"], 10)
|
self.assertEqual(events[0]["severity"], "alarm")
|
||||||
self.assertIn("disposal_deadline", events[0])
|
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:
|
def test_trash_deposit_confirms_pending_disposal(self) -> None:
|
||||||
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
||||||
@@ -59,12 +80,13 @@ class BatchEngineTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual([event["event"] for event in events], ["batch_discarded"])
|
self.assertEqual([event["event"] for event in events], ["batch_discarded"])
|
||||||
|
|
||||||
def test_missing_trash_deposit_raises_violation_after_deadline(self) -> None:
|
def test_missing_trash_deposit_escalates_warning_after_deadline(self) -> None:
|
||||||
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
||||||
self.engine.process(obs(self.t0 + timedelta(seconds=11), {"r1c1": 0}))
|
self.engine.process(obs(self.t0 + timedelta(seconds=11), {"r1c1": 0}))
|
||||||
events = self.engine.process(obs(self.t0 + timedelta(seconds=17), {"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"])
|
self.assertEqual(events[0]["violation_reasons"], ["missing_disposal"])
|
||||||
|
|
||||||
def test_adding_food_before_zone_clears_raises_mixed_batch_violation(self) -> None:
|
def test_adding_food_before_zone_clears_raises_mixed_batch_violation(self) -> None:
|
||||||
@@ -72,6 +94,7 @@ class BatchEngineTests(unittest.TestCase):
|
|||||||
events = self.engine.process(obs(self.t0 + timedelta(seconds=1), {"r1c1": 3}))
|
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([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")
|
self.assertEqual(events[0]["reason"], "food_added_before_zone_cleared")
|
||||||
|
|
||||||
def test_count_decrease_keeps_same_batch_active(self) -> None:
|
def test_count_decrease_keeps_same_batch_active(self) -> None:
|
||||||
@@ -93,6 +116,178 @@ class BatchEngineTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(events[0]["appeared_zones"], ["r1c2"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
113
tests/test_main.py
Normal file
113
tests/test_main.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cold_display_guard.main import restore_runtime_state
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeRestoreTests(unittest.TestCase):
|
||||||
|
def test_restore_runtime_state_uses_stable_occupancy_when_raw_metrics_flicker(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
diagnostics_path = Path(tmpdir) / "runtime_diagnostics.jsonl"
|
||||||
|
diagnostics_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ts": "2026-05-29T10:05:26+08:00",
|
||||||
|
"zone_counts": {"2": 1},
|
||||||
|
"diagnostics": {
|
||||||
|
"baseline_ready": True,
|
||||||
|
"zones": {
|
||||||
|
"2": {
|
||||||
|
"baseline_mean_luma": 165.0,
|
||||||
|
"baseline_texture": 16.0,
|
||||||
|
"baseline_dark_fraction": 0.0,
|
||||||
|
"baseline_bright_fraction": 0.0,
|
||||||
|
"mean_delta": 17.077,
|
||||||
|
"texture_delta": 8.819,
|
||||||
|
"dark_fraction": 0.0357,
|
||||||
|
"bright_fraction": 0.0,
|
||||||
|
"raw_occupied": False,
|
||||||
|
"occupied": True,
|
||||||
|
"empty_streak": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
_, zone_counts = restore_runtime_state(
|
||||||
|
diagnostics_path,
|
||||||
|
{
|
||||||
|
"runtime": {
|
||||||
|
"occupancy_mean_delta": 55.0,
|
||||||
|
"occupancy_texture_delta": 18.0,
|
||||||
|
"occupancy_dark_fraction": 0.06,
|
||||||
|
"occupancy_texture_dark_fraction": 0.04,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(zone_counts, {"2": 1})
|
||||||
|
|
||||||
|
def test_restore_runtime_state_uses_dark_fraction_rules(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
diagnostics_path = Path(tmpdir) / "runtime_diagnostics.jsonl"
|
||||||
|
diagnostics_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ts": "2026-05-29T10:00:00+08:00",
|
||||||
|
"zone_counts": {"1": 1, "4": 1},
|
||||||
|
"diagnostics": {
|
||||||
|
"baseline_ready": True,
|
||||||
|
"zones": {
|
||||||
|
"1": {
|
||||||
|
"baseline_mean_luma": 165.0,
|
||||||
|
"baseline_texture": 16.0,
|
||||||
|
"baseline_dark_fraction": 0.0,
|
||||||
|
"baseline_bright_fraction": 0.0,
|
||||||
|
"mean_delta": 40.0,
|
||||||
|
"texture_delta": 18.0,
|
||||||
|
"dark_fraction": 0.10,
|
||||||
|
"bright_fraction": 0.0,
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"baseline_mean_luma": 177.0,
|
||||||
|
"baseline_texture": 9.0,
|
||||||
|
"baseline_dark_fraction": 0.0,
|
||||||
|
"baseline_bright_fraction": 0.0,
|
||||||
|
"mean_delta": 16.0,
|
||||||
|
"texture_delta": 40.0,
|
||||||
|
"dark_fraction": 0.0769,
|
||||||
|
"bright_fraction": 0.3077,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
baselines, zone_counts = restore_runtime_state(
|
||||||
|
diagnostics_path,
|
||||||
|
{
|
||||||
|
"runtime": {
|
||||||
|
"occupancy_mean_delta": 55.0,
|
||||||
|
"occupancy_texture_delta": 18.0,
|
||||||
|
"occupancy_dark_fraction": 0.06,
|
||||||
|
"occupancy_texture_dark_fraction": 0.04,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(zone_counts, {"1": 1, "4": 0})
|
||||||
|
self.assertEqual(baselines["1"].dark_fraction, 0.0)
|
||||||
|
self.assertEqual(baselines["4"].bright_fraction, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -28,6 +28,62 @@ class ManageApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(merged["zones"][1]["id"], "r1c2")
|
self.assertEqual(merged["zones"][1]["id"], "r1c2")
|
||||||
self.assertEqual(merged["trash"]["roi"][0], [0.8, 0.8])
|
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:
|
def test_save_config_document_round_trips_manage_fields(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
path = Path(tmpdir) / "config.toml"
|
path = Path(tmpdir) / "config.toml"
|
||||||
@@ -65,8 +121,9 @@ class ManageApiTests(unittest.TestCase):
|
|||||||
events_path.write_text(
|
events_path.write_text(
|
||||||
"\n".join(
|
"\n".join(
|
||||||
[
|
[
|
||||||
json.dumps({"event": "batch_started", "ts": "2026-04-27T10:00:00+08:00"}),
|
json.dumps({"event": "batch_started", "severity": "info", "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": "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",
|
encoding="utf-8",
|
||||||
@@ -74,8 +131,43 @@ class ManageApiTests(unittest.TestCase):
|
|||||||
|
|
||||||
summary = build_summary(ManageContext(config_path=config_path, project_root=root))
|
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"]["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:
|
def test_summary_reads_runtime_diagnostics(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
@@ -108,6 +200,156 @@ class ManageApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(summary["metrics"]["latest_zone_counts"], {"r1c1": 1})
|
self.assertEqual(summary["metrics"]["latest_zone_counts"], {"r1c1": 1})
|
||||||
self.assertTrue(summary["metrics"]["baseline_ready"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from cold_display_guard.vision import (
|
from cold_display_guard.vision import (
|
||||||
Frame,
|
Frame,
|
||||||
Region,
|
Region,
|
||||||
|
RegionMetrics,
|
||||||
RuntimeVisionSettings,
|
RuntimeVisionSettings,
|
||||||
ZoneOccupancyDetector,
|
ZoneOccupancyDetector,
|
||||||
|
load_runtime_vision_settings,
|
||||||
point_in_polygon,
|
point_in_polygon,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +28,16 @@ def patched_frame(width: int, height: int, base: int, patch: tuple[int, int, int
|
|||||||
return Frame(width=width, height=height, rgb=bytes(pixels))
|
return Frame(width=width, height=height, rgb=bytes(pixels))
|
||||||
|
|
||||||
|
|
||||||
|
def multi_patched_frame(width: int, height: int, base: int, patches: list[tuple[int, int, int, int, int]]) -> Frame:
|
||||||
|
pixels = bytearray(bytes([base, base, base]) * width * height)
|
||||||
|
for x1, y1, x2, y2, value in patches:
|
||||||
|
for y in range(y1, y2):
|
||||||
|
for x in range(x1, x2):
|
||||||
|
offset = (y * width + x) * 3
|
||||||
|
pixels[offset : offset + 3] = bytes([value, value, value])
|
||||||
|
return Frame(width=width, height=height, rgb=bytes(pixels))
|
||||||
|
|
||||||
|
|
||||||
class VisionTests(unittest.TestCase):
|
class VisionTests(unittest.TestCase):
|
||||||
def test_point_in_polygon(self) -> None:
|
def test_point_in_polygon(self) -> None:
|
||||||
polygon = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))
|
polygon = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))
|
||||||
@@ -42,6 +54,8 @@ class VisionTests(unittest.TestCase):
|
|||||||
sample_stride_pixels=4,
|
sample_stride_pixels=4,
|
||||||
occupancy_mean_delta=10,
|
occupancy_mean_delta=10,
|
||||||
occupancy_texture_delta=10,
|
occupancy_texture_delta=10,
|
||||||
|
occupancy_confirm_frames=1,
|
||||||
|
empty_confirm_frames=1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
@@ -67,6 +81,196 @@ class VisionTests(unittest.TestCase):
|
|||||||
self.assertEqual(first_deposit, 0)
|
self.assertEqual(first_deposit, 0)
|
||||||
self.assertEqual(second_deposit, 1)
|
self.assertEqual(second_deposit, 1)
|
||||||
|
|
||||||
|
def test_detector_reports_sustained_trash_motion_below_single_frame_threshold(self) -> None:
|
||||||
|
trash = Region("trash", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[],
|
||||||
|
trash_region=trash,
|
||||||
|
settings=RuntimeVisionSettings(
|
||||||
|
sample_stride_pixels=4,
|
||||||
|
trash_motion_delta=18,
|
||||||
|
trash_sustained_motion_delta=8,
|
||||||
|
trash_sustained_motion_frames=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
_, first_deposit, _ = detector.observe(solid_frame(32, 32, 30), now)
|
||||||
|
_, second_deposit, second_diagnostics = detector.observe(solid_frame(32, 32, 39), now)
|
||||||
|
_, third_deposit, third_diagnostics = detector.observe(solid_frame(32, 32, 48), now)
|
||||||
|
|
||||||
|
self.assertEqual(first_deposit, 0)
|
||||||
|
self.assertEqual(second_deposit, 0)
|
||||||
|
self.assertEqual(second_diagnostics["trash"]["motion_streak"], 1)
|
||||||
|
self.assertEqual(third_deposit, 1)
|
||||||
|
self.assertEqual(third_diagnostics["trash"]["motion_streak"], 2)
|
||||||
|
|
||||||
|
def test_detector_allows_quick_sequential_strong_trash_motions(self) -> None:
|
||||||
|
trash = Region("trash", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[],
|
||||||
|
trash_region=trash,
|
||||||
|
settings=RuntimeVisionSettings(sample_stride_pixels=4, trash_motion_delta=18),
|
||||||
|
)
|
||||||
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
_, first_deposit, _ = detector.observe(solid_frame(32, 32, 30), now)
|
||||||
|
_, second_deposit, _ = detector.observe(solid_frame(32, 32, 90), now + timedelta(seconds=1))
|
||||||
|
_, third_deposit, third_diagnostics = detector.observe(solid_frame(32, 32, 30), now + timedelta(seconds=7))
|
||||||
|
|
||||||
|
self.assertEqual(first_deposit, 0)
|
||||||
|
self.assertEqual(second_deposit, 1)
|
||||||
|
self.assertEqual(third_deposit, 1)
|
||||||
|
self.assertFalse(third_diagnostics["trash"]["in_cooldown"])
|
||||||
|
|
||||||
|
def test_detector_requires_consecutive_occupied_frames(self) -> None:
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||||
|
trash_region=None,
|
||||||
|
settings=RuntimeVisionSettings(
|
||||||
|
baseline_frames=1,
|
||||||
|
sample_stride_pixels=4,
|
||||||
|
occupancy_mean_delta=10,
|
||||||
|
occupancy_texture_delta=10,
|
||||||
|
occupancy_confirm_frames=2,
|
||||||
|
empty_confirm_frames=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
detector.observe(solid_frame(32, 32, 30), now)
|
||||||
|
first_counts, _, first_diagnostics = detector.observe(solid_frame(32, 32, 90), now)
|
||||||
|
second_counts, _, second_diagnostics = detector.observe(solid_frame(32, 32, 90), now)
|
||||||
|
first_empty_counts, _, _ = detector.observe(solid_frame(32, 32, 30), now)
|
||||||
|
second_empty_counts, _, _ = detector.observe(solid_frame(32, 32, 30), now)
|
||||||
|
|
||||||
|
self.assertEqual(first_counts, {"1": 0})
|
||||||
|
self.assertTrue(first_diagnostics["zones"]["1"]["raw_occupied"])
|
||||||
|
self.assertEqual(first_diagnostics["zones"]["1"]["occupied_streak"], 1)
|
||||||
|
self.assertEqual(second_counts, {"1": 1})
|
||||||
|
self.assertTrue(second_diagnostics["zones"]["1"]["occupied"])
|
||||||
|
self.assertEqual(first_empty_counts, {"1": 1})
|
||||||
|
self.assertEqual(second_empty_counts, {"1": 0})
|
||||||
|
|
||||||
|
def test_runtime_vision_defaults_raise_brightness_reflection_threshold(self) -> None:
|
||||||
|
settings = load_runtime_vision_settings({})
|
||||||
|
|
||||||
|
self.assertEqual(settings.sample_stride_pixels, 4)
|
||||||
|
self.assertEqual(settings.occupancy_mean_delta, 55.0)
|
||||||
|
self.assertEqual(settings.occupancy_confirm_frames, 2)
|
||||||
|
self.assertEqual(settings.empty_confirm_frames, 2)
|
||||||
|
self.assertEqual(settings.trash_motion_delta, 18.0)
|
||||||
|
self.assertEqual(settings.trash_sustained_motion_delta, 8.0)
|
||||||
|
self.assertEqual(settings.trash_sustained_motion_frames, 2)
|
||||||
|
self.assertEqual(settings.trash_motion_cooldown_seconds, 3)
|
||||||
|
|
||||||
|
def test_detector_can_seed_previous_baseline_and_occupancy(self) -> None:
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||||
|
trash_region=None,
|
||||||
|
settings=RuntimeVisionSettings(
|
||||||
|
baseline_frames=10,
|
||||||
|
sample_stride_pixels=4,
|
||||||
|
occupancy_mean_delta=55,
|
||||||
|
occupancy_texture_delta=18,
|
||||||
|
occupancy_confirm_frames=2,
|
||||||
|
empty_confirm_frames=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
detector.seed_baseline({"1": RegionMetrics(mean_luma=30.0, texture=0.0, sample_count=1)})
|
||||||
|
detector.seed_occupancy({"1": 1})
|
||||||
|
|
||||||
|
counts, _, diagnostics = detector.observe(solid_frame(32, 32, 90), datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
self.assertTrue(diagnostics["baseline_ready"])
|
||||||
|
self.assertEqual(counts, {"1": 1})
|
||||||
|
self.assertTrue(diagnostics["zones"]["1"]["occupied"])
|
||||||
|
|
||||||
|
def test_detector_reports_compact_dark_object_as_occupied(self) -> None:
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||||
|
trash_region=None,
|
||||||
|
settings=RuntimeVisionSettings(
|
||||||
|
baseline_frames=1,
|
||||||
|
sample_stride_pixels=4,
|
||||||
|
occupancy_mean_delta=55,
|
||||||
|
occupancy_texture_delta=100,
|
||||||
|
occupancy_dark_luma_threshold=80,
|
||||||
|
occupancy_dark_fraction=0.06,
|
||||||
|
occupancy_confirm_frames=1,
|
||||||
|
empty_confirm_frames=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
detector.observe(solid_frame(32, 32, 180), now)
|
||||||
|
counts, _, diagnostics = detector.observe(patched_frame(32, 32, 180, (0, 0, 8, 32, 20)), now)
|
||||||
|
|
||||||
|
self.assertEqual(counts, {"1": 1})
|
||||||
|
self.assertGreaterEqual(diagnostics["zones"]["1"]["dark_fraction"], 0.06)
|
||||||
|
|
||||||
|
def test_detector_ignores_bright_reflection_without_dark_object(self) -> None:
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||||
|
trash_region=None,
|
||||||
|
settings=RuntimeVisionSettings(
|
||||||
|
baseline_frames=1,
|
||||||
|
sample_stride_pixels=4,
|
||||||
|
occupancy_mean_delta=55,
|
||||||
|
occupancy_texture_delta=10,
|
||||||
|
occupancy_dark_luma_threshold=80,
|
||||||
|
occupancy_dark_fraction=0.06,
|
||||||
|
occupancy_texture_dark_fraction=0.04,
|
||||||
|
occupancy_confirm_frames=1,
|
||||||
|
empty_confirm_frames=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
detector.observe(solid_frame(32, 32, 160), now)
|
||||||
|
counts, _, diagnostics = detector.observe(patched_frame(32, 32, 160, (0, 0, 8, 32, 255)), now)
|
||||||
|
|
||||||
|
self.assertEqual(counts, {"1": 0})
|
||||||
|
self.assertGreaterEqual(diagnostics["zones"]["1"]["texture_delta"], 10)
|
||||||
|
self.assertLess(diagnostics["zones"]["1"]["dark_fraction"], 0.04)
|
||||||
|
|
||||||
|
def test_detector_ignores_bright_reflection_with_small_dark_edge(self) -> None:
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||||
|
trash_region=None,
|
||||||
|
settings=RuntimeVisionSettings(
|
||||||
|
baseline_frames=1,
|
||||||
|
sample_stride_pixels=4,
|
||||||
|
occupancy_mean_delta=55,
|
||||||
|
occupancy_texture_delta=18,
|
||||||
|
occupancy_dark_luma_threshold=80,
|
||||||
|
occupancy_dark_fraction=0.06,
|
||||||
|
occupancy_texture_dark_fraction=0.04,
|
||||||
|
occupancy_confirm_frames=1,
|
||||||
|
empty_confirm_frames=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
detector.observe(solid_frame(40, 40, 180), now)
|
||||||
|
counts, _, diagnostics = detector.observe(
|
||||||
|
multi_patched_frame(
|
||||||
|
40,
|
||||||
|
40,
|
||||||
|
180,
|
||||||
|
[
|
||||||
|
(0, 0, 12, 40, 255),
|
||||||
|
(12, 0, 16, 32, 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
zone = diagnostics["zones"]["1"]
|
||||||
|
self.assertEqual(counts, {"1": 0})
|
||||||
|
self.assertGreaterEqual(zone["dark_fraction"], 0.06)
|
||||||
|
self.assertGreaterEqual(zone["bright_fraction"], 0.18)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
3
web/.dockerignore
Normal file
3
web/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
29
web/Dockerfile
Normal file
29
web/Dockerfile
Normal 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
20
web/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
438
web/src/main.js
438
web/src/main.js
@@ -1,35 +1,51 @@
|
|||||||
import "./styles.css";
|
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 draftStorageKey = "cold-display-guard.calibrationDraft.v2";
|
||||||
const allRegions = [...zoneIds, "trash"];
|
const defaultFoodZones = deriveFoodZones({layout: {zone_count: 8}});
|
||||||
const draftStorageKey = "cold-display-guard.calibrationDraft.v1";
|
const runtimeClockMs = 1000;
|
||||||
const palette = {
|
const runtimePollMs = 5000;
|
||||||
r1c1: "#d92d20",
|
|
||||||
r1c2: "#b54708",
|
window.addEventListener("error", (event) => {
|
||||||
r1c3: "#4e5ba6",
|
showFatalError(event.error || event.message);
|
||||||
r1c4: "#008a5a",
|
});
|
||||||
r2c1: "#0077a3",
|
|
||||||
r2c2: "#155eef",
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
r2c3: "#7f56d9",
|
showFatalError(event.reason);
|
||||||
r2c4: "#c11574",
|
});
|
||||||
trash: "#111827",
|
|
||||||
};
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
config: null,
|
config: null,
|
||||||
summary: null,
|
summary: null,
|
||||||
events: [],
|
events: [],
|
||||||
activeTab: "calibration",
|
activeTab: "events",
|
||||||
activeRegion: "r1c1",
|
activeRegion: "1",
|
||||||
polygons: Object.fromEntries(allRegions.map((id) => [id, []])),
|
foodZones: defaultFoodZones,
|
||||||
|
foodZoneCount: defaultFoodZones.length,
|
||||||
|
polygons: buildPolygonMap(defaultFoodZones),
|
||||||
image: null,
|
image: null,
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
status: "正在连接后端...",
|
status: "正在连接后端...",
|
||||||
|
runtimeDemoReason: "正在读取后端运行数据",
|
||||||
configDirty: false,
|
configDirty: false,
|
||||||
calibrationDirty: false,
|
calibrationDirty: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const app = document.querySelector("#app");
|
const app = document.querySelector("#app");
|
||||||
|
let runtimeRefreshInFlight = false;
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
@@ -83,6 +99,10 @@ app.innerHTML = `
|
|||||||
<aside class="panel zone-panel">
|
<aside class="panel zone-panel">
|
||||||
<div class="panel-meta">ZONE MATRIX</div>
|
<div class="panel-meta">ZONE MATRIX</div>
|
||||||
<div class="panel-title">区域选择</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 id="regionList" class="region-list"></div>
|
||||||
<div class="tool-stack">
|
<div class="tool-stack">
|
||||||
<button id="undoPoint" type="button">撤销点</button>
|
<button id="undoPoint" type="button">撤销点</button>
|
||||||
@@ -94,7 +114,7 @@ app.innerHTML = `
|
|||||||
<section class="canvas-panel">
|
<section class="canvas-panel">
|
||||||
<div class="canvas-toolbar">
|
<div class="canvas-toolbar">
|
||||||
<span>FRAME INSPECTION</span>
|
<span>FRAME INSPECTION</span>
|
||||||
<strong id="activeRegionBadge">r1c1</strong>
|
<strong id="activeRegionBadge">区域 1</strong>
|
||||||
</div>
|
</div>
|
||||||
<canvas id="canvas" width="1280" height="720"></canvas>
|
<canvas id="canvas" width="1280" height="720"></canvas>
|
||||||
</section>
|
</section>
|
||||||
@@ -115,7 +135,13 @@ app.innerHTML = `
|
|||||||
</div>
|
</div>
|
||||||
<p class="view-note">从运行进程写入的事件和诊断数据中读取最近状态。</p>
|
<p class="view-note">从运行进程写入的事件和诊断数据中读取最近状态。</p>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="runtimeOverview" class="runtime-overview"></section>
|
||||||
<section class="metrics" id="metrics"></section>
|
<section class="metrics" id="metrics"></section>
|
||||||
|
<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">
|
<section class="panel event-panel">
|
||||||
<div class="panel-meta">EVENT LOG</div>
|
<div class="panel-meta">EVENT LOG</div>
|
||||||
<div class="panel-title">最近事件</div>
|
<div class="panel-title">最近事件</div>
|
||||||
@@ -149,8 +175,8 @@ app.innerHTML = `
|
|||||||
<input id="timezone" type="text">
|
<input id="timezone" type="text">
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>最大放置秒数</span>
|
<span>报警阈值(分钟)</span>
|
||||||
<input id="maxDwell" type="number" min="1">
|
<input id="maxDwell" type="number" min="1" step="1">
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>垃圾桶确认秒数</span>
|
<span>垃圾桶确认秒数</span>
|
||||||
@@ -177,6 +203,7 @@ const els = {
|
|||||||
statusText: document.querySelector("#statusText"),
|
statusText: document.querySelector("#statusText"),
|
||||||
canvas: document.querySelector("#canvas"),
|
canvas: document.querySelector("#canvas"),
|
||||||
regionList: document.querySelector("#regionList"),
|
regionList: document.querySelector("#regionList"),
|
||||||
|
foodZoneCount: document.querySelector("#foodZoneCount"),
|
||||||
rtspUrl: document.querySelector("#rtspUrl"),
|
rtspUrl: document.querySelector("#rtspUrl"),
|
||||||
settingsRtspUrl: document.querySelector("#settingsRtspUrl"),
|
settingsRtspUrl: document.querySelector("#settingsRtspUrl"),
|
||||||
cameraId: document.querySelector("#cameraId"),
|
cameraId: document.querySelector("#cameraId"),
|
||||||
@@ -185,6 +212,8 @@ const els = {
|
|||||||
trashWindow: document.querySelector("#trashWindow"),
|
trashWindow: document.querySelector("#trashWindow"),
|
||||||
configPreview: document.querySelector("#configPreview"),
|
configPreview: document.querySelector("#configPreview"),
|
||||||
regionSummary: document.querySelector("#regionSummary"),
|
regionSummary: document.querySelector("#regionSummary"),
|
||||||
|
runtimeOverview: document.querySelector("#runtimeOverview"),
|
||||||
|
runtimeProgress: document.querySelector("#runtimeProgress"),
|
||||||
metrics: document.querySelector("#metrics"),
|
metrics: document.querySelector("#metrics"),
|
||||||
eventsTable: document.querySelector("#eventsTable"),
|
eventsTable: document.querySelector("#eventsTable"),
|
||||||
statusPill: document.querySelector("#statusPill"),
|
statusPill: document.querySelector("#statusPill"),
|
||||||
@@ -194,9 +223,13 @@ const ctx = els.canvas.getContext("2d");
|
|||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
wireEvents();
|
wireEvents();
|
||||||
loadDraftPolygons();
|
render();
|
||||||
renderRegionList();
|
loadInitialData().finally(startRuntimeTimers);
|
||||||
loadInitialData();
|
}
|
||||||
|
|
||||||
|
function startRuntimeTimers() {
|
||||||
|
window.setInterval(renderRuntimeSections, runtimeClockMs);
|
||||||
|
window.setInterval(refreshRuntimeDataSilently, runtimePollMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireEvents() {
|
function wireEvents() {
|
||||||
@@ -213,6 +246,7 @@ function wireEvents() {
|
|||||||
document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig);
|
document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig);
|
||||||
els.canvas.addEventListener("click", addPoint);
|
els.canvas.addEventListener("click", addPoint);
|
||||||
window.addEventListener("resize", drawCanvas);
|
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) => {
|
[els.rtspUrl, els.settingsRtspUrl, els.cameraId, els.timezone, els.maxDwell, els.trashWindow].forEach((input) => {
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
state.configDirty = true;
|
state.configDirty = true;
|
||||||
@@ -230,22 +264,17 @@ function wireEvents() {
|
|||||||
async function loadInitialData() {
|
async function loadInitialData() {
|
||||||
try {
|
try {
|
||||||
setStatus("正在读取配置和运行数据...");
|
setStatus("正在读取配置和运行数据...");
|
||||||
const [config, summary, events] = await Promise.all([
|
const config = await apiJson("/api/manage/config");
|
||||||
apiJson("/api/manage/config"),
|
|
||||||
apiJson("/api/manage/summary"),
|
|
||||||
apiJson("/api/manage/events?limit=200"),
|
|
||||||
]);
|
|
||||||
state.config = config;
|
state.config = config;
|
||||||
state.summary = summary;
|
applyConfigRegions(config, {useDraft: true});
|
||||||
state.events = events.items || [];
|
await loadRuntimeData();
|
||||||
fillForm();
|
fillForm();
|
||||||
state.configDirty = false;
|
state.configDirty = false;
|
||||||
if (!hasAnyPolygon()) {
|
|
||||||
loadPolygonsFromConfig(false);
|
|
||||||
}
|
|
||||||
render();
|
render();
|
||||||
setStatus("已连接后端 19080");
|
setStatus("已连接后端 19080");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
state.runtimeDemoReason = `后端连接失败:${error.message}`;
|
||||||
|
render();
|
||||||
setStatus(`连接失败:${error.message}`);
|
setStatus(`连接失败:${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,19 +282,53 @@ async function loadInitialData() {
|
|||||||
async function refreshRuntimeData() {
|
async function refreshRuntimeData() {
|
||||||
try {
|
try {
|
||||||
setStatus("正在刷新运行数据...");
|
setStatus("正在刷新运行数据...");
|
||||||
const [summary, events] = await Promise.all([
|
await loadRuntimeData();
|
||||||
apiJson("/api/manage/summary"),
|
|
||||||
apiJson("/api/manage/events?limit=200"),
|
|
||||||
]);
|
|
||||||
state.summary = summary;
|
|
||||||
state.events = events.items || [];
|
|
||||||
render();
|
render();
|
||||||
setStatus("运行数据已刷新");
|
setStatus(state.runtimeDemoReason ? `运行数据已刷新,部分接口失败:${state.runtimeDemoReason}` : "运行数据已刷新");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
state.runtimeDemoReason = `运行数据刷新失败:${error.message}`;
|
||||||
|
render();
|
||||||
setStatus(`刷新运行数据失败:${error.message}`);
|
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() {
|
async function reloadConfig() {
|
||||||
if (state.configDirty && !window.confirm("当前运行配置有未保存修改。确认放弃修改并重新载入后端配置?")) {
|
if (state.configDirty && !window.confirm("当前运行配置有未保存修改。确认放弃修改并重新载入后端配置?")) {
|
||||||
return;
|
return;
|
||||||
@@ -273,6 +336,7 @@ async function reloadConfig() {
|
|||||||
try {
|
try {
|
||||||
setStatus("正在重新载入后端配置...");
|
setStatus("正在重新载入后端配置...");
|
||||||
state.config = await apiJson("/api/manage/config");
|
state.config = await apiJson("/api/manage/config");
|
||||||
|
applyConfigRegions(state.config, {useDraft: false});
|
||||||
fillForm();
|
fillForm();
|
||||||
state.configDirty = false;
|
state.configDirty = false;
|
||||||
render();
|
render();
|
||||||
@@ -289,7 +353,7 @@ async function saveConfig() {
|
|||||||
timezone: els.timezone.value.trim(),
|
timezone: els.timezone.value.trim(),
|
||||||
rtsp_url: els.settingsRtspUrl.value.trim(),
|
rtsp_url: els.settingsRtspUrl.value.trim(),
|
||||||
thresholds: {
|
thresholds: {
|
||||||
max_dwell_seconds: Number(els.maxDwell.value),
|
max_dwell_seconds: alarmMinutesToSeconds(els.maxDwell.value),
|
||||||
trash_confirmation_seconds: Number(els.trashWindow.value),
|
trash_confirmation_seconds: Number(els.trashWindow.value),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -349,15 +413,8 @@ async function saveCalibration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function persistCalibration({requireAny}) {
|
async function persistCalibration({requireAny}) {
|
||||||
const zones = zoneIds
|
const payload = buildCalibrationPayload(state.foodZones, state.polygons);
|
||||||
.map((id) => ({id, polygon: serializePolygon(state.polygons[id])}))
|
if (!payload.zones.length && !payload.trash.roi) {
|
||||||
.filter((zone) => zone.polygon.length >= 3);
|
|
||||||
const trashPolygon = state.polygons.trash;
|
|
||||||
const payload = {zones, trash: {}};
|
|
||||||
if (trashPolygon.length >= 3) {
|
|
||||||
payload.trash.roi = serializePolygon(trashPolygon);
|
|
||||||
}
|
|
||||||
if (!zones.length && !payload.trash.roi) {
|
|
||||||
if (requireAny) {
|
if (requireAny) {
|
||||||
setStatus("当前没有可保存的标定点;每个区域至少需要 3 个点");
|
setStatus("当前没有可保存的标定点;每个区域至少需要 3 个点");
|
||||||
}
|
}
|
||||||
@@ -370,10 +427,6 @@ async function persistCalibration({requireAny}) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializePolygon(points) {
|
|
||||||
return points.map((point) => [point.x, point.y]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTab(tab) {
|
function setTab(tab) {
|
||||||
state.activeTab = tab;
|
state.activeTab = tab;
|
||||||
document.querySelectorAll(".tabs button").forEach((button) => {
|
document.querySelectorAll(".tabs button").forEach((button) => {
|
||||||
@@ -386,12 +439,44 @@ function setTab(tab) {
|
|||||||
|
|
||||||
function fillForm() {
|
function fillForm() {
|
||||||
const config = state.config || {};
|
const config = state.config || {};
|
||||||
|
const alarmSeconds = config.thresholds?.max_dwell_seconds || 10800;
|
||||||
els.rtspUrl.value = config.stream?.rtsp_url || "";
|
els.rtspUrl.value = config.stream?.rtsp_url || "";
|
||||||
els.settingsRtspUrl.value = config.stream?.rtsp_url || "";
|
els.settingsRtspUrl.value = config.stream?.rtsp_url || "";
|
||||||
els.cameraId.value = config.camera_id || "";
|
els.cameraId.value = config.camera_id || "";
|
||||||
els.timezone.value = config.timezone || "";
|
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.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) {
|
function loadPolygonsFromConfig(updateStatus = true) {
|
||||||
@@ -401,14 +486,7 @@ function loadPolygonsFromConfig(updateStatus = true) {
|
|||||||
if (updateStatus && state.calibrationDirty && !window.confirm("当前标定有未保存草稿。确认放弃草稿并载入已保存标定?")) {
|
if (updateStatus && state.calibrationDirty && !window.confirm("当前标定有未保存草稿。确认放弃草稿并载入已保存标定?")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const zone of state.config.zones || []) {
|
applyConfigRegions(state.config, {useDraft: false});
|
||||||
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}));
|
|
||||||
}
|
|
||||||
state.calibrationDirty = false;
|
state.calibrationDirty = false;
|
||||||
saveDraftPolygons();
|
saveDraftPolygons();
|
||||||
render();
|
render();
|
||||||
@@ -421,29 +499,47 @@ function render() {
|
|||||||
renderRegionList();
|
renderRegionList();
|
||||||
drawCanvas();
|
drawCanvas();
|
||||||
renderRegionSummary();
|
renderRegionSummary();
|
||||||
renderMetrics();
|
renderRuntimeSections();
|
||||||
renderEvents();
|
|
||||||
renderConfigPreview();
|
renderConfigPreview();
|
||||||
setTab(state.activeTab);
|
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() {
|
function renderRegionList() {
|
||||||
els.regionList.innerHTML = "";
|
els.regionList.innerHTML = "";
|
||||||
for (const id of allRegions) {
|
for (const id of allRegionIds()) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
const complete = state.polygons[id].length >= 3;
|
const complete = (state.polygons[id] || []).length >= 3;
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = [
|
button.className = [
|
||||||
"region-button",
|
"region-button",
|
||||||
id === state.activeRegion ? "active" : "",
|
id === state.activeRegion ? "active" : "",
|
||||||
complete ? "complete" : "",
|
complete ? "complete" : "",
|
||||||
].filter(Boolean).join(" ");
|
].filter(Boolean).join(" ");
|
||||||
button.style.setProperty("--region-color", palette[id] || "#ffffff");
|
button.style.setProperty("--region-color", getRegionColor(id));
|
||||||
button.innerHTML = `
|
button.innerHTML = `
|
||||||
<span class="region-swatch"></span>
|
<span class="region-swatch"></span>
|
||||||
<span class="region-name">${escapeHtml(getRegionLabel(id))}</span>
|
<span class="region-name">${escapeHtml(getRegionLabel(id))}</span>
|
||||||
<span class="region-code">${escapeHtml(id)}</span>
|
<span class="region-code">${id === TRASH_REGION_ID ? "ROI" : escapeHtml(id)}</span>
|
||||||
<span class="region-points">${state.polygons[id].length}</span>
|
<span class="region-points">${(state.polygons[id] || []).length}</span>
|
||||||
`;
|
`;
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
state.activeRegion = id;
|
state.activeRegion = id;
|
||||||
@@ -467,6 +563,9 @@ function addPoint(event) {
|
|||||||
}
|
}
|
||||||
const x = clamp(rawX / imageRect.width);
|
const x = clamp(rawX / imageRect.width);
|
||||||
const y = clamp(rawY / imageRect.height);
|
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.polygons[state.activeRegion].push({x: round(x), y: round(y)});
|
||||||
state.calibrationDirty = true;
|
state.calibrationDirty = true;
|
||||||
saveDraftPolygons();
|
saveDraftPolygons();
|
||||||
@@ -496,7 +595,7 @@ function getCanvasImageRect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function undoPoint() {
|
function undoPoint() {
|
||||||
state.polygons[state.activeRegion].pop();
|
(state.polygons[state.activeRegion] || []).pop();
|
||||||
state.calibrationDirty = true;
|
state.calibrationDirty = true;
|
||||||
saveDraftPolygons();
|
saveDraftPolygons();
|
||||||
render();
|
render();
|
||||||
@@ -510,30 +609,37 @@ function clearRegion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasAnyPolygon() {
|
function hasAnyPolygon() {
|
||||||
return allRegions.some((id) => state.polygons[id].length > 0);
|
return allRegionIds().some((id) => (state.polygons[id] || []).length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDraftPolygons() {
|
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);
|
const raw = localStorage.getItem(draftStorageKey);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return;
|
return {};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const draft = JSON.parse(raw);
|
const draft = JSON.parse(raw);
|
||||||
for (const id of allRegions) {
|
const polygons = draft.polygons && typeof draft.polygons === "object" ? draft.polygons : draft;
|
||||||
if (!Array.isArray(draft[id])) {
|
const normalized = {};
|
||||||
|
for (const id of Object.keys(polygons)) {
|
||||||
|
if (!Array.isArray(polygons[id])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
state.polygons[id] = draft[id]
|
normalized[id] = polygons[id]
|
||||||
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
|
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
|
||||||
.map((point) => ({x: clamp(point.x), y: clamp(point.y)}));
|
.map((point) => ({x: clamp(point.x), y: clamp(point.y)}));
|
||||||
}
|
}
|
||||||
|
return normalized;
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(draftStorageKey);
|
localStorage.removeItem(draftStorageKey);
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,8 +655,8 @@ function drawCanvas() {
|
|||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.fillText("输入 RTSP 地址并抓取一帧", els.canvas.width / 2, els.canvas.height / 2);
|
ctx.fillText("输入 RTSP 地址并抓取一帧", els.canvas.width / 2, els.canvas.height / 2);
|
||||||
}
|
}
|
||||||
for (const id of allRegions) {
|
for (const id of allRegionIds()) {
|
||||||
drawPolygon(id, state.polygons[id]);
|
drawPolygon(id, state.polygons[id] || []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,7 +664,7 @@ function drawPolygon(id, points) {
|
|||||||
if (!points.length) {
|
if (!points.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const color = palette[id] || "#ffffff";
|
const color = getRegionColor(id);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.strokeStyle = color;
|
ctx.strokeStyle = color;
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
@@ -595,43 +701,65 @@ function drawPolygon(id, points) {
|
|||||||
const first = points[0];
|
const first = points[0];
|
||||||
ctx.font = id === state.activeRegion ? "bold 18px system-ui" : "14px system-ui";
|
ctx.font = id === state.activeRegion ? "bold 18px system-ui" : "14px system-ui";
|
||||||
ctx.textAlign = "left";
|
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();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRegionSummary() {
|
function renderRegionSummary() {
|
||||||
els.regionSummary.innerHTML = allRegions
|
els.regionSummary.innerHTML = allRegionIds()
|
||||||
.map((id) => {
|
.map((id) => {
|
||||||
const count = state.polygons[id].length;
|
const count = (state.polygons[id] || []).length;
|
||||||
const complete = count >= 3;
|
const complete = count >= 3;
|
||||||
return `
|
return `
|
||||||
<div class="summary-row ${complete ? "complete" : "pending"}">
|
<div class="summary-row ${complete ? "complete" : "pending"}">
|
||||||
<span class="summary-dot" style="--region-color:${palette[id] || "#ffffff"}"></span>
|
<span class="summary-dot" style="--region-color:${getRegionColor(id)}"></span>
|
||||||
<strong>${escapeHtml(getRegionLabel(id))}</strong>
|
<strong>${escapeHtml(getRegionLabel(id))}</strong>
|
||||||
<span>${complete ? `${count} 点 / 已标定` : `${count} 点 / 待完成`}</span>
|
<span>${complete ? `${count} 点 / 已标定` : `${count} 点 / 待完成`}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
els.activeRegionBadge.textContent = state.activeRegion;
|
els.activeRegionBadge.textContent = getRegionLabel(state.activeRegion);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMetrics() {
|
function renderRuntimeOverview(model) {
|
||||||
const metrics = state.summary?.metrics || {};
|
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 violationCount = metrics.violation_count ?? 0;
|
||||||
const baselineReady = Boolean(metrics.baseline_ready);
|
const baselineReady = Boolean(metrics.baseline_ready);
|
||||||
|
const metricLabel = (label) => label;
|
||||||
const cards = [
|
const cards = [
|
||||||
{label: "事件总数", value: metrics.event_count ?? 0, tone: "neutral"},
|
{label: metricLabel("事件总数"), value: metrics.event_count ?? 0, tone: "neutral"},
|
||||||
{label: "违规事件", value: violationCount, tone: violationCount > 0 ? "danger" : "good"},
|
{label: metricLabel("时间报警"), value: alertCount, tone: alertCount > 0 ? "alarm" : "good"},
|
||||||
{label: "诊断帧数", value: metrics.diagnostics_count ?? 0, tone: "neutral"},
|
{label: metricLabel("升级警告"), value: warningCount, tone: warningCount > 0 ? "warning" : "good"},
|
||||||
{label: "基线状态", value: baselineReady ? "ready" : "learning", tone: baselineReady ? "good" : "warning"},
|
{label: metricLabel("违规事件"), value: violationCount, tone: violationCount > 0 ? "danger" : "good"},
|
||||||
{label: "最新报警", value: metrics.latest_alert_time || "-", tone: metrics.latest_alert_time ? "danger" : "neutral"},
|
{label: metricLabel("诊断帧数"), value: metrics.diagnostics_count ?? 0, tone: "neutral"},
|
||||||
{label: "事件文件", value: metrics.events_path || "-", tone: "path"},
|
{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 zoneCounts = metrics.latest_zone_counts || {};
|
||||||
const zoneSummary = Object.keys(zoneCounts).length
|
const zoneSummary = Object.keys(zoneCounts).length
|
||||||
? `<div class="metric wide zone-state"><span>最新区域状态</span><strong>${Object.entries(zoneCounts)
|
? `<div class="metric wide zone-state"><span>${escapeHtml(metricLabel("最新区域状态"))}</span><strong>${Object.entries(zoneCounts)
|
||||||
.map(([zoneId, count]) => `${zoneId}:${count}`)
|
.map(([zoneId, count]) => escapeHtml(`${zoneId}:${count}`))
|
||||||
.join(" ")}</strong></div>`
|
.join(" ")}</strong></div>`
|
||||||
: "";
|
: "";
|
||||||
els.metrics.innerHTML = cards
|
els.metrics.innerHTML = cards
|
||||||
@@ -644,28 +772,59 @@ function renderMetrics() {
|
|||||||
.join("") + zoneSummary;
|
.join("") + zoneSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEvents() {
|
function renderRuntimeProgress(model) {
|
||||||
if (!state.events.length) {
|
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 renderEvents(model) {
|
||||||
|
const events = model.displayEvents || model.events;
|
||||||
|
if (!events.length) {
|
||||||
els.eventsTable.innerHTML = `<div class="empty">还没有事件数据</div>`;
|
els.eventsTable.innerHTML = `<div class="empty">还没有事件数据</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
els.eventsTable.innerHTML = `
|
els.eventsTable.innerHTML = `
|
||||||
<table>
|
<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>
|
<tbody>
|
||||||
${state.events
|
${events
|
||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((event) => {
|
.map((event) => {
|
||||||
const eventName = event.event || "";
|
const eventName = event.event || "";
|
||||||
const isViolation = eventName.includes("violation");
|
const meta = classifyEvent(event);
|
||||||
return `
|
return `
|
||||||
<tr class="${isViolation ? "violation-row" : ""}">
|
<tr class="event-row ${meta.tone}">
|
||||||
<td>${escapeHtml(event.ts || "")}</td>
|
<td>${escapeHtml(event.ts || "")}</td>
|
||||||
<td><span class="event-name ${isViolation ? "danger" : ""}">${escapeHtml(eventName)}</span></td>
|
<td><span class="event-source real">真实</span></td>
|
||||||
<td>${escapeHtml(event.zone_id || "")}</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(event.batch_id || "")}</td>
|
||||||
<td>${escapeHtml(String(event.dwell_seconds ?? ""))}</td>
|
<td>${escapeHtml(String(event.displayDwellSeconds ?? event.dwell_seconds ?? ""))}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
@@ -675,6 +834,23 @@ function renderEvents() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function renderConfigPreview() {
|
||||||
const preview = {
|
const preview = {
|
||||||
...(state.config || {}),
|
...(state.config || {}),
|
||||||
@@ -685,9 +861,21 @@ function renderConfigPreview() {
|
|||||||
camera_id: els.cameraId.value,
|
camera_id: els.cameraId.value,
|
||||||
timezone: els.timezone.value,
|
timezone: els.timezone.value,
|
||||||
thresholds: {
|
thresholds: {
|
||||||
max_dwell_seconds: Number(els.maxDwell.value || 0),
|
max_dwell_seconds: alarmMinutesToSeconds(els.maxDwell.value || 0),
|
||||||
trash_confirmation_seconds: Number(els.trashWindow.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: {
|
ui_state: {
|
||||||
config_dirty: state.configDirty,
|
config_dirty: state.configDirty,
|
||||||
calibration_dirty: state.calibrationDirty,
|
calibration_dirty: state.calibrationDirty,
|
||||||
@@ -721,17 +909,6 @@ function setStatus(message) {
|
|||||||
els.statusPill.className = `status-pill ${tone}`;
|
els.statusPill.className = `status-pill ${tone}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRegionLabel(id) {
|
|
||||||
if (id === "trash") {
|
|
||||||
return "垃圾桶";
|
|
||||||
}
|
|
||||||
const match = id.match(/^r(\d)c(\d)$/);
|
|
||||||
if (!match) {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
return `${match[1]}排${match[2]}列`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value) {
|
function clamp(value) {
|
||||||
return Math.min(1, Math.max(0, value));
|
return Math.min(1, Math.max(0, value));
|
||||||
}
|
}
|
||||||
@@ -740,14 +917,25 @@ function round(value) {
|
|||||||
return Math.round(value * 1000000) / 1000000;
|
return Math.round(value * 1000000) / 1000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function allRegionIds() {
|
||||||
return value.replace(/[&<>"']/g, (char) => ({
|
return [...state.foodZones.map((zone) => zone.id), TRASH_REGION_ID];
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
'"': """,
|
|
||||||
"'": "'",
|
|
||||||
})[char]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
boot();
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ body {
|
|||||||
var(--paper);
|
var(--paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fatal-error {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid rgba(201, 50, 50, 0.28);
|
||||||
|
background: #fdecea;
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input {
|
input {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -357,6 +369,10 @@ input::placeholder {
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.zone-count-field {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.region-button {
|
.region-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 12px minmax(0, 1fr) auto 28px;
|
grid-template-columns: 12px minmax(0, 1fr) auto 28px;
|
||||||
@@ -507,6 +523,52 @@ canvas {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtime-overview {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-banner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, auto) minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-left: 5px solid var(--green);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfa;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-banner.demo {
|
||||||
|
border-left-color: var(--amber);
|
||||||
|
background: #fffaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-banner span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-banner strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-banner p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.55;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.metric {
|
.metric {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -541,6 +603,10 @@ canvas {
|
|||||||
border-top-color: var(--amber);
|
border-top-color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric.alarm {
|
||||||
|
border-top-color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
.metric.danger {
|
.metric.danger {
|
||||||
border-top-color: var(--red);
|
border-top-color: var(--red);
|
||||||
}
|
}
|
||||||
@@ -560,6 +626,107 @@ canvas {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-progress {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(130px, 0.4fr) minmax(180px, 1fr) minmax(88px, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #e2e8ee;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfcfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-zone {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-number {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 30px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #d7dee5;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "DIN Alternate", "Avenir Next Condensed", sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-zone strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
height: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #d5dde5;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track span {
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 3px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row.alarm .progress-track span {
|
||||||
|
background: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row.warning .progress-track span {
|
||||||
|
background: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
justify-items: end;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-meta strong {
|
||||||
|
font-family: "DIN Alternate", "Avenir Next Condensed", "PingFang SC", sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-meta span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row.alarm .progress-meta span {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row.warning .progress-meta span {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
.events-table {
|
.events-table {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
margin: 0 -14px -14px;
|
margin: 0 -14px -14px;
|
||||||
@@ -593,11 +760,18 @@ td {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-row.warning,
|
||||||
.violation-row {
|
.violation-row {
|
||||||
background: #fff6f5;
|
background: #fff6f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-name {
|
.event-row.alarm {
|
||||||
|
background: #f0f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-name,
|
||||||
|
.event-severity,
|
||||||
|
.event-source {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 26px;
|
min-height: 26px;
|
||||||
@@ -610,12 +784,37 @@ td {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-severity {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-source.real {
|
||||||
|
border-color: rgba(15, 143, 97, 0.24);
|
||||||
|
background: #e9f8f1;
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-source.demo {
|
||||||
|
border-color: rgba(183, 110, 0, 0.24);
|
||||||
|
background: #fff4df;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-name.warning,
|
||||||
|
.event-severity.warning,
|
||||||
.event-name.danger {
|
.event-name.danger {
|
||||||
border-color: rgba(201, 50, 50, 0.24);
|
border-color: rgba(201, 50, 50, 0.24);
|
||||||
background: #fdecea;
|
background: #fdecea;
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-name.alarm,
|
||||||
|
.event-severity.alarm {
|
||||||
|
border-color: rgba(35, 95, 159, 0.24);
|
||||||
|
background: #e7f1fd;
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-layout {
|
.settings-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(360px, 0.9fr) minmax(420px, 1.1fr);
|
grid-template-columns: minmax(360px, 0.9fr) minmax(420px, 1.1fr);
|
||||||
@@ -719,10 +918,20 @@ td {
|
|||||||
.calibration-layout,
|
.calibration-layout,
|
||||||
.settings-layout,
|
.settings-layout,
|
||||||
.settings-grid,
|
.settings-grid,
|
||||||
.metrics {
|
.metrics,
|
||||||
|
.runtime-banner,
|
||||||
|
.progress-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtime-banner p {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-meta {
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
.canvas-panel {
|
.canvas-panel {
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|||||||
502
web/src/zone-state.js
Normal file
502
web/src/zone-state.js
Normal 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) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
})[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
630
web/test/zone-state.test.js
Normal 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"'),
|
||||||
|
"<img src=x onerror=alert(1)> & "zone"",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
}]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user