diff --git a/README_zh.md b/README_zh.md index 53e4927..b6ef9a6 100644 --- a/README_zh.md +++ b/README_zh.md @@ -183,6 +183,10 @@ occupancy_reflection_dark_fraction = 0.10 occupancy_reflection_bright_dark_ratio = 2.0 occupancy_confirm_frames = 2 empty_confirm_frames = 2 +lighting_shift_guard_enabled = true +lighting_shift_min_regions = 3 +lighting_shift_region_fraction = 0.6 +lighting_shift_mean_delta = 45.0 trash_motion_delta = 18.0 trash_sustained_motion_delta = 8.0 trash_sustained_motion_frames = 2 @@ -208,6 +212,7 @@ diagnostics_path = "logs/runtime_diagnostics.jsonl" 运行诊断写入 `logs/runtime_diagnostics.jsonl`。每行包含顶层 `disposal_evidence`,以及 `diagnostics.trajectory`: - 顶层 `disposal_evidence`:本帧实际输出给状态机的来源区域到垃圾桶证据。 +- `diagnostics.lighting_shift`:多数区域同时同方向亮度漂移时启用,防止灯光/曝光变化被当成食品进出。 - `diagnostics.trajectory`:轻量轨迹 backend 的候选、过期、拒绝和已发出证据等调试信息。 ## 本地测试 diff --git a/config/example.toml b/config/example.toml index fa8c340..d78b80b 100644 --- a/config/example.toml +++ b/config/example.toml @@ -48,6 +48,10 @@ occupancy_reflection_dark_fraction = 0.10 occupancy_reflection_bright_dark_ratio = 2.0 occupancy_confirm_frames = 2 empty_confirm_frames = 2 +lighting_shift_guard_enabled = true +lighting_shift_min_regions = 3 +lighting_shift_region_fraction = 0.6 +lighting_shift_mean_delta = 45.0 trash_motion_delta = 18.0 trash_sustained_motion_delta = 8.0 trash_sustained_motion_frames = 2 diff --git a/docs/project.md b/docs/project.md index 2699bf5..44a960c 100644 --- a/docs/project.md +++ b/docs/project.md @@ -50,6 +50,8 @@ The `v1.2 轨迹识别` batch adds source-zone trajectory evidence for disposal - Stored under `[trash] roi`. - Does not use a food zone number. - v1.2 trajectory settings: + - `lighting_shift_guard_enabled`: freezes occupancy changes when many regions shift brightness in the same direction. + - `lighting_shift_min_regions`, `lighting_shift_region_fraction`, `lighting_shift_mean_delta`: tune the global lighting/exposure guard. - `trajectory_enabled`: enables source-zone trajectory evidence. - `trajectory_window_seconds`: seconds after a zone clears where movement can confirm disposal. - `trajectory_sample_interval_seconds`: faster runtime delay while a candidate is active. @@ -78,6 +80,7 @@ The current tracker is a motion backend only. A later trained YOLO detector shou - Runtime diagnostics JSONL records one item per runtime iteration. - Root `disposal_evidence` is the exact evidence list passed into the engine for that iteration. - `diagnostics.zones` contains occupancy metrics used to derive `zone_counts`. +- `diagnostics.lighting_shift` reports whether global brightness drift suppressed occupancy transitions. - `diagnostics.trash` contains generic trash-motion metrics and cooldown state. - `diagnostics.trajectory` contains v1.2 candidate counts, emitted evidence count, motion point count, and per-candidate emitted/rejected/expired records. - Capture failures still keep the v1.2 schema with root `disposal_evidence: []` and `diagnostics.trajectory.reason = "frame_capture_failed"`. @@ -122,6 +125,7 @@ In v1.2, `batch_discarded` can be triggered by zone-scoped `disposal_evidence` b ## Known Risks - The current vision detector is heuristic and reports binary occupancy, not item counts. +- The lighting-shift guard suppresses multi-zone brightness/exposure jumps; if operators intentionally fill most zones at once under a large lighting change, diagnostics should be reviewed before treating that interval as clean data. - v1.2 motion tracking improves disposal matching but can still miss movement if the hand/object path is occluded, too broad, too small, or sampled too sparsely. - YOLO config fields are present for compatibility, but no trained YOLO model is part of the current runtime. - If food is already present during baseline collection, those regions may be treated as empty baseline until visual changes occur. diff --git a/memories.md b/memories.md index b14944f..fc6938e 100644 --- a/memories.md +++ b/memories.md @@ -29,6 +29,7 @@ - Stage 2 added the lightweight motion trajectory runtime path: ROI occupancy still drives occupied/empty state, `TrajectoryTracker` emits source-zone-to-trash evidence, and generic trash-motion count remains as a fallback. - Stage 3 added diagnostics and tests for runtime evidence propagation, trajectory sampling interval behavior, capture-failure schema, and trajectory/yolo runtime config parsing. - Final review fixes: matched evidence now only subtracts the trash fallback budget by the number of batches it actually closed, and trajectory candidates reject outside-before-source motion with `motion_started_outside_source`. +- 2026-06-01 false events across zones 1-7 were caused by a global brightness/exposure drop around 04:55 and recovery around 08:16; the fix is a lighting-shift guard that freezes occupancy transitions when many regions shift brightness in the same direction. - Current v1.2 does not use YOLO. `yolo_enabled`, `yolo_model_path`, and `yolo_min_confidence` are reserved for a future trained model backend that should keep emitting the same `disposal_evidence` shape. ## Remote Deployment Notes diff --git a/src/cold_display_guard/vision.py b/src/cold_display_guard/vision.py index f30799b..7d13280 100644 --- a/src/cold_display_guard/vision.py +++ b/src/cold_display_guard/vision.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta +from math import ceil from typing import Any from cold_display_guard.models import DisposalEvidence @@ -39,6 +40,10 @@ class RuntimeVisionSettings: occupancy_reflection_bright_dark_ratio: float = 2.0 occupancy_confirm_frames: int = 2 empty_confirm_frames: int = 2 + lighting_shift_guard_enabled: bool = True + lighting_shift_min_regions: int = 3 + lighting_shift_region_fraction: float = 0.6 + lighting_shift_mean_delta: float = 45.0 trash_motion_delta: float = 18.0 trash_sustained_motion_delta: float = 8.0 trash_sustained_motion_frames: int = 2 @@ -125,7 +130,12 @@ class ZoneOccupancyDetector: self._update_baseline(metrics_by_region) zone_counts: dict[str, int] = {} - diagnostics: dict[str, Any] = {"zones": {}, "baseline_ready": self.baseline_ready} + lighting_shift = self._lighting_shift(metrics_by_region) + diagnostics: dict[str, Any] = { + "zones": {}, + "baseline_ready": self.baseline_ready, + "lighting_shift": lighting_shift, + } for region in self.regions: metrics = metrics_by_region[region.region_id] baseline = self._baseline.get(region.region_id) @@ -133,8 +143,12 @@ class ZoneOccupancyDetector: if baseline is not None: mean_delta = abs(metrics.mean_luma - baseline.mean_luma) texture_delta = metrics.texture - baseline.texture - raw_occupied = self._raw_occupied(metrics, baseline, mean_delta, texture_delta) - occupied = self._confirmed_occupancy(region.region_id, raw_occupied) + if lighting_shift["active"]: + raw_occupied = False + occupied = self._stable_occupancy.get(region.region_id, False) + else: + raw_occupied = self._raw_occupied(metrics, baseline, mean_delta, texture_delta) + occupied = self._confirmed_occupancy(region.region_id, raw_occupied) diagnostics["zones"][region.region_id] = { "mean_luma": round(metrics.mean_luma, 3), "baseline_mean_luma": round(baseline.mean_luma, 3), @@ -151,6 +165,7 @@ class ZoneOccupancyDetector: "occupied": occupied, "occupied_streak": self._occupied_streaks[region.region_id], "empty_streak": self._empty_streaks[region.region_id], + "lighting_shift_suppressed": lighting_shift["active"], } zone_counts[region.region_id] = 1 if occupied else 0 @@ -201,6 +216,60 @@ class ZoneOccupancyDetector: if len(samples) >= self.settings.baseline_frames: self._baseline[region_id] = average_metrics(samples) + def _lighting_shift(self, metrics_by_region: dict[str, RegionMetrics]) -> dict[str, Any]: + if not self.settings.lighting_shift_guard_enabled: + return self._lighting_shift_diagnostics(False, None, 0, 0, 0) + + eligible_region_count = 0 + darker_count = 0 + brighter_count = 0 + for region in self.regions: + metrics = metrics_by_region.get(region.region_id) + baseline = self._baseline.get(region.region_id) + if metrics is None or baseline is None: + continue + eligible_region_count += 1 + signed_delta = metrics.mean_luma - baseline.mean_luma + if abs(signed_delta) < self.settings.lighting_shift_mean_delta: + continue + if signed_delta < 0: + darker_count += 1 + else: + brighter_count += 1 + + required_regions = max( + self.settings.lighting_shift_min_regions, + ceil(eligible_region_count * self.settings.lighting_shift_region_fraction), + ) + active_direction: str | None = None + shifted_count = max(darker_count, brighter_count) + if eligible_region_count >= self.settings.lighting_shift_min_regions and shifted_count >= required_regions: + active_direction = "darker" if darker_count >= brighter_count else "brighter" + return self._lighting_shift_diagnostics( + active_direction is not None, + active_direction, + shifted_count, + eligible_region_count, + required_regions, + ) + + def _lighting_shift_diagnostics( + self, + active: bool, + direction: str | None, + shifted_regions: int, + eligible_regions: int, + required_regions: int, + ) -> dict[str, Any]: + return { + "active": active, + "direction": direction, + "shifted_regions": shifted_regions, + "eligible_regions": eligible_regions, + "required_regions": required_regions, + "mean_delta_threshold": self.settings.lighting_shift_mean_delta, + } + def _confirmed_occupancy(self, region_id: str, raw_occupied: bool) -> bool: if raw_occupied: self._occupied_streaks[region_id] = self._occupied_streaks.get(region_id, 0) + 1 @@ -635,6 +704,10 @@ def load_runtime_vision_settings(config: dict[str, Any]) -> RuntimeVisionSetting occupancy_reflection_bright_dark_ratio=float(runtime.get("occupancy_reflection_bright_dark_ratio", 2.0)), occupancy_confirm_frames=max(1, int(runtime.get("occupancy_confirm_frames", 2))), empty_confirm_frames=max(1, int(runtime.get("empty_confirm_frames", 2))), + lighting_shift_guard_enabled=bool(runtime.get("lighting_shift_guard_enabled", True)), + lighting_shift_min_regions=max(1, int(runtime.get("lighting_shift_min_regions", 3))), + lighting_shift_region_fraction=max(0.0, min(1.0, float(runtime.get("lighting_shift_region_fraction", 0.6)))), + lighting_shift_mean_delta=float(runtime.get("lighting_shift_mean_delta", 45.0)), trash_motion_delta=float(runtime.get("trash_motion_delta", 18.0)), trash_sustained_motion_delta=float(runtime.get("trash_sustained_motion_delta", 8.0)), trash_sustained_motion_frames=max(1, int(runtime.get("trash_sustained_motion_frames", 2))), diff --git a/tests/test_vision.py b/tests/test_vision.py index e18a579..b1bef4f 100644 --- a/tests/test_vision.py +++ b/tests/test_vision.py @@ -158,6 +158,66 @@ class VisionTests(unittest.TestCase): self.assertEqual(first_empty_counts, {"1": 1}) self.assertEqual(second_empty_counts, {"1": 0}) + def test_detector_ignores_global_lighting_dimming_across_many_zones(self) -> None: + regions = [ + Region(str(index + 1), ((index / 7, 0.0), ((index + 1) / 7, 0.0), ((index + 1) / 7, 1.0), (index / 7, 1.0))) + for index in range(7) + ] + detector = ZoneOccupancyDetector( + regions, + trash_region=None, + settings=RuntimeVisionSettings( + baseline_frames=1, + sample_stride_pixels=2, + occupancy_mean_delta=55, + occupancy_texture_delta=18, + occupancy_confirm_frames=2, + empty_confirm_frames=2, + ), + ) + now = datetime(2026, 6, 1, 4, 55, tzinfo=timezone.utc) + + detector.observe(solid_frame(70, 20, 180), now) + first_counts, _, first_diagnostics = detector.observe(solid_frame(70, 20, 100), now + timedelta(seconds=5)) + second_counts, _, second_diagnostics = detector.observe(solid_frame(70, 20, 100), now + timedelta(seconds=10)) + + self.assertEqual(first_counts, {str(index): 0 for index in range(1, 8)}) + self.assertEqual(second_counts, {str(index): 0 for index in range(1, 8)}) + self.assertTrue(first_diagnostics["lighting_shift"]["active"]) + self.assertTrue(second_diagnostics["lighting_shift"]["active"]) + self.assertTrue(all(not zone["raw_occupied"] for zone in second_diagnostics["zones"].values())) + + def test_detector_allows_single_zone_object_while_lighting_guard_is_available(self) -> None: + regions = [ + Region(str(index + 1), ((index / 7, 0.0), ((index + 1) / 7, 0.0), ((index + 1) / 7, 1.0), (index / 7, 1.0))) + for index in range(7) + ] + detector = ZoneOccupancyDetector( + regions, + trash_region=None, + settings=RuntimeVisionSettings( + baseline_frames=1, + sample_stride_pixels=2, + occupancy_mean_delta=55, + occupancy_texture_delta=100, + occupancy_dark_luma_threshold=80, + occupancy_dark_fraction=0.06, + occupancy_confirm_frames=2, + empty_confirm_frames=2, + ), + ) + now = datetime(2026, 6, 1, 10, 0, tzinfo=timezone.utc) + + detector.observe(solid_frame(70, 20, 180), now) + object_frame = patched_frame(70, 20, 180, (0, 0, 10, 20, 20)) + detector.observe(object_frame, now + timedelta(seconds=5)) + counts, _, diagnostics = detector.observe(object_frame, now + timedelta(seconds=10)) + + self.assertEqual(counts["1"], 1) + self.assertEqual(sum(counts.values()), 1) + self.assertFalse(diagnostics["lighting_shift"]["active"]) + self.assertTrue(diagnostics["zones"]["1"]["raw_occupied"]) + def test_runtime_vision_defaults_raise_brightness_reflection_threshold(self) -> None: settings = load_runtime_vision_settings({}) @@ -169,6 +229,10 @@ class VisionTests(unittest.TestCase): self.assertEqual(settings.trash_sustained_motion_delta, 8.0) self.assertEqual(settings.trash_sustained_motion_frames, 2) self.assertEqual(settings.trash_motion_cooldown_seconds, 3) + self.assertTrue(settings.lighting_shift_guard_enabled) + self.assertEqual(settings.lighting_shift_min_regions, 3) + self.assertAlmostEqual(settings.lighting_shift_region_fraction, 0.6) + self.assertEqual(settings.lighting_shift_mean_delta, 45.0) def test_detector_can_seed_previous_baseline_and_occupancy(self) -> None: detector = ZoneOccupancyDetector( @@ -596,6 +660,10 @@ class VisionTests(unittest.TestCase): "yolo_enabled": True, "yolo_model_path": "models/yolo.onnx", "yolo_min_confidence": 0.7, + "lighting_shift_guard_enabled": False, + "lighting_shift_min_regions": 4, + "lighting_shift_region_fraction": 0.75, + "lighting_shift_mean_delta": 60.0, } } ) @@ -613,6 +681,10 @@ class VisionTests(unittest.TestCase): self.assertTrue(settings.yolo_enabled) self.assertEqual(settings.yolo_model_path, "models/yolo.onnx") self.assertEqual(settings.yolo_min_confidence, 0.7) + self.assertFalse(settings.lighting_shift_guard_enabled) + self.assertEqual(settings.lighting_shift_min_regions, 4) + self.assertAlmostEqual(settings.lighting_shift_region_fraction, 0.75) + self.assertEqual(settings.lighting_shift_mean_delta, 60.0) if __name__ == "__main__":