feat(perception): canonicalize temporal motion
This commit is contained in:
@@ -42,6 +42,16 @@ GEOMETRY_RUNTIME_MODULES = (
|
||||
"providers.py",
|
||||
"recorded_source.py",
|
||||
)
|
||||
TEMPORAL_RUNTIME_MODULES = (
|
||||
"contracts.py",
|
||||
"geometry.py",
|
||||
"motion.py",
|
||||
"providers.py",
|
||||
"recorded_source.py",
|
||||
"temporal.py",
|
||||
"temporal_replay.py",
|
||||
"temporal_replay_cli.py",
|
||||
)
|
||||
|
||||
|
||||
def _imports(path: Path) -> set[str]:
|
||||
@@ -202,6 +212,18 @@ def test_geometry_runtime_closure_imports_no_legacy_compute_or_device_package()
|
||||
assert {name: modules for name, modules in violations.items() if modules} == {}
|
||||
|
||||
|
||||
def test_temporal_runtime_closure_imports_no_legacy_compute_or_device_package() -> None:
|
||||
violations = {
|
||||
name: sorted(
|
||||
module
|
||||
for module in _imports(PERCEPTION_ROOT / name)
|
||||
if module.startswith(("k1link.compute", "k1link.device_plugins"))
|
||||
)
|
||||
for name in TEMPORAL_RUNTIME_MODULES
|
||||
}
|
||||
assert {name: modules for name, modules in violations.items() if modules} == {}
|
||||
|
||||
|
||||
def test_new_perception_boundary_has_no_experiment_specific_imports() -> None:
|
||||
violations: dict[str, str] = {}
|
||||
for path in PERCEPTION_ROOT.glob("*.py"):
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.perception.contracts import (
|
||||
ClockBasis,
|
||||
EvidenceBasis,
|
||||
EvidenceCurrentness,
|
||||
MetricGeometry,
|
||||
ModalityOutcome,
|
||||
ModalityStatus,
|
||||
MotionState,
|
||||
ObstacleObservation,
|
||||
SourceEnvelope,
|
||||
TemporalState,
|
||||
TimestampBundle,
|
||||
)
|
||||
from k1link.perception.motion import ClassIndependentMotionEstimator
|
||||
from k1link.perception.providers import SourcePacket
|
||||
from k1link.perception.temporal import (
|
||||
BoundedSpatialTemporalProvider,
|
||||
load_temporal_motion_profile,
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
PROFILE_PATH = REPOSITORY_ROOT / "config/perception/m4-temporal-motion-v1.json"
|
||||
|
||||
|
||||
class _Points:
|
||||
def __init__(self, frames: dict[int, list[list[float]]]) -> None:
|
||||
self.frames = frames
|
||||
|
||||
def current_points(self, packet: SourcePacket) -> np.ndarray:
|
||||
return np.asarray(self.frames[packet.envelope.sequence], dtype=np.float64)
|
||||
|
||||
|
||||
def _status() -> ModalityStatus:
|
||||
return ModalityStatus(True, ModalityOutcome.AVAILABLE, "test-available")
|
||||
|
||||
|
||||
def _packet(sequence: int, seconds: float) -> SourcePacket:
|
||||
frame_id = f"frame-{sequence:06d}"
|
||||
return SourcePacket(
|
||||
envelope=SourceEnvelope(
|
||||
source_id="RAVNOVES00",
|
||||
session_id="20260720T065719Z_viewer_live",
|
||||
frame_id=frame_id,
|
||||
sequence=sequence,
|
||||
timestamps=TimestampBundle(
|
||||
utc_ns=round(seconds * 1_000_000_000),
|
||||
monotonic_ns=round(seconds * 1_000_000_000),
|
||||
source_ns=round(seconds * 1_000_000_000),
|
||||
clock_basis=ClockBasis.RECORDED_HOST,
|
||||
),
|
||||
source_age_ns=0,
|
||||
binding_reason="test-source",
|
||||
calibration_id="test-calibration",
|
||||
representation_id="registered-map-increment-v1",
|
||||
image=_status(),
|
||||
registered_point_increment=_status(),
|
||||
pose=_status(),
|
||||
),
|
||||
image_payload="image",
|
||||
registered_point_increment_payload="points",
|
||||
pose_payload="pose",
|
||||
)
|
||||
|
||||
|
||||
def _observation(
|
||||
packet: SourcePacket,
|
||||
point_index: int,
|
||||
*,
|
||||
identity: str,
|
||||
hint: str | None = "object",
|
||||
) -> ObstacleObservation:
|
||||
return ObstacleObservation(
|
||||
observation_id=f"{packet.envelope.frame_id}:{identity}",
|
||||
occupancy_key=f"{packet.envelope.frame_id}:{identity}",
|
||||
source_id=packet.envelope.source_id,
|
||||
frame_id=packet.envelope.frame_id,
|
||||
evidence_time_ns=packet.envelope.timestamps.source_ns,
|
||||
basis=EvidenceBasis.FUSED,
|
||||
currentness=EvidenceCurrentness.CURRENT,
|
||||
occupied_support=True,
|
||||
source_point_ids=(point_index,),
|
||||
metric_geometry=MetricGeometry(
|
||||
coordinate_frame="map",
|
||||
centroid_xyz_m=(0.0, 0.0, 0.0),
|
||||
range_m=1.0,
|
||||
covariance_diagonal_m2=(0.0, 0.0, 0.0),
|
||||
),
|
||||
proposal_ids=(f"proposal-{identity}",),
|
||||
semantic_hint=hint,
|
||||
reason_codes=("test-current-support",),
|
||||
)
|
||||
|
||||
|
||||
def _camera_uncertainty(packet: SourcePacket) -> ObstacleObservation:
|
||||
return ObstacleObservation(
|
||||
observation_id=f"{packet.envelope.frame_id}:camera",
|
||||
occupancy_key=f"{packet.envelope.frame_id}:camera",
|
||||
source_id=packet.envelope.source_id,
|
||||
frame_id=packet.envelope.frame_id,
|
||||
evidence_time_ns=packet.envelope.timestamps.source_ns,
|
||||
basis=EvidenceBasis.CAMERA,
|
||||
currentness=EvidenceCurrentness.CURRENT,
|
||||
occupied_support=False,
|
||||
source_point_ids=(),
|
||||
metric_geometry=None,
|
||||
proposal_ids=("proposal-camera",),
|
||||
semantic_hint="person",
|
||||
reason_codes=("camera-without-current-points",),
|
||||
)
|
||||
|
||||
|
||||
def test_held_evidence_is_unknown_and_expires_without_cells() -> None:
|
||||
profile = load_temporal_motion_profile(PROFILE_PATH)
|
||||
points = _Points({0: [[1.0, 2.0, 0.5]], 1: [], 2: []})
|
||||
temporal = BoundedSpatialTemporalProvider(point_resolver=points, profile=profile)
|
||||
motion = ClassIndependentMotionEstimator(profile=profile)
|
||||
|
||||
first_packet = _packet(0, 0.0)
|
||||
current = motion.estimate(
|
||||
first_packet,
|
||||
temporal.update(first_packet, (_observation(first_packet, 0, identity="a"),)),
|
||||
)
|
||||
held_packet = _packet(1, 0.5)
|
||||
held = motion.estimate(held_packet, temporal.update(held_packet, ()))
|
||||
expired_packet = _packet(2, 0.9)
|
||||
expired = motion.estimate(expired_packet, temporal.update(expired_packet, ()))
|
||||
|
||||
assert current[0].state is TemporalState.CURRENT
|
||||
assert held[0].state is TemporalState.HELD
|
||||
assert held[0].motion is MotionState.UNKNOWN
|
||||
assert held[0].motion_reason == "stale-support"
|
||||
assert held[0].cells
|
||||
assert expired[0].state is TemporalState.EXPIRED
|
||||
assert expired[0].cells == ()
|
||||
assert expired[0].coordinate_frame is None
|
||||
assert temporal.snapshot().past_ttl_occupied_publications == 0
|
||||
|
||||
|
||||
def test_detector_identity_and_semantic_changes_do_not_erase_spatial_component() -> None:
|
||||
profile = load_temporal_motion_profile(PROFILE_PATH)
|
||||
frames = {
|
||||
index: [[index * 0.1, 0.0, 0.0]]
|
||||
for index in range(4)
|
||||
}
|
||||
temporal = BoundedSpatialTemporalProvider(
|
||||
point_resolver=_Points(frames),
|
||||
profile=profile,
|
||||
)
|
||||
motion = ClassIndependentMotionEstimator(profile=profile)
|
||||
component_ids: list[str] = []
|
||||
result = ()
|
||||
for index, hint in enumerate(("person", "truck", "car", "object")):
|
||||
packet = _packet(index, index * 0.1)
|
||||
obstacles = temporal.update(
|
||||
packet,
|
||||
(_observation(packet, 0, identity=f"changed-{index}", hint=hint),),
|
||||
)
|
||||
result = motion.estimate(packet, obstacles)
|
||||
component_ids.append(result[0].component_id)
|
||||
|
||||
assert len(set(component_ids)) == 1
|
||||
assert result[0].motion is MotionState.MOVING
|
||||
assert result[0].motion_confidence == 0.75
|
||||
snapshot = temporal.snapshot()
|
||||
assert snapshot.detector_identity_changes_reassociated == 3
|
||||
assert snapshot.spatial_reassociations == 3
|
||||
|
||||
|
||||
def test_stationary_motion_decision_uses_only_bounded_map_history() -> None:
|
||||
profile = load_temporal_motion_profile(PROFILE_PATH)
|
||||
temporal = BoundedSpatialTemporalProvider(
|
||||
point_resolver=_Points({index: [[1.0, 1.0, 0.0]] for index in range(4)}),
|
||||
profile=profile,
|
||||
)
|
||||
motion = ClassIndependentMotionEstimator(profile=profile)
|
||||
result = ()
|
||||
for index in range(4):
|
||||
packet = _packet(index, index * 0.1)
|
||||
result = motion.estimate(
|
||||
packet,
|
||||
temporal.update(
|
||||
packet,
|
||||
(_observation(packet, 0, identity=f"id-{index}", hint=None),),
|
||||
),
|
||||
)
|
||||
|
||||
assert result[0].motion is MotionState.STATIONARY
|
||||
assert len(result[0].history) == 4
|
||||
assert result[0].motion_reason == "bounded-map-history-stationary"
|
||||
|
||||
|
||||
def test_map_frame_discontinuity_forces_unknown_motion() -> None:
|
||||
profile = load_temporal_motion_profile(PROFILE_PATH)
|
||||
first_points = [[0.0, 0.0, 0.0], [3.0, 0.0, 0.0], [6.0, 0.0, 0.0], [9.0, 0.0, 0.0]]
|
||||
shifted_points = [[x + 2.0, y, z] for x, y, z in first_points]
|
||||
temporal = BoundedSpatialTemporalProvider(
|
||||
point_resolver=_Points({0: first_points, 1: shifted_points}),
|
||||
profile=profile,
|
||||
)
|
||||
motion = ClassIndependentMotionEstimator(profile=profile)
|
||||
first = _packet(0, 0.0)
|
||||
temporal.update(
|
||||
first,
|
||||
tuple(_observation(first, index, identity=f"a-{index}") for index in range(4)),
|
||||
)
|
||||
second = _packet(1, 0.1)
|
||||
result = motion.estimate(
|
||||
second,
|
||||
temporal.update(
|
||||
second,
|
||||
tuple(_observation(second, index, identity=f"b-{index}") for index in range(4)),
|
||||
),
|
||||
)
|
||||
|
||||
current = tuple(item for item in result if item.state is TemporalState.CURRENT)
|
||||
assert len(current) == 4
|
||||
assert all(item.association_basis == "map-frame-discontinuity" for item in current)
|
||||
assert all(item.motion is MotionState.UNKNOWN for item in current)
|
||||
assert temporal.snapshot().map_frame_jump_candidates == 1
|
||||
|
||||
|
||||
def test_camera_only_uncertainty_cannot_create_occupied_temporal_state() -> None:
|
||||
profile = load_temporal_motion_profile(PROFILE_PATH)
|
||||
temporal = BoundedSpatialTemporalProvider(
|
||||
point_resolver=_Points({0: []}),
|
||||
profile=profile,
|
||||
)
|
||||
packet = _packet(0, 0.0)
|
||||
|
||||
result = temporal.update(packet, (_camera_uncertainty(packet),))
|
||||
|
||||
assert result == ()
|
||||
snapshot = temporal.snapshot()
|
||||
assert snapshot.current_occupied_observations == 0
|
||||
assert snapshot.nonmetric_uncertainty_observations == 1
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.perception.temporal_replay import (
|
||||
TemporalReplayResult,
|
||||
read_temporal_replay_result,
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
RESULT_ROOT = (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime/perception-m4/temporal-results"
|
||||
/ "m4-temporal-replay-9ed5dcd249ed3bcb81661dd18e2b854a7ffedf3fd2b92b9c994c3c70c34533f2"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def result() -> TemporalReplayResult:
|
||||
return read_temporal_replay_result(RESULT_ROOT)
|
||||
|
||||
|
||||
def test_full_source_temporal_result_closes_m4_5_contract(
|
||||
result: TemporalReplayResult,
|
||||
) -> None:
|
||||
assert result.accepted is True
|
||||
assert result.metrics["frames"] == {"failed": 0, "total": 4489}
|
||||
assert result.metrics["input_observations"] == 37457
|
||||
temporal = result.metrics["temporal"]
|
||||
assert isinstance(temporal, dict)
|
||||
assert temporal["current_occupied_observations"] == 27299
|
||||
assert temporal["nonmetric_uncertainty_observations"] == 10158
|
||||
assert temporal["detector_identity_changes_reassociated"] == 22994
|
||||
assert temporal["peak_active_components"] == 34
|
||||
assert temporal["maximum_history_samples"] == 8
|
||||
assert temporal["past_ttl_occupied_publications"] == 0
|
||||
|
||||
|
||||
def test_temporal_result_measures_motion_retention_and_engineering_clips(
|
||||
result: TemporalReplayResult,
|
||||
) -> None:
|
||||
motion = result.metrics["motion"]
|
||||
retention = result.metrics["retention"]
|
||||
clips = result.metrics["labeled_engineering_clip_checks"]
|
||||
assert isinstance(motion, dict)
|
||||
assert isinstance(retention, dict)
|
||||
assert motion["moving"] == 11365
|
||||
assert motion["stationary"] == 1451
|
||||
assert motion["unknown"] == 52478
|
||||
assert retention == {
|
||||
"false_persistence_truth_available": False,
|
||||
"ghost_occupancy_past_ttl_count": 0,
|
||||
"maximum_expiry_deadline_delay_ns": 0,
|
||||
"maximum_expiry_materialization_delay_ns": 283000000,
|
||||
"maximum_held_age_ns": 750000000,
|
||||
"past_ttl_occupied_publications": 0,
|
||||
"ttl_ns": 750000000,
|
||||
}
|
||||
assert clips == {
|
||||
"component_correspondence_available": False,
|
||||
"frame_count": 16,
|
||||
"independent_truth": False,
|
||||
}
|
||||
|
||||
|
||||
def test_temporal_result_is_digest_bound_to_m4_4_e34_e51_and_e46b(
|
||||
result: TemporalReplayResult,
|
||||
) -> None:
|
||||
identity = result.manifest["identity"]
|
||||
assert isinstance(identity, dict)
|
||||
assert identity["geometry_result_id"] == (
|
||||
"m4-geometry-replay-"
|
||||
"8daf3109e3cf30b960b4b376032ff3b5ec58ca42a1e5899b841cf29fbcf14ad8"
|
||||
)
|
||||
assert identity["frames_sha256"] == (
|
||||
"1bf1365bdb3f20214443d3f8b87a0fa88f9848af8ca0456b7ca364d37631c3fc"
|
||||
)
|
||||
references = identity["historical_references"]
|
||||
assert isinstance(references, dict)
|
||||
assert set(references) == {"e34", "e46b", "e51"}
|
||||
assert references["e46b"]["independent_truth"] is False
|
||||
assert len(identity["clip_checks"]) == 16
|
||||
Reference in New Issue
Block a user