diff --git a/README_zh.md b/README_zh.md index b6ef9a6..8b50f73 100644 --- a/README_zh.md +++ b/README_zh.md @@ -195,6 +195,8 @@ trajectory_enabled = true trajectory_window_seconds = 8 trajectory_sample_interval_seconds = 1.0 trajectory_min_points = 3 +trajectory_segmented_enabled = true +trajectory_segmented_min_points = 2 trajectory_min_confidence = 0.72 trajectory_motion_delta = 20.0 trajectory_min_blob_area = 12 @@ -213,7 +215,7 @@ diagnostics_path = "logs/runtime_diagnostics.jsonl" - 顶层 `disposal_evidence`:本帧实际输出给状态机的来源区域到垃圾桶证据。 - `diagnostics.lighting_shift`:多数区域同时同方向亮度漂移时启用,防止灯光/曝光变化被当成食品进出。 -- `diagnostics.trajectory`:轻量轨迹 backend 的候选、过期、拒绝和已发出证据等调试信息。 +- `diagnostics.trajectory`:轻量轨迹 backend 的候选、过期、拒绝、分段轨迹和已发出证据等调试信息。 ## 本地测试 diff --git a/config/example.toml b/config/example.toml index d78b80b..03bf7e9 100644 --- a/config/example.toml +++ b/config/example.toml @@ -60,6 +60,8 @@ trajectory_enabled = true trajectory_window_seconds = 8 trajectory_sample_interval_seconds = 1.0 trajectory_min_points = 3 +trajectory_segmented_enabled = true +trajectory_segmented_min_points = 2 trajectory_min_confidence = 0.72 trajectory_motion_delta = 20.0 trajectory_min_blob_area = 12 diff --git a/docs/project.md b/docs/project.md index 44a960c..b6c1e6f 100644 --- a/docs/project.md +++ b/docs/project.md @@ -56,6 +56,7 @@ The `v1.2 轨迹识别` batch adds source-zone trajectory evidence for disposal - `trajectory_window_seconds`: seconds after a zone clears where movement can confirm disposal. - `trajectory_sample_interval_seconds`: faster runtime delay while a candidate is active. - `trajectory_min_points`: minimum sampled motion points required before evidence can emit. + - `trajectory_segmented_enabled`, `trajectory_segmented_min_points`: allow a source point plus trash-entry point to confirm disposal when the middle of the path is occluded. - `trajectory_min_confidence`: minimum confidence before evidence can close pending disposal. - `trajectory_motion_delta`: frame-difference threshold for trajectory motion points. - `trajectory_min_blob_area`: minimum connected motion area to keep as a point. @@ -68,7 +69,7 @@ The `v1.2 轨迹识别` batch adds source-zone trajectory evidence for disposal 1. `RTSPFrameSource` captures a resized RGB frame. 2. `ZoneOccupancyDetector` updates per-zone binary occupancy and generic trash-motion count from calibrated ROIs. -3. `TrajectoryTracker` watches zones that just cleared, follows lightweight motion points toward the trash ROI, and emits source-specific `DisposalEvidence` when confidence passes the configured threshold. +3. `TrajectoryTracker` watches zones that just cleared, follows lightweight motion points toward the trash ROI, and emits source-specific `DisposalEvidence` when confidence passes the configured threshold. If the middle of the path is occluded, a segmented source-to-trash track can still emit evidence. 4. `BatchEngine` processes `Observation(zone_counts, trash_deposit_count, disposal_evidence)`. 5. For pending disposal, matching `disposal_evidence.source_zone_id` confirms `batch_discarded` before generic FIFO `trash_deposit_count` fallback is used. 6. Runtime writes events to `logs/events.jsonl` and diagnostics to `logs/runtime_diagnostics.jsonl`. @@ -82,7 +83,7 @@ The current tracker is a motion backend only. A later trained YOLO detector shou - `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. +- `diagnostics.trajectory` contains v1.2 candidate counts, emitted evidence count, motion point count, segmented-track flags, and per-candidate emitted/rejected/expired records. - Capture failures still keep the v1.2 schema with root `disposal_evidence: []` and `diagnostics.trajectory.reason = "frame_capture_failed"`. ## Event Model diff --git a/memories.md b/memories.md index fc6938e..9c45c36 100644 --- a/memories.md +++ b/memories.md @@ -30,6 +30,7 @@ - Stage 3 added diagnostics and tests for runtime evidence propagation, trajectory sampling interval behavior, capture-failure schema, and trajectory/yolo runtime config parsing. - Final review fixes: matched evidence now only subtracts the trash fallback budget by the number of batches it actually closed, and trajectory candidates reject outside-before-source motion with `motion_started_outside_source`. - 2026-06-01 false events across zones 1-7 were caused by a global brightness/exposure drop around 04:55 and recovery around 08:16; the fix is a lighting-shift guard that freezes occupancy transitions when many regions shift brightness in the same direction. +- 2026-06-01 trajectory update allows segmented source-to-trash tracks: after a source-zone motion point is seen, the middle of the path may be occluded, and a later trash-entry point can still emit `disposal_evidence` when direction/confidence pass. - 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 7d13280..90b4554 100644 --- a/src/cold_display_guard/vision.py +++ b/src/cold_display_guard/vision.py @@ -52,6 +52,8 @@ class RuntimeVisionSettings: trajectory_window_seconds: int = 8 trajectory_sample_interval_seconds: float = 1.0 trajectory_min_points: int = 3 + trajectory_segmented_enabled: bool = True + trajectory_segmented_min_points: int = 2 trajectory_min_confidence: float = 0.72 trajectory_motion_delta: float = 20.0 trajectory_min_blob_area: int = 12 @@ -596,7 +598,7 @@ class TrajectoryTracker: return ( candidate.source_motion_seen and self._candidate_reached_trash(candidate) - and len(candidate.points or []) >= self.settings.trajectory_min_points + and self._has_enough_track_points(candidate) and self._direction_score(candidate) >= 0.35 and confidence >= self.settings.trajectory_min_confidence ) @@ -606,7 +608,7 @@ class TrajectoryTracker: return "missing_source_motion" if not self._candidate_reached_trash(candidate): return "did_not_reach_trash" - if len(candidate.points or []) < self.settings.trajectory_min_points: + if not self._has_enough_track_points(candidate): return "insufficient_points" if self._direction_score(candidate) < 0.35: return "bad_direction" @@ -614,6 +616,17 @@ class TrajectoryTracker: return "low_confidence" return "rejected" + def _has_enough_track_points(self, candidate: _TrajectoryCandidate) -> bool: + point_count = len(candidate.points or []) + if point_count >= self.settings.trajectory_min_points: + return True + return ( + self.settings.trajectory_segmented_enabled + and point_count >= self.settings.trajectory_segmented_min_points + and candidate.source_motion_seen + and self._candidate_reached_trash(candidate) + ) + def _candidate_event(self, candidate: _TrajectoryCandidate, reason: str) -> dict[str, Any]: return { "source_zone_id": candidate.source_region.region_id, @@ -621,8 +634,18 @@ class TrajectoryTracker: "point_count": len(candidate.points or []), "confidence": round(self._confidence(candidate), 3), "direction_score": round(self._direction_score(candidate), 3), + "segmented": self._is_segmented_track(candidate), } + def _is_segmented_track(self, candidate: _TrajectoryCandidate) -> bool: + point_count = len(candidate.points or []) + return ( + self.settings.trajectory_segmented_enabled + and self.settings.trajectory_segmented_min_points <= point_count < self.settings.trajectory_min_points + and candidate.source_motion_seen + and self._candidate_reached_trash(candidate) + ) + def _track_signature(self, candidate: _TrajectoryCandidate) -> tuple[tuple[float, float], ...]: return tuple((round(point.x, 4), round(point.y, 4)) for point in candidate.points or []) @@ -716,6 +739,8 @@ def load_runtime_vision_settings(config: dict[str, Any]) -> RuntimeVisionSetting trajectory_window_seconds=max(1, int(runtime.get("trajectory_window_seconds", 8))), trajectory_sample_interval_seconds=max(0.0, float(runtime.get("trajectory_sample_interval_seconds", 1.0))), trajectory_min_points=max(1, int(runtime.get("trajectory_min_points", 3))), + trajectory_segmented_enabled=bool(runtime.get("trajectory_segmented_enabled", True)), + trajectory_segmented_min_points=max(2, int(runtime.get("trajectory_segmented_min_points", 2))), trajectory_min_confidence=float(runtime.get("trajectory_min_confidence", 0.72)), trajectory_motion_delta=float(runtime.get("trajectory_motion_delta", 20.0)), trajectory_min_blob_area=max(1, int(runtime.get("trajectory_min_blob_area", 12))), diff --git a/tests/test_vision.py b/tests/test_vision.py index b1bef4f..33ee856 100644 --- a/tests/test_vision.py +++ b/tests/test_vision.py @@ -378,6 +378,45 @@ class VisionTests(unittest.TestCase): self.assertGreaterEqual(len(emitted.track_points), 3) self.assertGreaterEqual(emitted_evidence_count, 1) + def test_segmented_source_to_trash_track_survives_temporary_occlusion(self) -> None: + source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65))) + trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65))) + tracker = TrajectoryTracker( + [source], + trash, + RuntimeVisionSettings( + trajectory_sample_interval_seconds=0.0, + trajectory_min_points=3, + trajectory_min_confidence=0.72, + trajectory_motion_delta=20.0, + trajectory_min_blob_area=12, + ), + ) + now = datetime(2026, 6, 1, 10, 55, tzinfo=timezone.utc) + + tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1}) + source_motion_frame = frame_with_motion_patch(80, 80, (16, 36)) + first_evidence, _ = tracker.observe(source_motion_frame, now + timedelta(seconds=1), {"source": 0}) + occluded_evidence, occluded_diagnostics = tracker.observe(source_motion_frame, now + timedelta(seconds=2), {"source": 0}) + trash_evidence, trash_diagnostics = tracker.observe( + frame_with_motion_patch(80, 80, (66, 36)), + now + timedelta(seconds=3), + {"source": 0}, + ) + + self.assertEqual(first_evidence, []) + self.assertEqual(occluded_evidence, []) + self.assertEqual(occluded_diagnostics["active_candidates"], 1) + self.assertEqual(len(trash_evidence), 1) + emitted = trash_evidence[0] + self.assertEqual(emitted.source_zone_id, "source") + self.assertEqual(emitted.target, "trash") + self.assertEqual(emitted.method, "motion") + self.assertEqual(len(emitted.track_points), 2) + self.assertGreaterEqual(emitted.confidence, 0.72) + self.assertEqual(trash_diagnostics["emitted"][0]["reason"], "emitted") + self.assertTrue(trash_diagnostics["emitted"][0]["segmented"]) + def test_motion_that_starts_away_from_source_zone_is_rejected(self) -> None: source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65))) trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65))) @@ -633,6 +672,8 @@ class VisionTests(unittest.TestCase): self.assertEqual(settings.trajectory_window_seconds, 8) self.assertEqual(settings.trajectory_sample_interval_seconds, 1.0) self.assertEqual(settings.trajectory_min_points, 3) + self.assertTrue(settings.trajectory_segmented_enabled) + self.assertEqual(settings.trajectory_segmented_min_points, 2) self.assertEqual(settings.trajectory_min_confidence, 0.72) self.assertEqual(settings.trajectory_motion_delta, 20.0) self.assertEqual(settings.trajectory_min_blob_area, 12) @@ -651,6 +692,8 @@ class VisionTests(unittest.TestCase): "trajectory_window_seconds": 11, "trajectory_sample_interval_seconds": 0.5, "trajectory_min_points": 4, + "trajectory_segmented_enabled": False, + "trajectory_segmented_min_points": 3, "trajectory_min_confidence": 0.8, "trajectory_motion_delta": 25.0, "trajectory_min_blob_area": 20, @@ -672,6 +715,8 @@ class VisionTests(unittest.TestCase): self.assertEqual(settings.trajectory_window_seconds, 11) self.assertEqual(settings.trajectory_sample_interval_seconds, 0.5) self.assertEqual(settings.trajectory_min_points, 4) + self.assertFalse(settings.trajectory_segmented_enabled) + self.assertEqual(settings.trajectory_segmented_min_points, 3) self.assertEqual(settings.trajectory_min_confidence, 0.8) self.assertEqual(settings.trajectory_motion_delta, 25.0) self.assertEqual(settings.trajectory_min_blob_area, 20)