fix: stabilize cold display occupancy detection

This commit is contained in:
2026-06-15 13:40:20 +08:00
parent 1059850378
commit fa2c90e250
5 changed files with 68 additions and 1 deletions

View File

@@ -41,6 +41,7 @@ roi = [[0.776842, 0.486901], [0.896842, 0.522456], [0.841053, 0.857427], [0.7168
sample_stride_pixels = 4 sample_stride_pixels = 4
occupancy_mean_delta = 55.0 occupancy_mean_delta = 55.0
occupancy_dark_luma_threshold = 80.0 occupancy_dark_luma_threshold = 80.0
occupancy_absolute_dark_fraction = 0.0
occupancy_dark_fraction = 0.06 occupancy_dark_fraction = 0.06
occupancy_texture_dark_fraction = 0.04 occupancy_texture_dark_fraction = 0.04
occupancy_bright_luma_threshold = 220.0 occupancy_bright_luma_threshold = 220.0

View File

@@ -30,6 +30,7 @@ class RuntimeVisionSettings:
occupancy_texture_delta: float = 18.0 occupancy_texture_delta: float = 18.0
occupancy_dark_luma_threshold: float = 80.0 occupancy_dark_luma_threshold: float = 80.0
occupancy_dark_fraction: float = 0.06 occupancy_dark_fraction: float = 0.06
occupancy_absolute_dark_fraction: float = 0.0
occupancy_texture_dark_fraction: float = 0.04 occupancy_texture_dark_fraction: float = 0.04
occupancy_bright_luma_threshold: float = 220.0 occupancy_bright_luma_threshold: float = 220.0
occupancy_bright_reflection_fraction: float = 0.18 occupancy_bright_reflection_fraction: float = 0.18
@@ -236,6 +237,7 @@ def load_runtime_vision_settings(config: dict[str, Any]) -> RuntimeVisionSetting
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_luma_threshold=float(runtime.get("occupancy_dark_luma_threshold", 80.0)),
occupancy_dark_fraction=float(runtime.get("occupancy_dark_fraction", 0.06)), occupancy_dark_fraction=float(runtime.get("occupancy_dark_fraction", 0.06)),
occupancy_absolute_dark_fraction=float(runtime.get("occupancy_absolute_dark_fraction", 0.0)),
occupancy_texture_dark_fraction=float(runtime.get("occupancy_texture_dark_fraction", 0.04)), 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_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_bright_reflection_fraction=float(runtime.get("occupancy_bright_reflection_fraction", 0.18)),
@@ -324,13 +326,18 @@ def metrics_indicate_occupied(
dark_delta = dark_fraction - baseline_dark_fraction dark_delta = dark_fraction - baseline_dark_fraction
bright_reflection = is_bright_reflection(settings, dark_delta, bright_fraction) bright_reflection = is_bright_reflection(settings, dark_delta, bright_fraction)
dark_occupied = dark_delta >= settings.occupancy_dark_fraction and not bright_reflection dark_occupied = dark_delta >= settings.occupancy_dark_fraction and not bright_reflection
absolute_dark_occupied = (
settings.occupancy_absolute_dark_fraction > 0
and dark_fraction >= settings.occupancy_absolute_dark_fraction
and not bright_reflection
)
mean_occupied = mean_delta >= settings.occupancy_mean_delta and not bright_reflection mean_occupied = mean_delta >= settings.occupancy_mean_delta and not bright_reflection
texture_occupied = ( texture_occupied = (
texture_delta >= settings.occupancy_texture_delta texture_delta >= settings.occupancy_texture_delta
and dark_delta >= settings.occupancy_texture_dark_fraction and dark_delta >= settings.occupancy_texture_dark_fraction
and not bright_reflection and not bright_reflection
) )
return dark_occupied or mean_occupied or texture_occupied return dark_occupied or absolute_dark_occupied or mean_occupied or texture_occupied
def is_bright_reflection(settings: RuntimeVisionSettings, dark_delta: float, bright_fraction: float) -> bool: def is_bright_reflection(settings: RuntimeVisionSettings, dark_delta: float, bright_fraction: float) -> bool:

View File

@@ -11,3 +11,9 @@
1. 涉及中文截图叠字时,镜像必须安装可验证的 CJK 字体包,并在容器内用 `fc-match :lang=zh-cn` 确认命中 CJK 字体。 1. 涉及中文截图叠字时,镜像必须安装可验证的 CJK 字体包,并在容器内用 `fc-match :lang=zh-cn` 确认命中 CJK 字体。
2. 部署后必须在目标容器内跑一次中文标签叠图烟测,确认真实渲染路径可用,而不只检查像素变化。 2. 部署后必须在目标容器内跑一次中文标签叠图烟测,确认真实渲染路径可用,而不只检查像素变化。
3. 字体渲染不可用时,回退文本必须转换成可读 ASCII 标识,例如 `区域 1` -> `R1``垃圾区` -> `TRASH`,避免继续绘制乱码中文。 3. 字体渲染不可用时,回退文本必须转换成可读 ASCII 标识,例如 `区域 1` -> `R1``垃圾区` -> `TRASH`,避免继续绘制乱码中文。
- 2026-06-15: 现场识别抖动排查时,不能先假设某个区域为空;用户指出区域 1、2、6 实际都有物后,原先单纯调高相对暗区阈值会压掉真实占用。
Prevention:
1. 调整视觉阈值前,必须向现场实际状态对齐,明确每个被分析区域当前应该是有物还是空。
2. 如果物品已存在于启动基线中,不能只依赖相对基线变化;需要绝对特征或重新采空基线来识别。
3. 对“正常取用”误报,应优先检查有物状态是否短暂掉空,并用判空确认帧数或滞后来处理抖动,而不是只提高占用阈值。

View File

@@ -284,3 +284,35 @@
- `GET /api/manage/health` returned `status=ok`, `runtime_status=running`, and version `dev`. - `GET /api/manage/health` returned `status=ok`, `runtime_status=running`, and version `dev`.
- `cold-display-guard-api` is healthy and `cold-display-guard-runtime` is running after restart. - `cold-display-guard-api` is healthy and `cold-display-guard-runtime` is running after restart.
- Runtime logs show normal startup after the restart. - Runtime logs show normal startup after the restart.
## Current Task: Investigate False Normal Consumption Events On 10.8.0.23
**Goal:** Determine why the live system records a normal consumption event about every two minutes with a dwell time near 13 seconds even when no one touched the cold display cabinet.
**Debug plan:** Inspect remote runtime/event/case/diagnostic logs first, correlate `batch_started` and `batch_consumed` pairs by zone and dwell time, then trace the vision metrics for those timestamps to identify whether the source is occupancy flicker, runtime restart state restoration, config thresholds, or downstream display interpretation.
- [ ] Inspect recent remote events and confirm the exact event names, zones, dwell seconds, and cadence.
- [ ] Inspect runtime diagnostics around those timestamps for occupancy and vision metric flicker.
- [ ] Inspect live config and runtime logs for sampling/stabilization settings and restarts.
- [x] Form and test a root-cause hypothesis before changing code or live thresholds.
- [x] Record findings, fix if needed, and verify with logs/tests.
### Findings And Fix
- The repeated records were real `batch_started` -> `batch_consumed` events from the camera-side engine, not a downstream display issue.
- Before the fix, recent events showed repeated zone 1 batches ending after 13-33 seconds, matching the two-frame confirmation cadence at the current sampling rate.
- Root cause had two parts:
- Zone 1 was genuinely occupied, but its vision signal hovered around the old relative dark threshold, so short raw-occupancy dips were interpreted as item removal.
- Zone 2 was occupied before or during baseline learning, so its relative difference from baseline stayed near zero and it was not detected as occupied.
- Added `occupancy_absolute_dark_fraction` in `src/cold_display_guard/vision.py`, defaulting to `0.0` so existing configs are unchanged unless they opt in.
- Updated the live config on `xiaozheng@10.8.0.23`:
- `occupancy_dark_fraction = 0.12`
- `occupancy_absolute_dark_fraction = 0.085`
- `empty_confirm_frames = 6`
- Rebuilt and restarted `cold-display-guard-api` and `cold-display-guard-runtime`.
- Verification:
- Local full Python suite passed: `PYTHONPATH=src python3 -m unittest discover -s tests -v` (`102` tests).
- Remote health returned `status=ok` and `runtime_status=running`.
- Remote container config shows the new thresholds.
- After deployment, latest diagnostics stabilized at `zone_counts = {"1": 1, "2": 1, "6": 1}`.
- During a two-minute observation window after `13:25`, no new `batch_consumed` events were emitted; only expected pre-warning/alarm lifecycle events appeared for the occupied zones.

View File

@@ -10,6 +10,7 @@ from cold_display_guard.vision import (
RuntimeVisionSettings, RuntimeVisionSettings,
ZoneOccupancyDetector, ZoneOccupancyDetector,
load_runtime_vision_settings, load_runtime_vision_settings,
metrics_indicate_occupied,
point_in_polygon, point_in_polygon,
) )
@@ -157,6 +158,7 @@ class VisionTests(unittest.TestCase):
self.assertEqual(settings.sample_stride_pixels, 4) self.assertEqual(settings.sample_stride_pixels, 4)
self.assertEqual(settings.occupancy_mean_delta, 55.0) self.assertEqual(settings.occupancy_mean_delta, 55.0)
self.assertEqual(settings.occupancy_absolute_dark_fraction, 0.0)
self.assertEqual(settings.occupancy_confirm_frames, 2) self.assertEqual(settings.occupancy_confirm_frames, 2)
self.assertEqual(settings.empty_confirm_frames, 2) self.assertEqual(settings.empty_confirm_frames, 2)
self.assertEqual(settings.trash_motion_delta, 18.0) self.assertEqual(settings.trash_motion_delta, 18.0)
@@ -164,6 +166,25 @@ class VisionTests(unittest.TestCase):
self.assertEqual(settings.trash_sustained_motion_frames, 2) self.assertEqual(settings.trash_sustained_motion_frames, 2)
self.assertEqual(settings.trash_motion_cooldown_seconds, 3) self.assertEqual(settings.trash_motion_cooldown_seconds, 3)
def test_absolute_dark_fraction_can_detect_food_already_present_in_baseline(self) -> None:
settings = RuntimeVisionSettings(
occupancy_mean_delta=55,
occupancy_texture_delta=18,
occupancy_dark_fraction=0.06,
occupancy_absolute_dark_fraction=0.085,
)
occupied = metrics_indicate_occupied(
settings,
mean_delta=5.0,
texture_delta=0.5,
dark_fraction=0.09,
baseline_dark_fraction=0.10,
bright_fraction=0.0,
)
self.assertTrue(occupied)
def test_detector_can_seed_previous_baseline_and_occupancy(self) -> None: def test_detector_can_seed_previous_baseline_and_occupancy(self) -> None:
detector = ZoneOccupancyDetector( detector = ZoneOccupancyDetector(
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))], [Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],