feat: allow segmented disposal trajectories

This commit is contained in:
Yoilun
2026-06-01 11:06:42 +08:00
parent 1ecf881684
commit 03bc7085ea
6 changed files with 81 additions and 5 deletions

View File

@@ -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 的候选、过期、拒绝、分段轨迹和已发出证据等调试信息。
## 本地测试

View File

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

View File

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

View File

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

View File

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

View File

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