fix: seed trajectories from recent source motion

This commit is contained in:
Yoilun
2026-06-01 12:03:05 +08:00
parent 03bc7085ea
commit a4657a4bdf
5 changed files with 75 additions and 5 deletions

View File

@@ -215,7 +215,7 @@ diagnostics_path = "logs/runtime_diagnostics.jsonl"
- 顶层 `disposal_evidence`:本帧实际输出给状态机的来源区域到垃圾桶证据。
- `diagnostics.lighting_shift`:多数区域同时同方向亮度漂移时启用,防止灯光/曝光变化被当成食品进出。
- `diagnostics.trajectory`:轻量轨迹 backend 的候选、过期、拒绝、分段轨迹和已发出证据等调试信息。
- `diagnostics.trajectory`:轻量轨迹 backend 的候选、来源 motion 预缓存、过期、拒绝、分段轨迹和已发出证据等调试信息。
## 本地测试

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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)))