fix: seed trajectories from recent source motion
This commit is contained in:
@@ -215,7 +215,7 @@ diagnostics_path = "logs/runtime_diagnostics.jsonl"
|
|||||||
|
|
||||||
- 顶层 `disposal_evidence`:本帧实际输出给状态机的来源区域到垃圾桶证据。
|
- 顶层 `disposal_evidence`:本帧实际输出给状态机的来源区域到垃圾桶证据。
|
||||||
- `diagnostics.lighting_shift`:多数区域同时同方向亮度漂移时启用,防止灯光/曝光变化被当成食品进出。
|
- `diagnostics.lighting_shift`:多数区域同时同方向亮度漂移时启用,防止灯光/曝光变化被当成食品进出。
|
||||||
- `diagnostics.trajectory`:轻量轨迹 backend 的候选、过期、拒绝、分段轨迹和已发出证据等调试信息。
|
- `diagnostics.trajectory`:轻量轨迹 backend 的候选、来源 motion 预缓存、过期、拒绝、分段轨迹和已发出证据等调试信息。
|
||||||
|
|
||||||
## 本地测试
|
## 本地测试
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ The `v1.2 轨迹识别` batch adds source-zone trajectory evidence for disposal
|
|||||||
|
|
||||||
1. `RTSPFrameSource` captures a resized RGB frame.
|
1. `RTSPFrameSource` captures a resized RGB frame.
|
||||||
2. `ZoneOccupancyDetector` updates per-zone binary occupancy and generic trash-motion count from calibrated ROIs.
|
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)`.
|
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.
|
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`.
|
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.zones` contains occupancy metrics used to derive `zone_counts`.
|
||||||
- `diagnostics.lighting_shift` reports whether global brightness drift suppressed occupancy transitions.
|
- `diagnostics.lighting_shift` reports whether global brightness drift suppressed occupancy transitions.
|
||||||
- `diagnostics.trash` contains generic trash-motion metrics and cooldown state.
|
- `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"`.
|
- Capture failures still keep the v1.2 schema with root `disposal_evidence: []` and `diagnostics.trajectory.reason = "frame_capture_failed"`.
|
||||||
|
|
||||||
## Event Model
|
## Event Model
|
||||||
|
|||||||
@@ -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`.
|
- 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 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 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.
|
- 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
|
## Remote Deployment Notes
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ class _TrajectoryCandidate:
|
|||||||
points: list[_MotionPoint] | None = None
|
points: list[_MotionPoint] | None = None
|
||||||
source_motion_seen: bool = False
|
source_motion_seen: bool = False
|
||||||
pre_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:
|
def __post_init__(self) -> None:
|
||||||
if self.points is None:
|
if self.points is None:
|
||||||
@@ -334,6 +336,8 @@ class TrajectoryTracker:
|
|||||||
self._previous_frame: Frame | None = None
|
self._previous_frame: Frame | None = None
|
||||||
self._previous_zone_counts: dict[str, int] = {}
|
self._previous_zone_counts: dict[str, int] = {}
|
||||||
self._candidates: list[_TrajectoryCandidate] = []
|
self._candidates: list[_TrajectoryCandidate] = []
|
||||||
|
self._recent_source_motion: dict[str, _MotionPoint] = {}
|
||||||
|
self._emitted_track_signatures: set[tuple[tuple[float, float], ...]] = set()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_active_candidates(self) -> bool:
|
def has_active_candidates(self) -> bool:
|
||||||
@@ -373,12 +377,13 @@ class TrajectoryTracker:
|
|||||||
return [], diagnostics
|
return [], diagnostics
|
||||||
|
|
||||||
blobs = self._motion_points(frame, when) if self._previous_frame is not None else []
|
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)
|
self._open_candidates(when, zone_counts)
|
||||||
|
|
||||||
emitted: list[DisposalEvidence] = []
|
emitted: list[DisposalEvidence] = []
|
||||||
remaining: list[_TrajectoryCandidate] = []
|
remaining: list[_TrajectoryCandidate] = []
|
||||||
consumed_blob_ids: set[int] = set()
|
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:
|
for candidate in self._candidates:
|
||||||
rejected_reason = self._sample_candidate(candidate, blobs, when, consumed_blob_ids)
|
rejected_reason = self._sample_candidate(candidate, blobs, when, consumed_blob_ids)
|
||||||
if rejected_reason is not None:
|
if rejected_reason is not None:
|
||||||
@@ -399,6 +404,7 @@ class TrajectoryTracker:
|
|||||||
else:
|
else:
|
||||||
emitted.append(self._evidence(candidate, when))
|
emitted.append(self._evidence(candidate, when))
|
||||||
emitted_track_signatures.add(signature)
|
emitted_track_signatures.add(signature)
|
||||||
|
self._emitted_track_signatures.add(signature)
|
||||||
diagnostics["emitted"].append(self._candidate_event(candidate, "emitted"))
|
diagnostics["emitted"].append(self._candidate_event(candidate, "emitted"))
|
||||||
continue
|
continue
|
||||||
if self._candidate_reached_trash(candidate):
|
if self._candidate_reached_trash(candidate):
|
||||||
@@ -410,6 +416,7 @@ class TrajectoryTracker:
|
|||||||
else:
|
else:
|
||||||
emitted.append(self._evidence(candidate, when))
|
emitted.append(self._evidence(candidate, when))
|
||||||
emitted_track_signatures.add(signature)
|
emitted_track_signatures.add(signature)
|
||||||
|
self._emitted_track_signatures.add(signature)
|
||||||
diagnostics["emitted"].append(self._candidate_event(candidate, "emitted"))
|
diagnostics["emitted"].append(self._candidate_event(candidate, "emitted"))
|
||||||
else:
|
else:
|
||||||
diagnostics["rejected_candidates"] += 1
|
diagnostics["rejected_candidates"] += 1
|
||||||
@@ -418,6 +425,8 @@ class TrajectoryTracker:
|
|||||||
remaining.append(candidate)
|
remaining.append(candidate)
|
||||||
|
|
||||||
self._candidates = remaining
|
self._candidates = remaining
|
||||||
|
if not self._candidates:
|
||||||
|
self._emitted_track_signatures.clear()
|
||||||
diagnostics["emitted_evidence"] = len(emitted)
|
diagnostics["emitted_evidence"] = len(emitted)
|
||||||
diagnostics["active_candidates"] = len(self._candidates)
|
diagnostics["active_candidates"] = len(self._candidates)
|
||||||
diagnostics["motion_points"] = len(blobs)
|
diagnostics["motion_points"] = len(blobs)
|
||||||
@@ -430,11 +439,38 @@ class TrajectoryTracker:
|
|||||||
|
|
||||||
def _open_candidates(self, when: datetime, zone_counts: dict[str, int]) -> None:
|
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}
|
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:
|
for region in self.regions:
|
||||||
previous = self._previous_zone_counts.get(region.region_id, 0)
|
previous = self._previous_zone_counts.get(region.region_id, 0)
|
||||||
current = max(0, int(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:
|
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]:
|
def _motion_points(self, frame: Frame, when: datetime) -> list[_MotionPoint]:
|
||||||
previous = self._previous_frame
|
previous = self._previous_frame
|
||||||
@@ -512,6 +548,8 @@ class TrajectoryTracker:
|
|||||||
when: datetime,
|
when: datetime,
|
||||||
consumed_blob_ids: set[int],
|
consumed_blob_ids: set[int],
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
|
if candidate.forced_rejection_reason is not None:
|
||||||
|
return candidate.forced_rejection_reason
|
||||||
if not blobs:
|
if not blobs:
|
||||||
return None
|
return None
|
||||||
if (
|
if (
|
||||||
@@ -635,6 +673,7 @@ class TrajectoryTracker:
|
|||||||
"confidence": round(self._confidence(candidate), 3),
|
"confidence": round(self._confidence(candidate), 3),
|
||||||
"direction_score": round(self._direction_score(candidate), 3),
|
"direction_score": round(self._direction_score(candidate), 3),
|
||||||
"segmented": self._is_segmented_track(candidate),
|
"segmented": self._is_segmented_track(candidate),
|
||||||
|
"source_seeded": candidate.source_seeded,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _is_segmented_track(self, candidate: _TrajectoryCandidate) -> bool:
|
def _is_segmented_track(self, candidate: _TrajectoryCandidate) -> bool:
|
||||||
|
|||||||
@@ -417,6 +417,36 @@ class VisionTests(unittest.TestCase):
|
|||||||
self.assertEqual(trash_diagnostics["emitted"][0]["reason"], "emitted")
|
self.assertEqual(trash_diagnostics["emitted"][0]["reason"], "emitted")
|
||||||
self.assertTrue(trash_diagnostics["emitted"][0]["segmented"])
|
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:
|
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)))
|
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)))
|
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
|
||||||
|
|||||||
Reference in New Issue
Block a user