From a4657a4bdf3dc7c97ef39ceadbd373b29981fe5a Mon Sep 17 00:00:00 2001 From: Yoilun Date: Mon, 1 Jun 2026 12:03:05 +0800 Subject: [PATCH] fix: seed trajectories from recent source motion --- README_zh.md | 2 +- docs/project.md | 4 +-- memories.md | 1 + src/cold_display_guard/vision.py | 43 ++++++++++++++++++++++++++++++-- tests/test_vision.py | 30 ++++++++++++++++++++++ 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/README_zh.md b/README_zh.md index 8b50f73..6f022c0 100644 --- a/README_zh.md +++ b/README_zh.md @@ -215,7 +215,7 @@ diagnostics_path = "logs/runtime_diagnostics.jsonl" - 顶层 `disposal_evidence`:本帧实际输出给状态机的来源区域到垃圾桶证据。 - `diagnostics.lighting_shift`:多数区域同时同方向亮度漂移时启用,防止灯光/曝光变化被当成食品进出。 -- `diagnostics.trajectory`:轻量轨迹 backend 的候选、过期、拒绝、分段轨迹和已发出证据等调试信息。 +- `diagnostics.trajectory`:轻量轨迹 backend 的候选、来源 motion 预缓存、过期、拒绝、分段轨迹和已发出证据等调试信息。 ## 本地测试 diff --git a/docs/project.md b/docs/project.md index b6c1e6f..cbbea9c 100644 --- a/docs/project.md +++ b/docs/project.md @@ -69,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. If the middle of the path is occluded, a segmented source-to-trash track can still emit evidence. +3. `TrajectoryTracker` caches recent source-region motion while a zone is occupied, watches zones that just cleared, follows lightweight motion points toward the trash ROI, and emits source-specific `DisposalEvidence` when confidence passes the configured threshold. If the middle of the path is occluded, a segmented source-to-trash track can still emit evidence. 4. `BatchEngine` processes `Observation(zone_counts, trash_deposit_count, disposal_evidence)`. 5. For pending disposal, matching `disposal_evidence.source_zone_id` confirms `batch_discarded` before generic FIFO `trash_deposit_count` fallback is used. 6. Runtime writes events to `logs/events.jsonl` and diagnostics to `logs/runtime_diagnostics.jsonl`. @@ -83,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, segmented-track flags, and per-candidate emitted/rejected/expired records. +- `diagnostics.trajectory` contains v1.2 candidate counts, emitted evidence count, motion point count, source-seeded and segmented-track flags, and per-candidate emitted/rejected/expired records. - Capture failures still keep the v1.2 schema with root `disposal_evidence: []` and `diagnostics.trajectory.reason = "frame_capture_failed"`. ## Event Model diff --git a/memories.md b/memories.md index 9c45c36..848af46 100644 --- a/memories.md +++ b/memories.md @@ -31,6 +31,7 @@ - Final review fixes: matched evidence now only subtracts the trash fallback budget by the number of batches it actually closed, and trajectory candidates reject outside-before-source motion with `motion_started_outside_source`. - 2026-06-01 false events across zones 1-7 were caused by a global brightness/exposure drop around 04:55 and recovery around 08:16; the fix is a lighting-shift guard that freezes occupancy transitions when many regions shift brightness in the same direction. - 2026-06-01 trajectory update allows segmented source-to-trash tracks: after a source-zone motion point is seen, the middle of the path may be occluded, and a later trash-entry point can still emit `disposal_evidence` when direction/confidence pass. +- 2026-06-01 follow-up fixed empty-confirmation lag: `TrajectoryTracker` now caches recent source-region motion while a zone is still occupied and seeds the disposal candidate when the stable zone count finally clears. - Current v1.2 does not use YOLO. `yolo_enabled`, `yolo_model_path`, and `yolo_min_confidence` are reserved for a future trained model backend that should keep emitting the same `disposal_evidence` shape. ## Remote Deployment Notes diff --git a/src/cold_display_guard/vision.py b/src/cold_display_guard/vision.py index 90b4554..753d409 100644 --- a/src/cold_display_guard/vision.py +++ b/src/cold_display_guard/vision.py @@ -91,6 +91,8 @@ class _TrajectoryCandidate: points: list[_MotionPoint] | None = None source_motion_seen: bool = False pre_source_motion_seen: bool = False + source_seeded: bool = False + forced_rejection_reason: str | None = None def __post_init__(self) -> None: if self.points is None: @@ -334,6 +336,8 @@ class TrajectoryTracker: self._previous_frame: Frame | None = None self._previous_zone_counts: dict[str, int] = {} self._candidates: list[_TrajectoryCandidate] = [] + self._recent_source_motion: dict[str, _MotionPoint] = {} + self._emitted_track_signatures: set[tuple[tuple[float, float], ...]] = set() @property def has_active_candidates(self) -> bool: @@ -373,12 +377,13 @@ class TrajectoryTracker: return [], diagnostics blobs = self._motion_points(frame, when) if self._previous_frame is not None else [] + self._remember_recent_source_motion(blobs, when, zone_counts) self._open_candidates(when, zone_counts) emitted: list[DisposalEvidence] = [] remaining: list[_TrajectoryCandidate] = [] consumed_blob_ids: set[int] = set() - emitted_track_signatures: set[tuple[tuple[float, float], ...]] = set() + emitted_track_signatures = set(self._emitted_track_signatures) for candidate in self._candidates: rejected_reason = self._sample_candidate(candidate, blobs, when, consumed_blob_ids) if rejected_reason is not None: @@ -399,6 +404,7 @@ class TrajectoryTracker: else: emitted.append(self._evidence(candidate, when)) emitted_track_signatures.add(signature) + self._emitted_track_signatures.add(signature) diagnostics["emitted"].append(self._candidate_event(candidate, "emitted")) continue if self._candidate_reached_trash(candidate): @@ -410,6 +416,7 @@ class TrajectoryTracker: else: emitted.append(self._evidence(candidate, when)) emitted_track_signatures.add(signature) + self._emitted_track_signatures.add(signature) diagnostics["emitted"].append(self._candidate_event(candidate, "emitted")) else: diagnostics["rejected_candidates"] += 1 @@ -418,6 +425,8 @@ class TrajectoryTracker: remaining.append(candidate) self._candidates = remaining + if not self._candidates: + self._emitted_track_signatures.clear() diagnostics["emitted_evidence"] = len(emitted) diagnostics["active_candidates"] = len(self._candidates) diagnostics["motion_points"] = len(blobs) @@ -430,11 +439,38 @@ class TrajectoryTracker: def _open_candidates(self, when: datetime, zone_counts: dict[str, int]) -> None: active_region_ids = {candidate.source_region.region_id for candidate in self._candidates} + used_seed_signatures: set[tuple[float, float, str]] = set() for region in self.regions: previous = self._previous_zone_counts.get(region.region_id, 0) current = max(0, int(zone_counts.get(region.region_id, 0))) if previous > 0 and current == 0 and region.region_id not in active_region_ids: - self._candidates.append(_TrajectoryCandidate(source_region=region, opened_at=when)) + candidate = _TrajectoryCandidate(source_region=region, opened_at=when) + recent = self._recent_source_motion.get(region.region_id) + if recent is not None and when - recent.when <= timedelta(seconds=self.settings.trajectory_window_seconds): + signature = (round(recent.x, 4), round(recent.y, 4), recent.when.isoformat()) + if signature in used_seed_signatures: + candidate.forced_rejection_reason = "ambiguous_motion_track" + else: + used_seed_signatures.add(signature) + candidate.points.append(recent) + candidate.source_motion_seen = True + candidate.source_seeded = True + candidate.last_sample_at = recent.when + self._candidates.append(candidate) + self._recent_source_motion.pop(region.region_id, None) + + def _remember_recent_source_motion(self, blobs: list[_MotionPoint], when: datetime, zone_counts: dict[str, int]) -> None: + if not blobs: + return + for region in self.regions: + previous = self._previous_zone_counts.get(region.region_id, 0) + current = max(0, int(zone_counts.get(region.region_id, 0))) + if previous <= 0 and current <= 0: + continue + source_blobs = [blob for blob in blobs if region_contains(region, blob.x, blob.y)] + if not source_blobs: + continue + self._recent_source_motion[region.region_id] = _nearest_point(region_center(region), source_blobs) def _motion_points(self, frame: Frame, when: datetime) -> list[_MotionPoint]: previous = self._previous_frame @@ -512,6 +548,8 @@ class TrajectoryTracker: when: datetime, consumed_blob_ids: set[int], ) -> str | None: + if candidate.forced_rejection_reason is not None: + return candidate.forced_rejection_reason if not blobs: return None if ( @@ -635,6 +673,7 @@ class TrajectoryTracker: "confidence": round(self._confidence(candidate), 3), "direction_score": round(self._direction_score(candidate), 3), "segmented": self._is_segmented_track(candidate), + "source_seeded": candidate.source_seeded, } def _is_segmented_track(self, candidate: _TrajectoryCandidate) -> bool: diff --git a/tests/test_vision.py b/tests/test_vision.py index 33ee856..6b8d976 100644 --- a/tests/test_vision.py +++ b/tests/test_vision.py @@ -417,6 +417,36 @@ class VisionTests(unittest.TestCase): self.assertEqual(trash_diagnostics["emitted"][0]["reason"], "emitted") self.assertTrue(trash_diagnostics["emitted"][0]["segmented"]) + def test_recent_source_motion_seeds_track_when_empty_confirmation_lags(self) -> None: + source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65))) + trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65))) + tracker = TrajectoryTracker( + [source], + trash, + RuntimeVisionSettings( + trajectory_sample_interval_seconds=0.0, + trajectory_min_points=3, + trajectory_min_confidence=0.72, + trajectory_motion_delta=20.0, + trajectory_min_blob_area=12, + ), + ) + now = datetime(2026, 6, 1, 11, 25, tzinfo=timezone.utc) + + tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1}) + tracker.observe(frame_with_motion_patch(80, 80, (16, 36)), now + timedelta(seconds=1), {"source": 1}) + evidence, diagnostics = tracker.observe( + frame_with_motion_patch(80, 80, (66, 36)), + now + timedelta(seconds=2), + {"source": 0}, + ) + + self.assertEqual(len(evidence), 1) + self.assertEqual(evidence[0].source_zone_id, "source") + self.assertEqual(len(evidence[0].track_points), 2) + self.assertTrue(diagnostics["emitted"][0]["segmented"]) + self.assertTrue(diagnostics["emitted"][0]["source_seeded"]) + def test_motion_that_starts_away_from_source_zone_is_rejected(self) -> None: source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65))) trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))