diff --git a/experiments/perception/LAB_E24_REPORT_2026-07-24.md b/experiments/perception/LAB_E24_REPORT_2026-07-24.md new file mode 100644 index 0000000..a7eef07 --- /dev/null +++ b/experiments/perception/LAB_E24_REPORT_2026-07-24.md @@ -0,0 +1,195 @@ +# LAB E24 — world-frame tracking and motion-state qualification + +Date: 2026-07-24 +Status: diagnostic artifact accepted; motion benchmark not accepted +Best immutable replay: +`LAB E24.5 · Final reproducible world motion · full RAVNOVES00` + +## Objective + +Test whether the already calculated camera/LiDAR perception result can support a +bounded, no-lookahead tracker that keeps object identity in `k1-map` and emits a +conservative `static`, `dynamic`, or `unknown` motion state. The goal is not to +make prettier boxes. The target is a streaming-equivalent world-state component +that can later feed collision and path-planning logic. + +## Source data and immutable inputs + +- Source session: `20260720T065719Z_viewer_live` (`RAVNOVES00`). +- Camera source: `sensor.camera.right`, calibration slot `camera_1`. +- Calibration SHA-256: + `05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`. +- Source integrated result: + `e10-integrated-perception-34ade557b5636717aa497fc00355b84df7063e483a175f2f5d3f04c03df1c898`. +- Source result SHA-256: + `418cd598277d50f4ee81c042d98ece9be10b1b8dc49bf3e2dcc2f2a9ca1c6dc3`. +- Frames: 4,489. +- Source timeline: 35.421857292–484.044857292 session seconds. +- Source detector: YOLOX-S, COCO-80, 640×640. +- Source semantic model: + `tue-mps/cityscapes_semantic_eomt_large_1024`, FP16 autocast. +- Source fusion: 3,915 LiDAR-fused frames and 3,349 accepted cuboids. +- Raw point cloud, ground returns, trajectory, camera media, calibration, + detector output and semantic masks were not changed. + +The E24 runs reuse the accepted E19 result. They do not repeat GPU inference. +E24 is a deterministic CPU post-fusion stage executed locally with Python +3.12/NumPy 2.5.1. + +## Operator benchmark + +The committed benchmark uses the operator's approximate RAVNOVES00 timeline: + +- 58 s: woman in a pink shirt with a dog, left; +- 65 s: person loading a car, right; +- 115 s: adult and child, left; +- 127 s: pedestrian on the road, right; +- 155 s: woman with stroller and child, left; +- 170 s: oncoming vehicle, right; +- 173 s: vulnerable-road-user group, left; +- 183 s: vehicle moving in the rig direction, right; +- three static-vehicle control windows before the moving-vehicle events. + +The benchmark is not labeled ground truth. It is a reproducible operator +annotation contract with explicit time windows, class groups, minimum hits and +minimum observed spans. + +## Implemented pipeline + +E24 implements tracking-by-detection in the map frame: + +1. accepted 3D cuboids are measurements in `k1-map`; +2. visible LiDAR support rejected only by amodal-completion coverage may enter as + a high-uncertainty provisional measurement; +3. a bounded constant-velocity Kalman state estimates position, velocity and + covariance; +4. global greedy association uses class compatibility, predicted map position, + shape distance, detector-ID aliases and strict ID-switch gates; +5. provisional tracks require repeated support before a cuboid is published; +6. short measurement gaps are explicit predicted holds, limited to 450 ms; +7. motion state requires a bounded history, minimum displacement, robust speed, + direction consistency and hysteresis; +8. every world object exposes observation age, track age, position/velocity + covariance, motion confidence and source track ID. + +State is bounded to 192 tracks, has no lookahead, and has no command, +navigation, or safety authority. + +## Run series + +### E24.1 — accepted cuboids only + +- Benchmark: 2/11. +- Processing p95: 0.600 ms/frame. +- Finding: static vehicle tracks can be held, but people and moving vehicles + often lack enough accepted 3D measurements to leave `unknown`. + +### E24.2 — provisional visible-support measurements + +- Benchmark: 6/11. +- Processing p95: 0.581 ms/frame. +- Finding: recovered motion evidence for the 58 s, 115 s and 155 s pedestrian + windows and the 183 s vehicle; broad reassociation also merged nearby vehicle + tracks and created false motion. + +### E24.3 — conservative ID-switch gates + +- Benchmark: 7/11. +- Processing p95: 0.638 ms/frame. +- Finding: strict ID-switch gates reduced false merges, but result identity did + not yet contain the postprocessor implementation SHA-256. + +### E24.4 — reproducible conservative ID-switch gates + +- Benchmark: 7/11. +- Processing p95: 0.611 ms/frame. +- Finding: implementation SHA-256 was added before the formatter produced the + final source bytes, so a final identity-exact replay was required. + +### E24.5 — final identity-exact replay + +- Result: + `e10-integrated-perception-e56fa878cba3f9f9e5943831cad0292643e49571f212a4d341bca28626515d80`. +- Profile SHA-256: + `dff304161ace7846dd7886f43877c6cae35737d4e7384b76dae6a430aeec1bc9`. +- Benchmark SHA-256: + `f1fa0f2b17b7148a1f5371339faf47a9ef25bfe9003fce4559f73be4f408818b`. +- Implementation SHA-256: + `3ca4c464eabaab3a89e7e684704d6d0288bb1dcd3dd455423456bed91d9d2ecc`. +- Benchmark: 7/11. +- Processing mean/p50/p95/max: + 0.328/0.287/0.713/53.743 ms per frame. +- Peak live track states: 10 of 192. +- Created tracks: 251. +- Cross-source-ID reassociations: 24. +- Explicit held cuboids: 1,859. +- Published motion states: 3,139 unknown, 1,494 static, 875 dynamic. + +Passed events: + +- dynamic person at 58 s; +- dynamic person at 65 s; +- static vehicle evidence at 70–90 s; +- dynamic person at 115 s; +- dynamic person at 155 s; +- oncoming dynamic vehicle at 170 s; +- same-direction dynamic vehicle at 183 s. + +Failed events: + +- 127 s pedestrian: only one publishable 3D observation after confirmation; +- 132–148 s static vehicles: visible-surface/amodal-center drift still creates + false dynamic hypotheses; +- 161–166 s static vehicles: insufficient persistent 3D support; +- 173 s vulnerable-road-user group: measurements are too sparse after strict + identity separation. + +## Main conclusion + +E24 proves that the tracker itself fits an eventual near-real-time budget by a +large margin. It also proves that temporal smoothing and threshold tuning alone +cannot make the current cuboid center a reliable motion measurement. + +For parked vehicles, the fitted/completed cuboid center can drift systematically +as the rig passes and a different visible LiDAR surface becomes dominant. A +constant-velocity tracker correctly interprets that input as motion because the +measurement itself is moving. Relaxed association hides gaps but merges nearby +objects; strict association avoids most merges but exposes the missing +measurement continuity. + +Therefore E24.5 is an accepted diagnostic artifact, not an accepted motion +classifier. It must not feed navigation or safety decisions. + +## Next experimental gate + +E25 must improve the measurement model rather than retune E24: + +- maintain object-level LiDAR support/occupancy evidence across scans in the map + frame instead of tracking only a completed box center; +- estimate a static-landmark hypothesis from persistent support and compare it + against a moving-object hypothesis; +- keep raw ground and the full cloud visible; exclude ground only from the + object-proposal branch; +- split coverage, association, motion-state and false-dynamic metrics so a + missing observation cannot be mistaken for a correct static classification; +- use the same operator anchors plus explicit per-object spatial annotations for + the failed 127 s, 132–148 s, 161–166 s and 173 s windows; +- accept E25 only after false dynamic vehicle tracks are reduced without losing + the two known moving vehicles. + +The planner-facing representation should be occupancy/footprint, velocity, +uncertainty and short-horizon prediction. 3D boxes remain an operator-facing +representation and one measurement carrier, not the sole collision model. + +## Validation + +- Unit tests cover bounded state, ID reassociation, static/dynamic + classification, provisional confirmation, short holds and benchmark parsing. +- E24.5 result passes the existing integrated-perception validator. +- Saved session `lab-e24-5-world-motion` is `ready` and replayable on + `http://127.0.0.1:8000/`. +- A cold full-session perception request completed with HTTP 200 in 51.958 s + and produced a 69,303,555-byte Rerun stream with `RRF2` magic. This cold + materialization time is a viewer-publication limitation, not E24 tracking + latency; the generated overlay is now cached. +- The existing localhost server was not stopped or restarted. diff --git a/experiments/perception/e24_motion_benchmark.json b/experiments/perception/e24_motion_benchmark.json new file mode 100644 index 0000000..00f739d --- /dev/null +++ b/experiments/perception/e24_motion_benchmark.json @@ -0,0 +1,97 @@ +{ + "schema_version": "missioncore.e24-motion-benchmark/v1", + "benchmark_id": "ravnoves00-operator-anchors-v1", + "source_session_id": "20260720T065719Z_viewer_live", + "timeline": "session_seconds", + "annotation_status": "operator-approximate", + "events": [ + { + "id": "dynamic-person-woman-dog-left-58s", + "window_seconds": [54.0, 62.0], + "class_group": "person", + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2 + }, + { + "id": "dynamic-person-loading-car-right-65s", + "window_seconds": [62.0, 69.0], + "class_group": "person", + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2 + }, + { + "id": "static-vehicles-before-moving-70-90s", + "window_seconds": [70.0, 90.0], + "class_group": "vehicle", + "expected_motion": "static", + "minimum_hits": 8, + "minimum_span_seconds": 0.7 + }, + { + "id": "dynamic-person-adult-child-left-115s", + "window_seconds": [111.0, 120.0], + "class_group": "person", + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2 + }, + { + "id": "dynamic-person-road-right-127s", + "window_seconds": [124.0, 132.0], + "class_group": "person", + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2 + }, + { + "id": "static-vehicles-mid-run-132-148s", + "window_seconds": [132.0, 148.0], + "class_group": "vehicle", + "expected_motion": "static", + "minimum_hits": 8, + "minimum_span_seconds": 0.7 + }, + { + "id": "dynamic-person-stroller-group-left-155s", + "window_seconds": [151.0, 161.0], + "class_group": "person", + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2 + }, + { + "id": "static-vehicles-before-oncoming-161-166s", + "window_seconds": [161.0, 166.0], + "class_group": "vehicle", + "expected_motion": "static", + "minimum_hits": 8, + "minimum_span_seconds": 0.7 + }, + { + "id": "dynamic-vehicle-oncoming-right-170s", + "window_seconds": [167.0, 173.0], + "class_group": "vehicle", + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.15 + }, + { + "id": "dynamic-person-bike-group-left-173s", + "window_seconds": [170.0, 179.0], + "class_group": "vulnerable_road_user", + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2 + }, + { + "id": "dynamic-vehicle-same-direction-right-183s", + "window_seconds": [180.0, 188.0], + "class_group": "vehicle", + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2 + } + ] +} diff --git a/experiments/perception/e24_world_motion_profile.json b/experiments/perception/e24_world_motion_profile.json new file mode 100644 index 0000000..dd21857 --- /dev/null +++ b/experiments/perception/e24_world_motion_profile.json @@ -0,0 +1,100 @@ +{ + "schema_version": "missioncore.e24-world-motion-profile/v1", + "profile_id": "lab-e24-world-frame-motion-v1", + "mode": "recorded-streaming-qualification", + "source": { + "source_id": "sensor.camera.right", + "coordinate_frame": "k1-map", + "calibration_slot": "camera_1", + "calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9" + }, + "association": { + "maximum_center_distance_m": 2.2, + "maximum_speed_mps": 16.0, + "maximum_gap_allowance_m": 2.5, + "maximum_reassociation_gap_seconds": 0.55, + "maximum_reassociation_distance_m": { + "person": 1.2, + "bicycle": 1.2, + "motorcycle": 1.2, + "vehicle": 0.75, + "default": 0.75 + }, + "source_track_alias_bonus": 0.8, + "size_distance_weight": 0.35 + }, + "filter": { + "initial_position_sigma_m": 0.8, + "initial_velocity_sigma_mps": 2.0, + "measurement_sigma_m": 1.6, + "provisional_measurement_sigma_m": 2.8, + "minimum_measurement_sigma_m": 0.18, + "support_points_saturation": 64, + "acceleration_sigma_mps2": 2.0, + "shape_alpha": 0.18 + }, + "provisional": { + "allowed_groups": [ + "person", + "bicycle", + "motorcycle", + "vehicle" + ], + "nominal_half_size_m": { + "person": [0.35, 0.35, 0.9], + "bicycle": [0.9, 0.325, 0.75], + "motorcycle": [0.9, 0.35, 0.75], + "vehicle": [2.25, 0.925, 0.775], + "default": [0.5, 0.5, 0.5] + } + }, + "tracking": { + "minimum_confirmation_hits": 4, + "provisional_confirmation_hits": 3, + "hold_seconds": 0.45 + }, + "motion": { + "history_seconds": 1.8, + "minimum_history_seconds": 0.8, + "dynamic_enter_speed_mps": { + "person": 0.45, + "bicycle": 0.55, + "motorcycle": 0.65, + "vehicle": 0.75, + "default": 0.75 + }, + "dynamic_exit_speed_mps": 0.4, + "dynamic_minimum_displacement_m": { + "person": 0.45, + "bicycle": 0.5, + "motorcycle": 0.55, + "vehicle": 0.65, + "default": 0.65 + }, + "minimum_direction_consistency": 0.62, + "dynamic_confirmation_frames": { + "person": 1, + "bicycle": 1, + "motorcycle": 1, + "vehicle": 2, + "default": 2 + }, + "static_enter_speed_mps": 0.28, + "static_exit_speed_mps": 0.55, + "static_maximum_displacement_m": 0.42, + "static_confirmation_frames": 7 + }, + "bounds": { + "track_id_start": 240001, + "maximum_tracks": 192, + "maximum_track_idle_seconds": 1.5 + }, + "acceptance": { + "maximum_processing_p95_ms": 8.0, + "maximum_tracks_observed": 192 + }, + "authority": { + "commands_enabled": false, + "navigation_or_safety_accepted": false + } +} diff --git a/src/k1link/compute/__init__.py b/src/k1link/compute/__init__.py index c31fd8f..108d62f 100644 --- a/src/k1link/compute/__init__.py +++ b/src/k1link/compute/__init__.py @@ -37,9 +37,11 @@ from .jobs import ( from .lab_instances import ( PublishedIntegratedLabInstance, PublishedTemporalLabInstance, + PublishedWorldMotionLabInstance, publish_e21_lab_instance, publish_e22_lab_instance, publish_e23_lab_instance, + publish_e24_lab_instance, publish_integrated_lab_instance, ) from .live_perception import ( @@ -119,6 +121,7 @@ __all__ = [ "IntegratedPerceptionOverlayStore", "PublishedIntegratedLabInstance", "PublishedTemporalLabInstance", + "PublishedWorldMotionLabInstance", "IntegratedPerceptionResult", "LiveReplayQualificationResult", "MultiratePerceptionArtifact", @@ -150,6 +153,7 @@ __all__ = [ "publish_e21_lab_instance", "publish_e22_lab_instance", "publish_e23_lab_instance", + "publish_e24_lab_instance", "publish_integrated_lab_instance", "validate_multirate_perception_qualification_result", "prepare_recorded_qualification_slice", diff --git a/src/k1link/compute/lab_instances.py b/src/k1link/compute/lab_instances.py index 9118d9f..fe37871 100644 --- a/src/k1link/compute/lab_instances.py +++ b/src/k1link/compute/lab_instances.py @@ -33,6 +33,7 @@ from .temporal_stability import ( _quality_metrics, build_temporal_stability_result, ) +from .world_motion import WorldMotionBuild, build_world_motion_result @dataclass(frozen=True, slots=True) @@ -50,6 +51,14 @@ class PublishedTemporalLabInstance: build: TemporalStabilityBuild +@dataclass(frozen=True, slots=True) +class PublishedWorldMotionLabInstance: + binding: LabSessionBinding + job: CameraComputeJob + result: IntegratedPerceptionResult + build: WorldMotionBuild + + def publish_integrated_lab_instance( *, repository_root: Path, @@ -477,6 +486,110 @@ def publish_e23_lab_instance( ) +def publish_e24_lab_instance( + *, + repository_root: Path, + source_result_root: Path, + profile_path: Path, + benchmark_path: Path, + lab_session_id: str, + lab_id: str, + display_name: str, +) -> PublishedWorldMotionLabInstance: + """Derive and publish a bounded world-frame motion-tracking LAB run.""" + + root = repository_root.expanduser().resolve(strict=True) + jobs_root = root / ".runtime" / "compute-jobs" + results_root = root / ".runtime" / "compute-experiments" / "e10" / "worker-results" + packs_root = root / ".runtime" / "compute-experiments" / "e10" / "lidar-packs" + source_path = source_result_root.expanduser().resolve(strict=True) + source_document = _read_object(source_path / "result.json", source_path) + identity = source_document.get("identity") + if not isinstance(identity, dict) or not isinstance(identity.get("job_id"), str): + raise SessionIntegrityError("E24 source has no job identity") + source = validate_integrated_perception_result( + jobs_root / identity["job_id"], + source_path, + packs_root, + ) + if not source.accepted: + raise SessionIntegrityError("E24 source result is not accepted") + + lab_job = _publish_lab_job(source.job, jobs_root, lab_session_id) + lab_pack = _publish_lab_pack(source, lab_job, packs_root, lab_session_id) + build = build_world_motion_result( + source=source, + lab_job=lab_job, + lab_pack=lab_pack, + results_root=results_root, + profile_path=profile_path, + benchmark_path=benchmark_path, + ) + validated = validate_integrated_perception_result( + lab_job.job_root, + build.result_root, + packs_root, + ) + if not validated.accepted: + failed = [ + name for name, accepted in build.report["acceptance"]["checks"].items() if not accepted + ] + raise SessionIntegrityError( + f"E24 world-motion artifact acceptance failed: {', '.join(failed)}" + ) + + store = SessionStore(root) + source_lab = store.get_lab_instance(source.job.session_id) + source_session_id = ( + source.job.session_id if source_lab is None else source_lab.source_session_id + ) + publish_lab_replay_cache( + store.data_dir, + source_session_id=source_session_id, + lab_session_id=lab_session_id, + timeline_start_ns=round(validated.timeline_start_seconds * 1_000_000_000), + timeline_end_ns=round(validated.timeline_end_seconds * 1_000_000_000), + ) + metrics = build.report["metrics"] + binding = store.publish_lab_instance( + session_id=lab_session_id, + source_session_id=source_session_id, + display_name=display_name, + lab_id=lab_id, + result_kind="e24-world-motion", + result_id=validated.result_id, + source_result_id=source.result_id, + config_sha256=build.profile_sha256, + run_created_at_utc=validated.created_at_utc, + duration_seconds=(validated.timeline_end_seconds - validated.timeline_start_seconds), + include_recorded_media=False, + provenance={ + "schema_version": "missioncore.e24-lab-publication/v1", + "storage_mode": "bounded-world-frame-tracking-and-immutable-source-replay", + "source_result_id": source.result_id, + "source_lab_session_id": (None if source_lab is None else source_lab.session_id), + "source_payloads_mutated": False, + "coordinate_frame": "k1-map", + "lookahead_frames": 0, + "benchmark_sha256": build.benchmark_sha256, + "benchmark_passed": metrics["benchmark"]["passed"], + "benchmark_passed_events": metrics["benchmark"]["passed_events"], + "benchmark_total_events": metrics["benchmark"]["total_events"], + "world_motion_processing_p95_ms": metrics["runtime"][ + "world_motion_frame_processing_ms" + ]["p95"], + "peak_tracks": metrics["runtime"]["peak_tracks"], + "navigation_or_safety_accepted": False, + }, + ) + return PublishedWorldMotionLabInstance( + binding=binding, + job=lab_job, + result=validated, + build=build, + ) + + def _validate_e23_inputs( worker_root: Path, source_report_path: Path, diff --git a/src/k1link/compute/world_motion.py b/src/k1link/compute/world_motion.py new file mode 100644 index 0000000..6737a5e --- /dev/null +++ b/src/k1link/compute/world_motion.py @@ -0,0 +1,1068 @@ +"""Bounded world-frame tracking and motion-state qualification. + +The detector, semantic masks, LiDAR cloud, calibration and source result stay +immutable. This stage associates accepted 3D measurements in ``k1-map`` and +adds a conservative ``static``/``dynamic``/``unknown`` state with explicit +uncertainty. It is diagnostic only and has no navigation or command authority. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import secrets +import shutil +import statistics +import time +import zipfile +from collections import Counter, deque +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import numpy as np + +from k1link.artifacts import write_json_atomic +from k1link.sessions import SessionIntegrityError + +from .integrated_perception import IntegratedPerceptionResult +from .jobs import CameraComputeJob + +PROFILE_SCHEMA = "missioncore.e24-world-motion-profile/v1" +BENCHMARK_SCHEMA = "missioncore.e24-motion-benchmark/v1" +PIPELINE_ID = "world-frame-cv-tracker-motion-hysteresis/v1" + + +@dataclass(slots=True) +class _Track: + track_id: int + group: str + label: str + aliases: set[int] + state: np.ndarray + covariance: np.ndarray + half_size: np.ndarray + quaternion: np.ndarray + created_seconds: float + state_seconds: float + last_observed_seconds: float + hits: int + misses: int + score: float + template: dict[str, Any] + history: deque[tuple[float, np.ndarray]] = field(default_factory=deque) + motion_state: str = "unknown" + dynamic_evidence: int = 0 + static_evidence: int = 0 + motion_confidence: float = 0.0 + last_speed_mps: float | None = None + + +@dataclass(frozen=True, slots=True) +class WorldMotionBuild: + result_root: Path + profile_sha256: str + benchmark_sha256: str + report: dict[str, Any] + + +class WorldMotionTracker: + """Streaming constant-velocity Kalman tracker in the host map frame.""" + + def __init__(self, profile: dict[str, Any]) -> None: + self.profile = profile + self.tracks: dict[int, _Track] = {} + self.next_track_id = int(profile["bounds"]["track_id_start"]) + self.peak_tracks = 0 + self.created_tracks = 0 + self.reassociated_source_ids = 0 + self.held_cuboids = 0 + self.published_motion_states: Counter[str] = Counter() + + def update( + self, + *, + frame_index: int, + session_seconds: float, + objects: list[dict[str, Any]], + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Return normalized fusion objects and conservative world objects.""" + + self._predict_all(session_seconds) + measurements = self._measurement_candidates(objects) + assignments = self._associate(measurements, session_seconds) + matched_tracks: set[int] = set() + normalized_by_source: dict[int, dict[str, Any]] = {} + for measurement_index, track_id in assignments.items(): + source = measurements[measurement_index] + track = self.tracks[track_id] + self._observe(track, source, session_seconds) + matched_tracks.add(track_id) + normalized_by_source[id(source)] = self._fusion_object( + track, + source, + session_seconds, + observed=True, + ) + + for index, source in enumerate(measurements): + if index in assignments: + continue + track = self._create(source, session_seconds) + matched_tracks.add(track.track_id) + normalized_by_source[id(source)] = self._fusion_object( + track, + source, + session_seconds, + observed=True, + ) + + fusion_objects: list[dict[str, Any]] = [] + for source in objects: + normalized = normalized_by_source.get(id(source)) + if normalized is None: + normalized = json.loads(json.dumps(source)) + normalized["motion_state"] = "unknown" + normalized["motion_status"] = "unavailable-no-accepted-3d-measurement" + fusion_objects.append(normalized) + + hold_seconds = float(self.profile["tracking"]["hold_seconds"]) + minimum_hits = int(self.profile["tracking"]["minimum_confirmation_hits"]) + for track in sorted(self.tracks.values(), key=lambda item: item.track_id): + if track.track_id in matched_tracks or track.hits < minimum_hits: + continue + age = session_seconds - track.last_observed_seconds + if not 0.0 < age <= hold_seconds: + continue + held = self._fusion_object( + track, + track.template, + session_seconds, + observed=False, + ) + fusion_objects.append(held) + self.held_cuboids += 1 + + world_objects = [ + self._world_object(item) + for item in fusion_objects + if str(item.get("cuboid_status", "")).startswith("accepted-") + ] + self.published_motion_states.update(str(item["motion_state"]) for item in world_objects) + self._prune(session_seconds) + self.peak_tracks = max(self.peak_tracks, len(self.tracks)) + return fusion_objects, world_objects + + def snapshot(self) -> dict[str, Any]: + counts = {"static": 0, "dynamic": 0, "unknown": 0} + for track in self.tracks.values(): + counts[track.motion_state] += 1 + return { + "active_tracks": len(self.tracks), + "peak_tracks": self.peak_tracks, + "created_tracks": self.created_tracks, + "reassociated_source_ids": self.reassociated_source_ids, + "held_cuboids": self.held_cuboids, + "active_motion_states": counts, + "published_motion_states": dict(self.published_motion_states), + } + + def _predict_all(self, now: float) -> None: + acceleration_sigma = float(self.profile["filter"]["acceleration_sigma_mps2"]) + for track in self.tracks.values(): + dt = now - track.state_seconds + if dt <= 0: + continue + transition = np.eye(6, dtype=np.float64) + transition[:3, 3:] = np.eye(3, dtype=np.float64) * dt + q_position = 0.25 * dt**4 * acceleration_sigma**2 + q_cross = 0.5 * dt**3 * acceleration_sigma**2 + q_velocity = dt**2 * acceleration_sigma**2 + process = np.block( + [ + [ + np.eye(3, dtype=np.float64) * q_position, + np.eye(3, dtype=np.float64) * q_cross, + ], + [ + np.eye(3, dtype=np.float64) * q_cross, + np.eye(3, dtype=np.float64) * q_velocity, + ], + ] + ) + track.state = transition @ track.state + track.covariance = transition @ track.covariance @ transition.T + process + track.state_seconds = now + track.misses += 1 + + def _associate( + self, + measurements: list[dict[str, Any]], + now: float, + ) -> dict[int, int]: + candidates: list[tuple[float, int, int]] = [] + config = self.profile["association"] + gate = float(config["maximum_center_distance_m"]) + alias_bonus = float(config["source_track_alias_bonus"]) + size_weight = float(config["size_distance_weight"]) + for measurement_index, source in enumerate(measurements): + center = self._measurement_center(source) + half_size = self._measurement_half_size(source) + group = str(source.get("association_group", source.get("label", "object"))) + source_id = int(source["track_id"]) + for track in self.tracks.values(): + if not _compatible_group(group, track.group): + continue + age = now - track.last_observed_seconds + known_alias = source_id in track.aliases + if not known_alias and age > float(config["maximum_reassociation_gap_seconds"]): + continue + if known_alias: + adaptive_gate = gate + min( + float(config["maximum_speed_mps"]) * max(0.0, age), + float(config["maximum_gap_allowance_m"]), + ) + else: + gates = config["maximum_reassociation_distance_m"] + adaptive_gate = float(gates.get(group, gates["default"])) + distance = float(np.linalg.norm(center - track.state[:3])) + if distance > adaptive_gate: + continue + size_distance = float( + np.linalg.norm(half_size - track.half_size) + / max(0.5, np.linalg.norm(track.half_size)) + ) + cost = distance + size_weight * size_distance + if known_alias: + cost -= alias_bonus + candidates.append((cost, measurement_index, track.track_id)) + assigned_measurements: set[int] = set() + assigned_tracks: set[int] = set() + result: dict[int, int] = {} + for _, measurement_index, track_id in sorted(candidates): + if measurement_index in assigned_measurements or track_id in assigned_tracks: + continue + assigned_measurements.add(measurement_index) + assigned_tracks.add(track_id) + result[measurement_index] = track_id + return result + + def _create(self, source: dict[str, Any], now: float) -> _Track: + center = self._measurement_center(source) + initial_position_sigma = float(self.profile["filter"]["initial_position_sigma_m"]) + initial_velocity_sigma = float(self.profile["filter"]["initial_velocity_sigma_mps"]) + covariance = np.diag( + [initial_position_sigma**2] * 3 + [initial_velocity_sigma**2] * 3 + ).astype(np.float64) + track = _Track( + track_id=self.next_track_id, + group=str(source.get("association_group", source.get("label", "object"))), + label=str(source.get("label", "object")), + aliases={int(source["track_id"])}, + state=np.concatenate([center, np.zeros(3, dtype=np.float64)]), + covariance=covariance, + half_size=self._measurement_half_size(source), + quaternion=self._measurement_quaternion(source), + created_seconds=now, + state_seconds=now, + last_observed_seconds=now, + hits=1, + misses=0, + score=float(source.get("score", 0.0)), + template=json.loads(json.dumps(source)), + ) + track.history.append((now, center.copy())) + self.tracks[track.track_id] = track + self.next_track_id += 1 + self.created_tracks += 1 + return track + + def _observe(self, track: _Track, source: dict[str, Any], now: float) -> None: + measurement = self._measurement_center(source) + support = max(1, int(source.get("clustered_points", 1))) + config = self.profile["filter"] + sigma_key = ( + "measurement_sigma_m" + if self._measurement_quality(source) == "accepted" + else "provisional_measurement_sigma_m" + ) + sigma = float(config[sigma_key]) / math.sqrt( + min(support, int(config["support_points_saturation"])) + ) + sigma = max(float(config["minimum_measurement_sigma_m"]), sigma) + measurement_covariance = np.eye(3, dtype=np.float64) * sigma**2 + observation = np.zeros((3, 6), dtype=np.float64) + observation[:, :3] = np.eye(3, dtype=np.float64) + innovation = measurement - observation @ track.state + innovation_covariance = ( + observation @ track.covariance @ observation.T + measurement_covariance + ) + gain = track.covariance @ observation.T @ np.linalg.inv(innovation_covariance) + track.state = track.state + gain @ innovation + identity = np.eye(6, dtype=np.float64) + # Joseph form keeps the covariance positive under long recorded runs. + residual = identity - gain @ observation + track.covariance = ( + residual @ track.covariance @ residual.T + gain @ measurement_covariance @ gain.T + ) + source_id = int(source["track_id"]) + if source_id not in track.aliases: + self.reassociated_source_ids += 1 + track.aliases.add(source_id) + shape_alpha = float(config["shape_alpha"]) + track.half_size = ( + 1.0 - shape_alpha + ) * track.half_size + shape_alpha * self._measurement_half_size(source) + track.quaternion = self._measurement_quaternion(source) + track.label = str(source.get("label", track.label)) + track.score = float(source.get("score", track.score)) + track.template = json.loads(json.dumps(source)) + track.last_observed_seconds = now + track.hits += 1 + track.misses = 0 + track.history.append((now, track.state[:3].copy())) + history_seconds = float(self.profile["motion"]["history_seconds"]) + while track.history and now - track.history[0][0] > history_seconds: + track.history.popleft() + self._classify(track) + + def _classify(self, track: _Track) -> None: + config = self.profile["motion"] + speed, direction_consistency = _robust_motion(track.history) + track.last_speed_mps = speed + if speed is None: + track.motion_state = "unknown" + track.motion_confidence = 0.0 + return + history_duration = track.history[-1][0] - track.history[0][0] + displacement = float(np.linalg.norm(track.history[-1][1] - track.history[0][1])) + dynamic_thresholds = config["dynamic_enter_speed_mps"] + dynamic_threshold = float( + dynamic_thresholds.get(track.group, dynamic_thresholds["default"]) + ) + displacement_thresholds = config["dynamic_minimum_displacement_m"] + displacement_threshold = float( + displacement_thresholds.get( + track.group, + displacement_thresholds["default"], + ) + ) + dynamic = ( + history_duration >= float(config["minimum_history_seconds"]) + and speed >= dynamic_threshold + and displacement >= displacement_threshold + and direction_consistency >= float(config["minimum_direction_consistency"]) + ) + static = ( + history_duration >= float(config["minimum_history_seconds"]) + and speed <= float(config["static_enter_speed_mps"]) + and displacement <= float(config["static_maximum_displacement_m"]) + ) + track.dynamic_evidence = track.dynamic_evidence + 1 if dynamic else 0 + track.static_evidence = track.static_evidence + 1 if static else 0 + dynamic_confirmations = config["dynamic_confirmation_frames"] + dynamic_frames = int( + dynamic_confirmations.get( + track.group, + dynamic_confirmations["default"], + ) + ) + static_frames = int(config["static_confirmation_frames"]) + if track.dynamic_evidence >= dynamic_frames: + track.motion_state = "dynamic" + elif track.static_evidence >= static_frames: + track.motion_state = "static" + elif ( + track.motion_state == "dynamic" and speed < float(config["dynamic_exit_speed_mps"]) + ) or (track.motion_state == "static" and speed > float(config["static_exit_speed_mps"])): + track.motion_state = "unknown" + if track.motion_state == "dynamic": + track.motion_confidence = min( + 1.0, + speed / max(dynamic_threshold, 1e-6), + ) + elif track.motion_state == "static": + track.motion_confidence = max( + 0.0, + 1.0 - speed / max(float(config["static_exit_speed_mps"]), 1e-6), + ) + else: + track.motion_confidence = 0.0 + + def _fusion_object( + self, + track: _Track, + source: dict[str, Any], + now: float, + *, + observed: bool, + ) -> dict[str, Any]: + normalized_value = json.loads(json.dumps(source)) + if not isinstance(normalized_value, dict): + raise SessionIntegrityError("LAB E24 fusion object copy is invalid") + normalized: dict[str, Any] = normalized_value + normalized["source_track_id"] = int(source["track_id"]) + normalized["track_id"] = track.track_id + quality = self._measurement_quality(source) + publishable = quality == "accepted" or track.hits >= int( + self.profile["tracking"]["provisional_confirmation_hits"] + ) + if publishable: + normalized["cuboid_center_map"] = track.state[:3].tolist() + normalized["cuboid_half_size"] = track.half_size.tolist() + normalized["cuboid_quaternion_xyzw"] = track.quaternion.tolist() + normalized["cuboid_status"] = ( + ( + "accepted-world-tracked-e24-v1" + if quality == "accepted" + else "accepted-world-track-provisional-e24-v1" + ) + if observed + else "accepted-world-track-hold-e24-v1" + ) + normalized["geometry"] = ( + ( + str(source.get("geometry", "accepted-3d-measurement")) + if quality == "accepted" + else "provisional-world-track-from-visible-lidar-support" + ) + if observed + else "predicted-world-frame-track-hold" + ) + normalized["motion_state"] = track.motion_state + normalized["motion_confidence"] = track.motion_confidence + normalized["motion_status"] = ( + f"observed-world-frame-cv-{quality}" + if observed and publishable + else "tentative-world-frame-measurement" + if observed + else "predicted-world-frame-cv-hold" + ) + normalized["velocity_map_mps"] = track.state[3:].tolist() + normalized["speed_mps"] = track.last_speed_mps + normalized["position_covariance_m2"] = np.diag(track.covariance[:3, :3]).tolist() + normalized["velocity_covariance_m2ps2"] = np.diag(track.covariance[3:, 3:]).tolist() + normalized["track_age_s"] = max(0.0, now - track.created_seconds) + normalized["observation_age_ms"] = max(0.0, (now - track.last_observed_seconds) * 1000.0) + normalized["track_hits"] = track.hits + normalized["temporal_status"] = "e24-observed" if observed else "e24-held-prediction" + return normalized + + def _measurement_candidates( + self, + objects: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + vehicle_aliases = { + alias + for track in self.tracks.values() + if track.group == "vehicle" + for alias in track.aliases + } + allowed_groups = set(self.profile["provisional"]["allowed_groups"]) + for source in objects: + if self._measurement_quality(source) == "accepted": + result.append(source) + continue + center = source.get("observed_cuboid_center_map") + group = str(source.get("association_group", source.get("label", "object"))) + if ( + group not in allowed_groups + or not _valid_vector(center, 3) + or (group == "vehicle" and int(source["track_id"]) not in vehicle_aliases) + ): + continue + result.append(source) + return result + + @staticmethod + def _measurement_quality(source: dict[str, Any]) -> str: + return ( + "accepted" + if str(source.get("cuboid_status", "")).startswith("accepted-") + else "provisional" + ) + + @staticmethod + def _measurement_center(source: dict[str, Any]) -> np.ndarray: + key = ( + "cuboid_center_map" + if WorldMotionTracker._measurement_quality(source) == "accepted" + else "observed_cuboid_center_map" + ) + return np.asarray(source[key], dtype=np.float64) + + def _measurement_half_size(self, source: dict[str, Any]) -> np.ndarray: + candidate = source.get("cuboid_half_size") + if _valid_vector(candidate, 3): + return np.asarray(candidate, dtype=np.float64) + group = str(source.get("association_group", source.get("label", "object"))) + priors = self.profile["provisional"]["nominal_half_size_m"] + return np.asarray(priors.get(group, priors["default"]), dtype=np.float64) + + @staticmethod + def _measurement_quaternion(source: dict[str, Any]) -> np.ndarray: + for key in ("cuboid_quaternion_xyzw", "observed_cuboid_quaternion_xyzw"): + candidate = source.get(key) + if _valid_vector(candidate, 4): + return np.asarray(candidate, dtype=np.float64) + return np.asarray([0.0, 0.0, 0.0, 1.0], dtype=np.float64) + + @staticmethod + def _world_object(item: dict[str, Any]) -> dict[str, Any]: + half_size = np.asarray(item["cuboid_half_size"], dtype=np.float64) + return { + "track_id": int(item["track_id"]), + "source_track_id": int(item["source_track_id"]), + "class": str(item.get("association_group", item.get("label", "object"))), + "detector_label": str(item.get("label", "object")), + "confidence": float(item.get("score", 0.0)), + "position_map_m": list(item["cuboid_center_map"]), + "orientation_map_xyzw": list(item["cuboid_quaternion_xyzw"]), + "size_m": (2.0 * half_size).tolist(), + "range_m": item.get("distance_smoothed_m"), + "velocity_map_mps": list(item["velocity_map_mps"]), + "speed_mps": item.get("speed_mps"), + "velocity_status": item["motion_status"], + "motion_state": item["motion_state"], + "motion_confidence": item["motion_confidence"], + "position_covariance_m2": list(item["position_covariance_m2"]), + "velocity_covariance_m2ps2": list(item["velocity_covariance_m2ps2"]), + "track_age_s": item["track_age_s"], + "observation_age_ms": item["observation_age_ms"], + "track_hits": item["track_hits"], + "support_points": int(item.get("clustered_points", 0)), + "geometry": item["geometry"], + "temporal_status": item["temporal_status"], + } + + def _prune(self, now: float) -> None: + maximum_idle = float(self.profile["bounds"]["maximum_track_idle_seconds"]) + stale = [ + track_id + for track_id, track in self.tracks.items() + if now - track.last_observed_seconds > maximum_idle + ] + for track_id in stale: + self.tracks.pop(track_id, None) + maximum = int(self.profile["bounds"]["maximum_tracks"]) + if len(self.tracks) <= maximum: + return + ordered = sorted( + self.tracks.values(), + key=lambda track: (track.last_observed_seconds, track.hits), + ) + for track in ordered[: len(self.tracks) - maximum]: + self.tracks.pop(track.track_id, None) + + +def read_world_motion_profile(path: Path) -> tuple[dict[str, Any], str]: + resolved = path.expanduser().resolve(strict=True) + profile = _read_object(resolved) + sections = [ + profile.get("source"), + profile.get("association"), + profile.get("filter"), + profile.get("provisional"), + profile.get("tracking"), + profile.get("motion"), + profile.get("bounds"), + profile.get("acceptance"), + ] + if ( + profile.get("schema_version") != PROFILE_SCHEMA + or profile.get("mode") != "recorded-streaming-qualification" + or not all(isinstance(section, dict) for section in sections) + or profile["source"].get("coordinate_frame") != "k1-map" + or profile.get("authority") + != {"commands_enabled": False, "navigation_or_safety_accepted": False} + or not 1 <= int(profile["bounds"].get("maximum_tracks", 0)) <= 512 + or int(profile["tracking"].get("minimum_confirmation_hits", 0)) < 2 + or not isinstance(profile["motion"].get("dynamic_enter_speed_mps"), dict) + or float(profile["motion"]["static_enter_speed_mps"]) + >= float(profile["motion"]["dynamic_enter_speed_mps"].get("default", -1)) + ): + raise SessionIntegrityError("LAB E24 world-motion profile is invalid") + return profile, _sha256(resolved) + + +def read_motion_benchmark(path: Path) -> tuple[dict[str, Any], str]: + resolved = path.expanduser().resolve(strict=True) + benchmark = _read_object(resolved) + events = benchmark.get("events") + if ( + benchmark.get("schema_version") != BENCHMARK_SCHEMA + or not isinstance(events, list) + or not events + or benchmark.get("source_session_id") != "20260720T065719Z_viewer_live" + ): + raise SessionIntegrityError("LAB E24 motion benchmark is invalid") + identifiers: set[str] = set() + for event in events: + if ( + not isinstance(event, dict) + or not isinstance(event.get("id"), str) + or event["id"] in identifiers + or event.get("expected_motion") not in {"static", "dynamic"} + or event.get("class_group") + not in {"person", "vehicle", "bicycle", "vulnerable_road_user"} + or not isinstance(event.get("window_seconds"), list) + or len(event["window_seconds"]) != 2 + or float(event["window_seconds"][0]) >= float(event["window_seconds"][1]) + ): + raise SessionIntegrityError("LAB E24 benchmark event is invalid") + identifiers.add(event["id"]) + return benchmark, _sha256(resolved) + + +def evaluate_motion_benchmark( + world_rows: list[dict[str, Any]], + benchmark: dict[str, Any], +) -> dict[str, Any]: + outcomes: list[dict[str, Any]] = [] + for event in benchmark["events"]: + start, end = (float(value) for value in event["window_seconds"]) + observations: list[tuple[float, dict[str, Any]]] = [] + for row in world_rows: + timestamp = float(row["session_seconds"]) + if not start <= timestamp <= end: + continue + for item in row["objects"]: + class_matches = ( + item.get("class") in {"person", "bicycle", "motorcycle"} + if event["class_group"] == "vulnerable_road_user" + else item.get("class") == event["class_group"] + ) + if ( + class_matches + and item.get("motion_state") == event["expected_motion"] + and float(item.get("observation_age_ms", 0.0)) + <= float(event.get("maximum_observation_age_ms", 500.0)) + ): + observations.append((timestamp, item)) + by_track: dict[int, list[float]] = {} + for timestamp, item in observations: + by_track.setdefault(int(item["track_id"]), []).append(timestamp) + best_track: int | None = None + best_hits = 0 + best_span = 0.0 + for track_id, timestamps in by_track.items(): + span = max(timestamps) - min(timestamps) + if (len(timestamps), span) > (best_hits, best_span): + best_track = track_id + best_hits = len(timestamps) + best_span = span + minimum_hits = int(event.get("minimum_hits", 3)) + minimum_span = float(event.get("minimum_span_seconds", 0.2)) + passed = best_hits >= minimum_hits and best_span >= minimum_span + outcomes.append( + { + "id": event["id"], + "expected_motion": event["expected_motion"], + "class_group": event["class_group"], + "window_seconds": [start, end], + "passed": passed, + "best_track_id": best_track, + "best_hits": best_hits, + "best_span_seconds": best_span, + } + ) + return { + "schema_version": "missioncore.e24-motion-benchmark-result/v1", + "passed": all(item["passed"] for item in outcomes), + "passed_events": sum(item["passed"] for item in outcomes), + "total_events": len(outcomes), + "events": outcomes, + } + + +def build_world_motion_result( + *, + source: IntegratedPerceptionResult, + lab_job: CameraComputeJob, + lab_pack: Path, + results_root: Path, + profile_path: Path, + benchmark_path: Path, +) -> WorldMotionBuild: + """Build an immutable E24 projection from one accepted integrated result.""" + + if not source.accepted: + raise SessionIntegrityError("LAB E24 source result is not accepted") + profile, profile_sha256 = read_world_motion_profile(profile_path) + benchmark, benchmark_sha256 = read_motion_benchmark(benchmark_path) + if source.job.session_id != benchmark["source_session_id"]: + raise SessionIntegrityError("LAB E24 benchmark source session differs") + source_document = _read_object(source.result_root / "result.json") + source_report = _read_object(source.report_path) + source_fusion = _read_jsonl(source.fusion_path) + source_world = _read_jsonl(source.world_path) + if len(source_fusion) != source.frame_count or len(source_world) != source.frame_count: + raise SessionIntegrityError("LAB E24 source timelines are incomplete") + + tracker = WorldMotionTracker(profile) + fusion_rows: list[dict[str, Any]] = [] + world_rows: list[dict[str, Any]] = [] + frame_ms: list[float] = [] + for fusion, world in zip(source_fusion, source_world, strict=True): + started = time.perf_counter() + fusion_objects, world_objects = tracker.update( + frame_index=int(fusion["frame_index"]), + session_seconds=float(fusion["session_seconds"]), + objects=list(fusion["objects"]), + ) + normalized_fusion = json.loads(json.dumps(fusion)) + normalized_fusion["objects"] = fusion_objects + normalized_fusion["world_motion"] = { + "schema_version": "missioncore.e24-world-motion-frame/v1", + "coordinate_frame": "k1-map", + "lookahead_frames": 0, + } + normalized_world = json.loads(json.dumps(world)) + normalized_world["objects"] = world_objects + normalized_world["object_count"] = len(world_objects) + delivery = normalized_world.setdefault("delivery", {}) + delivery["world_motion"] = "e24-world-frame-cv-hysteresis" + elapsed = (time.perf_counter() - started) * 1000.0 + delivery["world_motion_processing_ms"] = elapsed + fusion_rows.append(normalized_fusion) + world_rows.append(normalized_world) + frame_ms.append(elapsed) + + benchmark_result = evaluate_motion_benchmark(world_rows, benchmark) + runtime = _percentiles(frame_ms) + checks = { + "source_payloads_immutable": True, + "diagnostic_authority_only": True, + "single_pass_no_lookahead": True, + "track_state_bound": tracker.peak_tracks + <= int(profile["acceptance"]["maximum_tracks_observed"]), + "processing_p95_ms": runtime["p95"] + <= float(profile["acceptance"]["maximum_processing_p95_ms"]), + } + accepted = all(checks.values()) + source_identity = source_document["identity"] + configuration = { + "pipeline": PIPELINE_ID, + "profile_sha256": profile_sha256, + "profile": profile, + "implementation_sha256": _sha256(Path(__file__).resolve()), + "benchmark_sha256": benchmark_sha256, + "benchmark": benchmark, + "source_result_id": source.result_id, + "source_result_sha256": _sha256(source.result_root / "result.json"), + "source_session_id": source.job.session_id, + "processing": "single-pass-bounded-state-no-lookahead", + } + identity = { + "schema_version": "missioncore.e10-integrated-perception-identity/v1", + "job_id": lab_job.job_id, + "input_sha256": lab_job.input_sha256, + "session_id": lab_job.session_id, + "source_id": lab_job.source_id, + "lidar_pack_id": lab_pack.name, + "selection": json.loads(json.dumps(source_identity["selection"])), + "configuration": configuration, + "models": source_identity["models"], + } + identity_sha256 = hashlib.sha256(_canonical(identity)).hexdigest() + result_id = f"e10-integrated-perception-{identity_sha256}" + destination = results_root / result_id + created_at = datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") + report = { + "schema_version": "missioncore.e10-integrated-perception-report/v1", + "result_id": result_id, + "created_at_utc": created_at, + "state": "accepted" if accepted else "rejected", + "ground_truth": False, + "identity": identity, + "acceptance": { + "accepted": accepted, + "navigation_or_safety_accepted": False, + "checks": checks, + }, + "metrics": { + "runtime": { + "world_motion_frame_processing_ms": runtime, + **tracker.snapshot(), + }, + "benchmark": benchmark_result, + "source_realtime_envelope": source_report.get("metrics", {}), + }, + "runtime": { + "executor": "local-deterministic-postprocessor", + "numpy": np.__version__, + "streaming_equivalent": True, + "lookahead_frames": 0, + }, + "limitations": [ + "Recorded diagnostic qualification only; no navigation or command authority.", + "Motion state is tracking-by-detection evidence, not labeled ground truth.", + "User-supplied event timestamps are approximate operator annotations.", + "Unknown is intentional when motion evidence is insufficient or contradictory.", + "Raw LiDAR, ground returns, camera media, calibration and model outputs are unchanged.", + ], + } + if destination.exists(): + existing = _read_object(destination / "result.json") + if existing.get("identity") != identity: + raise SessionIntegrityError("LAB E24 result id collides") + return WorldMotionBuild( + destination, + profile_sha256, + benchmark_sha256, + _read_object(destination / "run-report.json"), + ) + + results_root.mkdir(mode=0o700, parents=True, exist_ok=True) + staging = results_root / f".{result_id}.{secrets.token_hex(12)}.tmp" + staging.mkdir(mode=0o700) + try: + semantic_path = staging / "semantic-frames.jsonl" + fusion_path = staging / "fusion-frames.jsonl" + world_path = staging / "world-state.jsonl" + arrays_path = staging / "transient-perception.npz" + gpu_path = staging / "gpu-telemetry.jsonl" + report_path = staging / "run-report.json" + os.link(source.semantic_path, semantic_path, follow_symlinks=False) + _write_jsonl(fusion_path, fusion_rows) + _write_jsonl(world_path, world_rows) + box_offsets, centers, half_sizes, quaternions, colors = _box_arrays(fusion_rows) + _rewrite_npz_boxes( + source.arrays_path, + arrays_path, + { + "box_offsets": box_offsets, + "box_centers": centers, + "box_half_sizes": half_sizes, + "box_quaternions": quaternions, + "box_colors": colors, + }, + ) + os.link( + source.result_root / "gpu-telemetry.jsonl", + gpu_path, + follow_symlinks=False, + ) + write_json_atomic(report_path, report) + artifacts = [ + _artifact("e10-semantic-frames", semantic_path, "missioncore.e10-semantic-frame/v1"), + _artifact("e10-fusion-frames", fusion_path, "missioncore.e10-fusion-frame/v1"), + _artifact("e10-world-state", world_path, "missioncore.live-perception-world-state/v1"), + _artifact("e10-transient-perception", arrays_path, None), + _artifact("worker-gpu-telemetry", gpu_path, None), + _artifact( + "e10-run-report", + report_path, + "missioncore.e10-integrated-perception-report/v1", + ), + ] + write_json_atomic( + staging / "result.json", + { + "schema_version": "missioncore.e10-integrated-perception-result/v1", + "result_id": result_id, + "identity_sha256": identity_sha256, + "identity": identity, + "created_at_utc": created_at, + "ground_truth": False, + "publication_scope": "recorded-integrated-realtime-qualification-only", + "acceptance_state": "accepted" if accepted else "rejected", + "frames_processed": source.frame_count, + "artifacts": artifacts, + }, + ) + os.replace(staging, destination) + finally: + if staging.exists(): + shutil.rmtree(staging) + return WorldMotionBuild(destination, profile_sha256, benchmark_sha256, report) + + +def _robust_motion( + history: deque[tuple[float, np.ndarray]], +) -> tuple[float | None, float]: + if len(history) < 3: + return None, 0.0 + velocities: list[np.ndarray] = [] + values = list(history) + for start in range(len(values) - 1): + for end in range(start + 1, len(values)): + dt = values[end][0] - values[start][0] + if dt < 0.35: + continue + velocities.append((values[end][1] - values[start][1]) / dt) + if not velocities: + return None, 0.0 + planar = np.stack(velocities)[:, :2] + velocity = np.median(planar, axis=0) + norms = np.linalg.norm(planar, axis=1) + directional = planar[norms > 1e-6] / norms[norms > 1e-6, None] + consistency = ( + 0.0 if len(directional) == 0 else float(np.linalg.norm(np.mean(directional, axis=0))) + ) + return float(np.linalg.norm(velocity)), consistency + + +def _compatible_group(left: str, right: str) -> bool: + if left == right: + return True + vulnerable = {"person", "bicycle", "motorcycle"} + return left in vulnerable and right in vulnerable + + +def _valid_vector(value: Any, length: int) -> bool: + if not isinstance(value, list) or len(value) != length: + return False + return all( + isinstance(item, int | float) and not isinstance(item, bool) and math.isfinite(float(item)) + for item in value + ) + + +def _box_arrays( + fusion_rows: list[dict[str, Any]], +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + offsets = [0] + centers: list[list[float]] = [] + sizes: list[list[float]] = [] + quaternions: list[list[float]] = [] + colors: list[list[int]] = [] + for row in fusion_rows: + for item in row["objects"]: + if not str(item.get("cuboid_status", "")).startswith("accepted-"): + continue + centers.append(item["cuboid_center_map"]) + sizes.append(item["cuboid_half_size"]) + quaternions.append(item["cuboid_quaternion_xyzw"]) + colors.append(_cuboid_color(item)) + offsets.append(len(centers)) + return ( + np.asarray(offsets, dtype=np.int64), + np.asarray(centers, dtype=np.float32).reshape((-1, 3)), + np.asarray(sizes, dtype=np.float32).reshape((-1, 3)), + np.asarray(quaternions, dtype=np.float32).reshape((-1, 4)), + np.asarray(colors, dtype=np.uint8).reshape((-1, 4)), + ) + + +def _rewrite_npz_boxes( + source: Path, + destination: Path, + replacements: dict[str, np.ndarray], +) -> None: + replacement_names = {f"{name}.npy" for name in replacements} + with ( + zipfile.ZipFile(source, "r") as reader, + zipfile.ZipFile( + destination, + "x", + compression=zipfile.ZIP_DEFLATED, + compresslevel=6, + allowZip64=True, + ) as writer, + ): + for info in reader.infolist(): + if info.filename in replacement_names: + continue + with ( + reader.open(info, "r") as source_stream, + writer.open( + info.filename, + "w", + force_zip64=True, + ) as target_stream, + ): + shutil.copyfileobj(source_stream, target_stream, length=1024 * 1024) + for name, array in replacements.items(): + with writer.open(f"{name}.npy", "w", force_zip64=True) as stream: + np.lib.format.write_array(stream, array, allow_pickle=False) + os.chmod(destination, 0o600) + + +def _cuboid_color(item: dict[str, Any]) -> list[int]: + state = item.get("motion_state") + if state == "dynamic": + return [245, 81, 87, 112] + if state == "static": + return [75, 190, 128, 88] + return [238, 180, 55, 88] + + +def _percentiles(values: list[float]) -> dict[str, float]: + if not values: + return {"mean": 0.0, "p50": 0.0, "p95": 0.0, "maximum": 0.0} + ordered = np.asarray(values, dtype=np.float64) + return { + "mean": float(statistics.fmean(values)), + "p50": float(np.percentile(ordered, 50)), + "p95": float(np.percentile(ordered, 95)), + "maximum": float(np.max(ordered)), + } + + +def _read_jsonl(path: Path) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + with path.open(encoding="utf-8") as stream: + for line in stream: + value = json.loads(line) + if not isinstance(value, dict): + raise SessionIntegrityError("LAB E24 JSONL row is not an object") + rows.append(value) + return rows + + +def _write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: + with path.open("x", encoding="utf-8") as stream: + for row in rows: + stream.write( + json.dumps( + row, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + ) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.chmod(path, 0o600) + + +def _read_object(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise SessionIntegrityError("LAB E24 JSON document is not an object") + return value + + +def _artifact(kind: str, path: Path, schema: str | None) -> dict[str, Any]: + return { + "kind": kind, + "path": path.name, + "schema_version": schema, + "byte_length": path.stat().st_size, + "sha256": _sha256(path), + } + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while block := stream.read(1024 * 1024): + digest.update(block) + return digest.hexdigest() + + +def _canonical(value: Any) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode() diff --git a/src/k1link/device_plugins/xgrids_k1/cli.py b/src/k1link/device_plugins/xgrids_k1/cli.py index 45c44d0..dbcc591 100644 --- a/src/k1link/device_plugins/xgrids_k1/cli.py +++ b/src/k1link/device_plugins/xgrids_k1/cli.py @@ -22,6 +22,7 @@ from k1link.compute import ( publish_e21_lab_instance, publish_e22_lab_instance, publish_e23_lab_instance, + publish_e24_lab_instance, publish_integrated_lab_instance, ) from k1link.device_plugins.xgrids_k1.analyze import ( @@ -581,6 +582,80 @@ def publish_e23_lab( ) +@lab_app.command("publish-e24") +def publish_e24_lab( + result: Annotated[ + Path, + typer.Option( + exists=True, + file_okay=False, + readable=True, + resolve_path=True, + help="Accepted full-session integrated result used as the E24 source.", + ), + ], + profile: Annotated[ + Path, + typer.Option( + exists=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Bounded E24 world-motion profile.", + ), + ], + benchmark: Annotated[ + Path, + typer.Option( + exists=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Operator-anchored E24 motion benchmark.", + ), + ], + session_id: Annotated[ + str, + typer.Option("--session-id", help="New immutable LAB session id."), + ], + lab_id: Annotated[ + str, + typer.Option("--lab-id", help="LAB marker, for example 'LAB E24'."), + ], + display_name: Annotated[ + str, + typer.Option("--display-name", help="Operator-facing saved-session title."), + ], +) -> None: + """Build and publish a bounded world-frame motion-tracking run.""" + + repository_root = Path(__file__).resolve().parents[4] + try: + published = publish_e24_lab_instance( + repository_root=repository_root, + source_result_root=result, + profile_path=profile, + benchmark_path=benchmark, + lab_session_id=session_id, + lab_id=lab_id, + display_name=display_name, + ) + except (OSError, SessionIntegrityError, RuntimeError, ValueError) as exc: + console.print(f"[red]E24 LAB publication failed:[/red] {exc}") + raise typer.Exit(code=2) from exc + metrics = published.build.report["metrics"] + console.print( + "[green]E24 LAB instance published.[/green] " + f"session={published.binding.session_id}; " + f"source={published.binding.source_session_id}; " + f"result={published.binding.result_id}; " + f"benchmark={metrics['benchmark']['passed_events']}/" + f"{metrics['benchmark']['total_events']}; " + f"p95={metrics['runtime']['world_motion_frame_processing_ms']['p95']:.3f}ms; " + "source_payloads_mutated=false" + ) + + @app.command("serve") def serve_console( port: Annotated[ diff --git a/tests/test_e24_world_motion.py b/tests/test_e24_world_motion.py new file mode 100644 index 0000000..fa54c47 --- /dev/null +++ b/tests/test_e24_world_motion.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from k1link.compute.world_motion import ( + WorldMotionTracker, + evaluate_motion_benchmark, + read_motion_benchmark, + read_world_motion_profile, +) + + +def _profile() -> dict[str, object]: + root = Path(__file__).resolve().parents[1] + profile, digest = read_world_motion_profile( + root / "experiments" / "perception" / "e24_world_motion_profile.json" + ) + assert len(digest) == 64 + return profile + + +def _object(track_id: int, x: float, *, group: str = "vehicle") -> dict[str, object]: + label = "person" if group == "person" else "car" + half_size = [0.35, 0.35, 0.9] if group == "person" else [2.25, 0.925, 0.775] + return { + "association_group": group, + "bbox_xyxy": [100.0, 100.0, 200.0, 200.0], + "clustered_points": 32, + "cuboid_center_map": [x, 2.0, 0.8], + "cuboid_half_size": half_size, + "cuboid_quaternion_xyzw": [0.0, 0.0, 0.0, 1.0], + "cuboid_status": "accepted-class-prior-amodal-v1", + "distance_smoothed_m": 10.0, + "geometry": "class-prior-completed-from-visible-lidar-support", + "label": label, + "score": 0.9, + "track_id": track_id, + } + + +def _provisional_person(track_id: int, x: float) -> dict[str, object]: + value = _object(track_id, x, group="person") + value.update( + { + "cuboid_center_map": None, + "cuboid_half_size": None, + "cuboid_quaternion_xyzw": None, + "cuboid_status": "rejected-amodal-completion-support-coverage-below-threshold", + "observed_cuboid_center_map": [x, 2.0, 0.8], + "observed_cuboid_quaternion_xyzw": [0.0, 0.0, 0.0, 1.0], + } + ) + return value + + +def test_e24_profile_is_bounded_and_has_no_control_authority() -> None: + profile = _profile() + + assert profile["source"]["coordinate_frame"] == "k1-map" + assert profile["bounds"]["maximum_tracks"] == 192 + assert profile["authority"] == { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } + + +def test_e24_reassociates_changed_source_id_and_classifies_static() -> None: + tracker = WorldMotionTracker(_profile()) + last = [] + for frame in range(30): + source_id = 10 if frame < 12 else 77 + jitter = 0.025 if frame % 2 else -0.025 + last, world = tracker.update( + frame_index=frame, + session_seconds=frame * 0.1, + objects=[_object(source_id, 10.0 + jitter)], + ) + + assert last[0]["track_id"] == 240001 + assert last[0]["source_track_id"] == 77 + assert world[0]["motion_state"] == "static" + assert tracker.snapshot()["reassociated_source_ids"] == 1 + + +def test_e24_classifies_sustained_world_motion_dynamic() -> None: + tracker = WorldMotionTracker(_profile()) + world = [] + for frame in range(35): + _, world = tracker.update( + frame_index=frame, + session_seconds=frame * 0.1, + objects=[_object(20, 5.0 + frame * 0.12, group="person")], + ) + + assert world[0]["motion_state"] == "dynamic" + assert world[0]["speed_mps"] is not None + assert world[0]["speed_mps"] > 0.75 + + +def test_e24_confirms_sparse_visible_support_without_promoting_first_hit() -> None: + tracker = WorldMotionTracker(_profile()) + first, first_world = tracker.update( + frame_index=0, + session_seconds=0.0, + objects=[_provisional_person(21, 5.0)], + ) + tracker.update( + frame_index=1, + session_seconds=0.5, + objects=[_provisional_person(21, 5.5)], + ) + third, third_world = tracker.update( + frame_index=2, + session_seconds=1.0, + objects=[_provisional_person(21, 6.0)], + ) + + assert first[0]["cuboid_status"].startswith("rejected-") + assert first_world == [] + assert third[0]["cuboid_status"] == "accepted-world-track-provisional-e24-v1" + assert third_world[0]["track_hits"] == 3 + + +def test_e24_holds_confirmed_track_briefly_then_expires() -> None: + tracker = WorldMotionTracker(_profile()) + for frame in range(12): + tracker.update( + frame_index=frame, + session_seconds=frame * 0.1, + objects=[_object(30, 3.0)], + ) + held, held_world = tracker.update( + frame_index=12, + session_seconds=1.2, + objects=[], + ) + expired, expired_world = tracker.update( + frame_index=20, + session_seconds=2.0, + objects=[], + ) + + assert held[0]["cuboid_status"] == "accepted-world-track-hold-e24-v1" + assert held_world[0]["observation_age_ms"] == pytest.approx(100.0) + assert expired == [] + assert expired_world == [] + + +def test_e24_benchmark_contract_and_evaluator() -> None: + root = Path(__file__).resolve().parents[1] + benchmark, digest = read_motion_benchmark( + root / "experiments" / "perception" / "e24_motion_benchmark.json" + ) + event = benchmark["events"][0] + rows = [ + { + "session_seconds": 55.0 + index * 0.2, + "objects": [ + { + "track_id": 1, + "class": event["class_group"], + "motion_state": event["expected_motion"], + "observation_age_ms": 0.0, + } + ], + } + for index in range(5) + ] + one_event = {**benchmark, "events": [event]} + result = evaluate_motion_benchmark(rows, one_event) + + assert len(digest) == 64 + assert result["passed"] is True + assert result["events"][0]["best_hits"] == 5