diff --git a/experiments/perception/LAB_E25_REPORT_2026-07-24.md b/experiments/perception/LAB_E25_REPORT_2026-07-24.md new file mode 100644 index 0000000..ea1393d --- /dev/null +++ b/experiments/perception/LAB_E25_REPORT_2026-07-24.md @@ -0,0 +1,273 @@ +# LAB E25 — persistent LiDAR support and honest motion evidence + +Date: 2026-07-24 +Status: diagnostic artifact accepted; motion benchmark not accepted +Best immutable replay: +`LAB E25.3 · Persistent support · full RAVNOVES00` + +## Objective + +Test a different motion measurement after E24 proved that the center of a +completed 3D cuboid is not a stable world-space observation. E25 tracks the +LiDAR cells that actually support an object in `k1-map`, compares their +occupancy through time and emits `static`, `dynamic`, or `unknown`. + +This is not a box beautification experiment. The target is a bounded, +streaming-equivalent evidence branch for an unmanned vehicle. 3D boxes remain +an operator-facing presentation. Missing sensor evidence must remain +`unknown/occupied`; it must not be converted into an invented motion estimate +or free space. + +## Source data and immutable inputs + +- Source session: `20260720T065719Z_viewer_live` (`RAVNOVES00`). +- Camera source: `sensor.camera.right`, factory slot `camera_1`. +- Source frames: 4,489. +- Source timeline: 35.421857292–484.044857292 session seconds. +- Full map-frame LiDAR pack: 9,207,270 points. +- Semantic masks: 898 frames at 600×800. +- Factory camera model: KB4. +- Calibration SHA-256: + `05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`. +- Source integrated result: + `e10-integrated-perception-34ade557b5636717aa497fc00355b84df7063e483a175f2f5d3f04c03df1c898`. +- Source detector: YOLOX-S, COCO-80, 640×640. +- Source semantic model: + `tue-mps/cityscapes_semantic_eomt_large_1024`, FP16 autocast. + +E25 does not repeat GPU inference. It reuses the accepted detector and semantic +outputs and recomputes object support from the immutable full LiDAR pack, +factory projection and rig poses. Raw LiDAR, ground returns, camera media, +calibration, detector output and semantic masks were not modified. + +## Why E25 was required + +E24 classified motion from the temporal behavior of fitted cuboid centers. +That measurement is biased by visibility: while the rig passes a parked car, +different portions of the car become visible, and an amodal box fitter can move +its completed center even though the physical object is static. + +This creates a structurally false velocity. More smoothing only delays it. +Looser association hides missing observations but can join neighboring cars. +Stricter association preserves identity but exposes gaps. E25 therefore changes +the measurement rather than tuning another center filter. + +## Implemented pipeline + +For every source detector object, E25: + +1. takes the synchronized full LiDAR scan in `k1-map`; +2. projects candidate points through the factory KB4 camera calibration; +3. applies detector-box inset and compatible semantic labels; +4. performs depth and spatial clustering to reject unrelated projected points; +5. excludes ground only from the object-proposal branch while preserving the + complete ground and raw point cloud for visualization and downstream use; +6. quantizes object support into 0.25 m map-frame occupancy cells; +7. stores a bounded history of occupied cells and support points; +8. compares the current support with a reference 0.3–2.5 s in the past; +9. classifies static evidence from persistent overlap; +10. classifies dynamic evidence only when overlap is low and displacement, + robust speed, direction consistency and repeated current observations agree. + +The E25 branch is deliberately bounded: + +- maximum 192 live tracks; +- maximum 32 support snapshots per track; +- maximum 256 points and 256 cells per object snapshot; +- no lookahead; +- no commands, navigation authority or safety authority. + +The final pass also enforces an important evidence invariant: one current LiDAR +measurement can belong only to the current `e24-observed` object. An E24 +`held-hypothesis` may preserve a short-lived track state, but it receives +`current=false` and cannot duplicate the same present-time support. + +## Benchmark contract + +Benchmark v2 is bound to source detector IDs and time windows rather than to +any nearby object of the expected class. This prevents a parked car from +accidentally satisfying a moving-car event. + +Dynamic operator anchors: + +- 58 s: woman with dog, left; +- 65 s: person loading a car, right; +- 115 s: adult and child, left; +- 127 s: pedestrian on 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 with the rig direction, right. + +Static controls: + +- parked vehicles at 70–90 s; +- parked vehicles at 132–148 s; +- parked vehicles at 161–166 s. + +Frame review corrected the 183 s anchor from source track 391, which is parked, +to source track 373, which is the moving vehicle. Static controls report +coverage, classified fraction and false-dynamic fraction separately. A lack of +classification is therefore not counted as a correct static result. + +## Run series + +### E25.1 — first persistent-support pass + +- Immutable session: `lab-e25-1-persistent-support`. +- Result: + `e10-integrated-perception-fac9421996ce5d8622c534f66af1a9bfaea25c7ea8a2bfd1ec954330d12ce9ed`. +- Benchmark: 7/11. +- Processing p95: 3.966 ms/frame. + +An initial CPU-bound attempt was stopped because the NPZ container was +decompressing LiDAR members repeatedly for every frame. This was a laboratory +loader defect, not tracker cost. Materializing the immutable arrays once in +memory reduced the full pass to about 20 seconds. + +### E25.2 — benchmark identity correction + +- Immutable session: `lab-e25-2-persistent-support`. +- Result: + `e10-integrated-perception-e24715571eb5c40d6f0a853bb0551737b2935e0ccbb65e2b90061c0f422a49e3`. +- Benchmark: 7/11. +- Processing p95: 3.970 ms/frame. + +This pass binds the 183 s event to moving source track 373 and adds defensive +deduplication for source/frame evidence in the evaluator. + +### E25.3 — single-owner current evidence + +- Immutable session: `lab-e25-3-persistent-support`. +- Result: + `e10-integrated-perception-9034a5cf306b379cc248c1da17639f140fc896bb55e412bef43f89974de892a3`. +- Benchmark: 6/11. +- Processing mean/p50/p95: + 2.114/2.078/4.322 ms per frame. +- Processing maximum: 84.791 ms/frame. +- Peak live tracker states: 16 of 192. +- Current support observations: 7,204. +- Explicit held states: 3,073. +- Full laboratory packaging: approximately 20.5 s. + +The score decreased from 7/11 to 6/11 because held hypotheses no longer reuse +current LiDAR evidence. This is the accepted result: it is less flattering and +more correct. + +## E25.3 event results + +Passed: + +- woman/dog at 58 s; +- adult/child at 115 s; +- vulnerable-road-user group at 173 s; +- all three parked-vehicle control windows. + +Static control details: + +- 70–90 s: coverage 0.4619, classified fraction 0.77665, + 306 static, 88 unknown, false-dynamic fraction 0.0; +- 132–148 s: coverage 0.5683, classified fraction 0.63948, + 298 static, 168 unknown, false-dynamic fraction 0.0; +- 161–166 s: coverage 0.1722, classified fraction 0.61538, + 16 static, 10 unknown, false-dynamic fraction 0.0. + +Failed: + +- person loading car at 65 s: two current dynamic hits, minimum is three; +- right-side person at 127 s: only 3/21 frames contain usable LiDAR support; +- stroller group at 155 s: support exists, but association is ambiguous and + forks across source tracks; +- oncoming car at 170 s: only 2/15 current LiDAR-support observations, so no + reliable temporal history can be formed; +- same-direction car at 183 s, source track 373: the available evidence is + strong (`overlap=0`, approximately 4.8–4.9 m/s), but only two current dynamic + hits remain after held-evidence duplication is removed. + +## Performance and replay + +The tracker timing is CPU diagnostic timing without repeated detector or +semantic inference. Its p95 of 4.322 ms/frame is compatible with a future +near-real-time evidence branch, but it does not prove end-to-end real-time +performance. + +The first cold Rerun publication exposed a fail-soft viewer bug: a provisional +cuboid can legitimately have `distance_smoothed_m=null`, while the label +renderer called `float(None)`. The renderer now keeps the cuboid and simply +omits an unconfirmed range from its label. + +The final E25.3 overlay: + +- Rerun magic: `RRF2`; +- payload: 69,809,981 bytes; +- saved session: `ready`; +- replayable: `true`; +- warm endpoint response on the existing localhost server: HTTP 200 in 0.11 s. + +The server on `127.0.0.1:8000` was not stopped or restarted. + +## Main conclusion + +E25 fixes an important E24 failure: persistent map-frame LiDAR support no longer +turns cuboid-center drift on parked cars into false motion. All static control +windows have a false-dynamic fraction of 0.0. + +E25 also establishes the sensor limit. A LiDAR-only support branch cannot infer +motion when the object receives too few current returns. Threshold changes +cannot create absent observations. Treating those gaps as a confident velocity +would make the output visually smoother but less safe. + +E25.3 is therefore accepted as a diagnostic and evidence-quality result, not as +a navigation or safety motion classifier. + +## Change of emphasis for E26 + +E26 must add information from an independent source instead of further tuning +the LiDAR branch: + +1. maintain a temporally stable camera track in image space; +2. remove apparent image motion caused by rig ego-motion; +3. turn the factory KB4 pixel ray, rig pose/extrinsic and a bounded + ground-contact/range hypothesis into world-motion evidence; +4. fuse that evidence conservatively with the E25 LiDAR branch; +5. emit explicit `agree`, `single-source/unknown` and `conflict` outcomes; +6. keep unknown objects occupied for planning; +7. expose occupancy footprint, velocity and uncertainty to planner-facing + consumers while retaining boxes for the operator UI. + +Acceptance must be split into independent gates: + +- camera-track association integrity and ID-switch rate; +- current LiDAR-support coverage; +- false dynamic on parked vehicles; +- recall on the known moving people and two moving vehicles; +- camera/LiDAR conflict rate; +- end-to-end stage latency and bounded memory. + +This follows the established autonomous-driving separation between detection, +tracking, occupancy and validation. Foxglove and Rerun visualize those +products; they do not supply the motion-estimation algorithm. + +## Validation + +- 30 targeted E10/E24/E25 tests pass after the viewer fail-soft fix. +- Ruff passes for the changed modules. +- Targeted strict mypy passes for the changed compute modules. +- E25.3 passes the integrated-perception validator. +- The immutable saved session is ready and replayable on + `http://127.0.0.1:8000/`. +- Raw evidence was not modified. +- Navigation and safety acceptance remain explicitly false. + +## External implementation references + +- Autoware multi-object tracker: + +- Autoware detection-by-tracker: + +- Autoware probabilistic occupancy grid: + +- Autoware obstacle point-cloud validator: + +- Autoware perception architecture: + diff --git a/experiments/perception/e25_motion_benchmark.json b/experiments/perception/e25_motion_benchmark.json new file mode 100644 index 0000000..842fc6e --- /dev/null +++ b/experiments/perception/e25_motion_benchmark.json @@ -0,0 +1,131 @@ +{ + "schema_version": "missioncore.e25-motion-benchmark/v1", + "benchmark_id": "ravnoves00-spatially-bound-operator-anchors-v2", + "source_session_id": "20260720T065719Z_viewer_live", + "timeline": "session_seconds", + "annotation_status": "operator-approximate-with-reviewed-detector-track-binding", + "events": [ + { + "id": "dynamic-person-woman-dog-left-58s", + "kind": "target-motion", + "window_seconds": [54.0, 62.0], + "class_group": "person", + "target_source_track_ids": [83], + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2, + "minimum_coverage_fraction": 0.08 + }, + { + "id": "dynamic-person-loading-car-right-65s", + "kind": "target-motion", + "window_seconds": [62.0, 69.0], + "class_group": "person", + "target_source_track_ids": [112], + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2, + "minimum_coverage_fraction": 0.1 + }, + { + "id": "static-vehicles-before-moving-70-90s", + "kind": "static-control", + "window_seconds": [70.0, 90.0], + "class_group": "vehicle", + "expected_motion": "static", + "minimum_source_observations": 80, + "minimum_coverage_fraction": 0.2, + "minimum_classified_fraction": 0.2, + "maximum_false_dynamic_fraction": 0.1 + }, + { + "id": "dynamic-person-adult-child-left-115s", + "kind": "target-motion", + "window_seconds": [111.0, 120.0], + "class_group": "person", + "target_source_track_ids": [229], + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2, + "minimum_coverage_fraction": 0.08 + }, + { + "id": "dynamic-person-road-right-127s", + "kind": "target-motion", + "window_seconds": [124.0, 132.0], + "class_group": "person", + "target_source_track_ids": [253], + "expected_motion": "dynamic", + "minimum_hits": 2, + "minimum_span_seconds": 0.15, + "minimum_coverage_fraction": 0.08 + }, + { + "id": "static-vehicles-mid-run-132-148s", + "kind": "static-control", + "window_seconds": [132.0, 148.0], + "class_group": "vehicle", + "expected_motion": "static", + "minimum_source_observations": 80, + "minimum_coverage_fraction": 0.2, + "minimum_classified_fraction": 0.2, + "maximum_false_dynamic_fraction": 0.1 + }, + { + "id": "dynamic-person-stroller-group-left-155s", + "kind": "target-motion", + "window_seconds": [151.0, 161.0], + "class_group": "person", + "target_source_track_ids": [322, 328], + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2, + "minimum_coverage_fraction": 0.08 + }, + { + "id": "static-vehicles-before-oncoming-161-166s", + "kind": "static-control", + "window_seconds": [161.0, 166.0], + "class_group": "vehicle", + "expected_motion": "static", + "minimum_source_observations": 25, + "minimum_coverage_fraction": 0.15, + "minimum_classified_fraction": 0.15, + "maximum_false_dynamic_fraction": 0.1 + }, + { + "id": "dynamic-vehicle-oncoming-right-170s", + "kind": "target-motion", + "window_seconds": [167.0, 173.0], + "class_group": "vehicle", + "target_source_track_ids": [365], + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.15, + "minimum_coverage_fraction": 0.1 + }, + { + "id": "dynamic-person-bike-group-left-173s", + "kind": "target-motion", + "window_seconds": [170.0, 179.0], + "class_group": "vulnerable_road_user", + "target_source_track_ids": [379, 386, 388], + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2, + "minimum_coverage_fraction": 0.08 + }, + { + "id": "dynamic-vehicle-same-direction-right-183s", + "kind": "target-motion", + "window_seconds": [180.0, 188.0], + "class_group": "vehicle", + "target_source_track_ids": [373], + "binding_review": "frame-and-2d-track-reviewed; 391 is a parked vehicle", + "expected_motion": "dynamic", + "minimum_hits": 3, + "minimum_span_seconds": 0.2, + "minimum_coverage_fraction": 0.1 + } + ] +} diff --git a/experiments/perception/e25_persistent_support_profile.json b/experiments/perception/e25_persistent_support_profile.json new file mode 100644 index 0000000..8b31cf0 --- /dev/null +++ b/experiments/perception/e25_persistent_support_profile.json @@ -0,0 +1,77 @@ +{ + "schema_version": "missioncore.e25-persistent-support-profile/v1", + "profile_id": "lab-e25-map-frame-persistent-support-v1", + "mode": "recorded-streaming-qualification", + "source": { + "source_id": "sensor.camera.right", + "coordinate_frame": "k1-map", + "calibration_slot": "camera_1", + "calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9" + }, + "support": { + "minimum_evidence_points": { + "person": 2, + "bicycle": 2, + "motorcycle": 2, + "vehicle": 2, + "default": 2 + }, + "maximum_points_per_object": 256 + }, + "occupancy": { + "voxel_size_m": 0.25, + "neighbor_radius_cells": 1, + "reference_minimum_age_seconds": { + "person": 0.3, + "bicycle": 0.3, + "motorcycle": 0.3, + "vehicle": 0.55, + "default": 0.55 + }, + "reference_maximum_age_seconds": 2.5, + "history_seconds": 2.0, + "static_overlap_enter": 0.55, + "static_overlap_exit": 0.35, + "dynamic_overlap_enter": 0.25, + "dynamic_overlap_exit": 0.4, + "dynamic_minimum_speed_mps": { + "person": 0.4, + "bicycle": 0.5, + "motorcycle": 0.6, + "vehicle": 0.7, + "default": 0.7 + }, + "dynamic_minimum_displacement_m": { + "person": 0.4, + "bicycle": 0.45, + "motorcycle": 0.55, + "vehicle": 0.65, + "default": 0.65 + }, + "minimum_direction_consistency": 0.6, + "dynamic_confirmation_frames": { + "person": 2, + "bicycle": 2, + "motorcycle": 2, + "vehicle": 5, + "default": 5 + }, + "static_confirmation_frames": 4, + "contradiction_frames": 2, + "maximum_support_hold_seconds": 0.45, + "maximum_snapshots_per_track": 32, + "maximum_cells_per_snapshot": 256 + }, + "bounds": { + "maximum_tracks": 192, + "maximum_track_idle_seconds": 1.5 + }, + "acceptance": { + "maximum_processing_p95_ms": 12.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 108d62f..dbfbd4f 100644 --- a/src/k1link/compute/__init__.py +++ b/src/k1link/compute/__init__.py @@ -36,12 +36,14 @@ from .jobs import ( ) from .lab_instances import ( PublishedIntegratedLabInstance, + PublishedPersistentSupportLabInstance, PublishedTemporalLabInstance, PublishedWorldMotionLabInstance, publish_e21_lab_instance, publish_e22_lab_instance, publish_e23_lab_instance, publish_e24_lab_instance, + publish_e25_lab_instance, publish_integrated_lab_instance, ) from .live_perception import ( @@ -120,6 +122,7 @@ __all__ = [ "LivePerceptionIngress", "IntegratedPerceptionOverlayStore", "PublishedIntegratedLabInstance", + "PublishedPersistentSupportLabInstance", "PublishedTemporalLabInstance", "PublishedWorldMotionLabInstance", "IntegratedPerceptionResult", @@ -154,6 +157,7 @@ __all__ = [ "publish_e22_lab_instance", "publish_e23_lab_instance", "publish_e24_lab_instance", + "publish_e25_lab_instance", "publish_integrated_lab_instance", "validate_multirate_perception_qualification_result", "prepare_recorded_qualification_slice", diff --git a/src/k1link/compute/integrated_perception.py b/src/k1link/compute/integrated_perception.py index 8756663..69e8bc1 100644 --- a/src/k1link/compute/integrated_perception.py +++ b/src/k1link/compute/integrated_perception.py @@ -73,7 +73,7 @@ class _PresentedCuboid: track_id: int label: str association_group: str - distance_m: float + distance_m: float | None support_points: int center: np.ndarray half_size: np.ndarray @@ -126,12 +126,20 @@ class _CuboidPresentationState: raise RecordedPerceptionOverlayError( "integrated perception cuboid track identity is invalid" ) + distance_value = item.get("distance_smoothed_m") + distance_m = ( + float(distance_value) + if isinstance(distance_value, int | float) + and not isinstance(distance_value, bool) + and np.isfinite(float(distance_value)) + else None + ) self._latest[track_id] = _PresentedCuboid( observed_ns=timestamp_ns, track_id=track_id, label=str(item.get("label", "object")), association_group=str(item.get("association_group", "object")), - distance_m=float(item["distance_smoothed_m"]), + distance_m=distance_m, support_points=int(item["clustered_points"]), center=np.asarray(center).copy(), half_size=np.asarray(half_size).copy(), @@ -160,9 +168,10 @@ class _CuboidPresentationState: color[3] = max(24, round(float(color[3]) * (1.0 - 0.55 * fade))) presented_colors.append(color) age_label = "" if age_ns == 0 else f" · hold {age_ns / 1_000_000:.0f} ms" + distance_label = "" if cuboid.distance_m is None else f" · {cuboid.distance_m:.1f} m" labels.append( - f"{cuboid.association_group} #{cuboid.track_id} {cuboid.label} · " - f"{cuboid.distance_m:.1f} m · {cuboid.support_points} pts{age_label}" + f"{cuboid.association_group} #{cuboid.track_id} {cuboid.label}" + f"{distance_label} · {cuboid.support_points} pts{age_label}" ) return ( np.stack([cuboid.center for cuboid in presented]), diff --git a/src/k1link/compute/lab_instances.py b/src/k1link/compute/lab_instances.py index fe37871..5184d9d 100644 --- a/src/k1link/compute/lab_instances.py +++ b/src/k1link/compute/lab_instances.py @@ -28,6 +28,10 @@ from .integrated_perception import ( validate_integrated_perception_result, ) from .jobs import CameraComputeJob, validate_camera_compute_job +from .occupancy_motion import ( + PersistentSupportBuild, + build_persistent_support_result, +) from .temporal_stability import ( TemporalStabilityBuild, _quality_metrics, @@ -59,6 +63,14 @@ class PublishedWorldMotionLabInstance: build: WorldMotionBuild +@dataclass(frozen=True, slots=True) +class PublishedPersistentSupportLabInstance: + binding: LabSessionBinding + job: CameraComputeJob + result: IntegratedPerceptionResult + build: PersistentSupportBuild + + def publish_integrated_lab_instance( *, repository_root: Path, @@ -590,6 +602,113 @@ def publish_e24_lab_instance( ) +def publish_e25_lab_instance( + *, + repository_root: Path, + source_result_root: Path, + world_motion_profile_path: Path, + profile_path: Path, + benchmark_path: Path, + lab_session_id: str, + lab_id: str, + display_name: str, +) -> PublishedPersistentSupportLabInstance: + """Derive and publish one bounded persistent-support motion 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("E25 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("E25 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_persistent_support_result( + source=source, + lab_job=lab_job, + lab_pack=lab_pack, + results_root=results_root, + world_motion_profile_path=world_motion_profile_path, + 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"E25 persistent-support 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="e25-persistent-support-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.e25-lab-publication/v1", + "storage_mode": "bounded-persistent-support-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", + "measurement": "persistent-object-support-occupancy", + "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"], + "persistent_support_processing_p95_ms": metrics["runtime"][ + "persistent_support_frame_processing_ms" + ]["p95"], + "peak_tracks": metrics["runtime"]["peak_tracks"], + "navigation_or_safety_accepted": False, + }, + ) + return PublishedPersistentSupportLabInstance( + 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/occupancy_motion.py b/src/k1link/compute/occupancy_motion.py new file mode 100644 index 0000000..61e0136 --- /dev/null +++ b/src/k1link/compute/occupancy_motion.py @@ -0,0 +1,1466 @@ +"""Persistent map-frame support evidence for conservative object motion. + +LAB E25 keeps the detector, semantic masks, full LiDAR cloud, calibration and +source cuboids immutable. It replays the already recorded inputs once, rebuilds +per-object LiDAR support, and compares current occupied cells with older cells +in ``k1-map``. A 3D box remains presentation geometry; motion is not inferred +from the completed box center alone. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import secrets +import shutil +import time +from collections import Counter, defaultdict, deque +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import numpy as np +import numpy.typing as npt + +from k1link.artifacts import write_json_atomic +from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import ( + Kb4ProjectionProfile, + map_points_to_lidar, + project_map_points_kb4, +) +from k1link.sessions import SessionIntegrityError + +from .integrated_perception import IntegratedPerceptionResult +from .jobs import CameraComputeJob +from .world_motion import ( + WorldMotionTracker, + _artifact, + _box_arrays, + _canonical, + _percentiles, + _read_jsonl, + _read_object, + _rewrite_npz_boxes, + _robust_motion, + _sha256, + _write_jsonl, + read_world_motion_profile, +) + +PROFILE_SCHEMA = "missioncore.e25-persistent-support-profile/v1" +BENCHMARK_SCHEMA = "missioncore.e25-motion-benchmark/v1" +PIPELINE_ID = "map-frame-persistent-support-dual-hypothesis/v1" + +FloatArray = npt.NDArray[np.float64] +IntArray = npt.NDArray[np.int64] +Cell = tuple[int, int] + + +@dataclass(frozen=True, slots=True) +class SupportMeasurement: + source_track_id: int + label: str + group: str + score: float + points_map: FloatArray + source_indices: IntArray + + +@dataclass(slots=True) +class _SupportSnapshot: + session_seconds: float + cells: frozenset[Cell] + center_map: np.ndarray + + +@dataclass(slots=True) +class _OccupancyTrack: + track_id: int + group: str + aliases: set[int] + snapshots: deque[_SupportSnapshot] = field(default_factory=deque) + motion_state: str = "unknown" + static_evidence: int = 0 + dynamic_evidence: int = 0 + contradictions: int = 0 + overlap_fraction: float | None = None + speed_mps: float | None = None + direction_consistency: float = 0.0 + displacement_m: float | None = None + confidence: float = 0.0 + velocity_map_mps: np.ndarray | None = None + last_observed_seconds: float = 0.0 + support_observations: int = 0 + current_cells: frozenset[Cell] = frozenset() + + +@dataclass(frozen=True, slots=True) +class PersistentSupportBuild: + result_root: Path + profile_sha256: str + benchmark_sha256: str + report: dict[str, Any] + + +class PersistentSupportTracker: + """Bounded occupancy-evidence state keyed by the E24 world track.""" + + def __init__(self, profile: dict[str, Any]) -> None: + self.profile = profile + self.tracks: dict[int, _OccupancyTrack] = {} + self.peak_tracks = 0 + self.created_tracks = 0 + self.support_observations = 0 + self.held_states = 0 + self.published_states: Counter[str] = Counter() + + def observe( + self, + *, + track_id: int, + source_track_id: int, + group: str, + session_seconds: float, + points_map: FloatArray, + ) -> dict[str, Any]: + track = self.tracks.get(track_id) + if track is None: + track = _OccupancyTrack( + track_id=track_id, + group=group, + aliases={source_track_id}, + last_observed_seconds=session_seconds, + ) + self.tracks[track_id] = track + self.created_tracks += 1 + else: + track.aliases.add(source_track_id) + cells = _voxel_cells( + points_map, + voxel_size=float(self.profile["occupancy"]["voxel_size_m"]), + maximum=int(self.profile["occupancy"]["maximum_cells_per_snapshot"]), + ) + center = np.median(points_map, axis=0).astype(np.float64, copy=False) + self._classify(track, session_seconds, cells, center) + track.last_observed_seconds = session_seconds + track.support_observations += 1 + track.current_cells = cells + self.support_observations += 1 + self._prune(session_seconds) + self.peak_tracks = max(self.peak_tracks, len(self.tracks)) + self.published_states[track.motion_state] += 1 + return self._evidence(track, session_seconds, current=True) + + def hold(self, track_id: int, session_seconds: float) -> dict[str, Any] | None: + track = self.tracks.get(track_id) + if track is None: + return None + age = session_seconds - track.last_observed_seconds + if age > float(self.profile["occupancy"]["maximum_support_hold_seconds"]): + return None + self.held_states += 1 + self.published_states[track.motion_state] += 1 + return self._evidence(track, session_seconds, current=False) + + def snapshot(self) -> dict[str, Any]: + active = Counter(track.motion_state for track in self.tracks.values()) + return { + "active_tracks": len(self.tracks), + "peak_tracks": self.peak_tracks, + "created_tracks": self.created_tracks, + "support_observations": self.support_observations, + "held_states": self.held_states, + "active_motion_states": dict(active), + "published_motion_states": dict(self.published_states), + } + + def _classify( + self, + track: _OccupancyTrack, + now: float, + cells: frozenset[Cell], + center: np.ndarray, + ) -> None: + config = self.profile["occupancy"] + minimum_age = _group_value( + config["reference_minimum_age_seconds"], + track.group, + ) + maximum_age = float(config["reference_maximum_age_seconds"]) + reference: set[Cell] = set() + for snapshot in track.snapshots: + age = now - snapshot.session_seconds + if minimum_age <= age <= maximum_age: + reference.update(snapshot.cells) + overlap = ( + None + if not reference + else _cell_overlap( + cells, + reference, + radius=int(config["neighbor_radius_cells"]), + ) + ) + track.overlap_fraction = overlap + track.snapshots.append( + _SupportSnapshot( + session_seconds=now, + cells=cells, + center_map=center.copy(), + ) + ) + history_seconds = float(config["history_seconds"]) + while track.snapshots and now - track.snapshots[0].session_seconds > max( + maximum_age, + history_seconds, + ): + track.snapshots.popleft() + maximum_snapshots = int(config["maximum_snapshots_per_track"]) + while len(track.snapshots) > maximum_snapshots: + track.snapshots.popleft() + history: deque[tuple[float, np.ndarray]] = deque( + ( + snapshot.session_seconds, + snapshot.center_map, + ) + for snapshot in track.snapshots + if now - snapshot.session_seconds <= history_seconds + ) + speed, direction = _robust_motion(history) + track.speed_mps = speed + track.direction_consistency = direction + track.displacement_m = ( + None + if len(history) < 2 + else float(np.linalg.norm(history[-1][1][:2] - history[0][1][:2])) + ) + track.velocity_map_mps = _median_velocity(history) + if overlap is None or speed is None or track.displacement_m is None: + track.motion_state = "unknown" + track.confidence = 0.0 + return + dynamic = ( + overlap <= float(config["dynamic_overlap_enter"]) + and speed >= _group_value(config["dynamic_minimum_speed_mps"], track.group) + and track.displacement_m + >= _group_value(config["dynamic_minimum_displacement_m"], track.group) + and direction >= float(config["minimum_direction_consistency"]) + ) + static = overlap >= float(config["static_overlap_enter"]) + track.dynamic_evidence = track.dynamic_evidence + 1 if dynamic else 0 + track.static_evidence = track.static_evidence + 1 if static else 0 + raw_contradiction = not dynamic and not static + track.contradictions = track.contradictions + 1 if raw_contradiction else 0 + dynamic_frames = int(_group_value(config["dynamic_confirmation_frames"], track.group)) + 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 overlap >= float(config["dynamic_overlap_exit"]) + ) or (track.motion_state == "static" and overlap < float(config["static_overlap_exit"])): + track.motion_state = "unknown" + if track.contradictions >= int(config["contradiction_frames"]): + track.motion_state = "unknown" + if track.motion_state == "dynamic": + overlap_confidence = max( + 0.0, + 1.0 - overlap / max(float(config["dynamic_overlap_exit"]), 1e-6), + ) + track.confidence = min( + 1.0, + 0.5 * overlap_confidence + 0.5 * direction, + ) + elif track.motion_state == "static": + track.confidence = min( + 1.0, + overlap / max(float(config["static_overlap_enter"]), 1e-6), + ) + else: + track.confidence = 0.0 + + def _evidence( + self, + track: _OccupancyTrack, + now: float, + *, + current: bool, + ) -> dict[str, Any]: + voxel_size = float(self.profile["occupancy"]["voxel_size_m"]) + footprint = _footprint(track.current_cells, voxel_size) + velocity = ( + None + if track.motion_state == "unknown" or track.velocity_map_mps is None + else track.velocity_map_mps.tolist() + ) + if track.motion_state == "static": + velocity = [0.0, 0.0, 0.0] + return { + "motion_state": track.motion_state, + "motion_confidence": track.confidence, + "motion_status": ("e25-current-map-support" if current else "e25-held-map-support"), + "speed_mps": track.speed_mps, + "velocity_map_mps": velocity, + "occupancy_overlap_fraction": track.overlap_fraction, + "occupancy_displacement_m": track.displacement_m, + "occupancy_direction_consistency": track.direction_consistency, + "occupancy_support_observations": track.support_observations, + "occupancy_cell_count": len(track.current_cells), + "occupancy_resolution_m": voxel_size, + "occupancy_footprint_map_xy": footprint, + "occupancy_evidence_current": current, + "occupancy_observation_age_ms": max( + 0.0, + (now - track.last_observed_seconds) * 1000.0, + ), + "source_track_aliases": sorted(track.aliases), + } + + 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_tracks = int(self.profile["bounds"]["maximum_tracks"]) + if len(self.tracks) <= maximum_tracks: + return + ordered = sorted( + self.tracks.values(), + key=lambda track: track.last_observed_seconds, + ) + for track in ordered[: len(self.tracks) - maximum_tracks]: + self.tracks.pop(track.track_id, None) + + +class RecordedSupportExtractor: + """Rebuild sparse per-object support from immutable recorded inputs.""" + + def __init__( + self, + *, + source: IntegratedPerceptionResult, + lidar_pack: Path, + association: dict[str, Any], + profile: dict[str, Any], + ) -> None: + self.source = source + self.association = association + self.profile = profile + semantic_rows = _read_jsonl(source.semantic_path) + with np.load(source.arrays_path, allow_pickle=False) as source_arrays: + semantic_indices = source_arrays["semantic_frame_indices"] + self.semantic_masks = source_arrays["semantic_masks"] + if not np.array_equal( + semantic_indices, + np.asarray([row["frame_index"] for row in semantic_rows], dtype=np.int64), + ): + raise SessionIntegrityError("LAB E25 semantic frame binding changed") + self.semantic_by_source = { + int(row["source_frame_index"]): index for index, row in enumerate(semantic_rows) + } + required = { + "frame_indices", + "source_frame_indices", + "session_seconds", + "sample_available", + "cloud_offsets", + "cloud_points_map", + "pose_positions_map", + "pose_quaternions_map_from_lidar", + "lidar_camera_delta_ms", + "pose_point_delta_ms", + "intrinsic_fx_fy_cx_cy", + "distortion_kb4", + "t_camera_from_lidar", + } + with np.load(lidar_pack / "lidar-pack.npz", allow_pickle=False) as lidar_arrays: + if set(lidar_arrays.files) != required: + raise SessionIntegrityError("LAB E25 LiDAR replay array set changed") + if lidar_arrays["frame_indices"].shape != (source.frame_count,): + raise SessionIntegrityError("LAB E25 LiDAR replay frame count changed") + self.sample_available = lidar_arrays["sample_available"] + self.cloud_offsets = lidar_arrays["cloud_offsets"] + self.cloud_points_map = lidar_arrays["cloud_points_map"] + self.pose_positions_map = lidar_arrays["pose_positions_map"] + self.pose_quaternions_map_from_lidar = lidar_arrays["pose_quaternions_map_from_lidar"] + intrinsic_values = np.asarray( + lidar_arrays["intrinsic_fx_fy_cx_cy"], + dtype=np.float64, + ) + distortion_values = np.asarray( + lidar_arrays["distortion_kb4"], + dtype=np.float64, + ) + t_camera_from_lidar = np.asarray( + lidar_arrays["t_camera_from_lidar"], + dtype=np.float64, + ) + configuration = _read_object(source.result_root / "result.json")["identity"][ + "configuration" + ] + source_profile = configuration["profile"]["source"] + if intrinsic_values.shape != (4,) or distortion_values.shape != (4,): + raise SessionIntegrityError("LAB E25 calibration vector shape changed") + intrinsic = ( + float(intrinsic_values[0]), + float(intrinsic_values[1]), + float(intrinsic_values[2]), + float(intrinsic_values[3]), + ) + distortion = ( + float(distortion_values[0]), + float(distortion_values[1]), + float(distortion_values[2]), + float(distortion_values[3]), + ) + self.projection = Kb4ProjectionProfile( + source_id=str(source_profile["source_id"]), + calibration_slot=str(source_profile["calibration_slot"]), + width=int(source_profile["resolution"][0]), + height=int(source_profile["resolution"][1]), + intrinsic_fx_fy_cx_cy=intrinsic, + distortion_kb4=distortion, + t_camera_from_lidar=t_camera_from_lidar, + ) + self.frames_with_support = 0 + self.measurements = 0 + self.points = 0 + + def close(self) -> None: + return None + + def frame( + self, + frame_index: int, + fusion: dict[str, Any], + ) -> dict[int, SupportMeasurement]: + if not bool(self.sample_available[frame_index]): + return {} + semantic_source = fusion.get("semantic_source_frame_index") + if not isinstance(semantic_source, int): + return {} + semantic_index = self.semantic_by_source.get(semantic_source) + if semantic_index is None: + return {} + start = int(self.cloud_offsets[frame_index]) + end = int(self.cloud_offsets[frame_index + 1]) + points_map = np.asarray( + self.cloud_points_map[start:end], + dtype=np.float64, + ) + position_values = np.asarray( + self.pose_positions_map[frame_index], + dtype=np.float64, + ) + quaternion_values = np.asarray( + self.pose_quaternions_map_from_lidar[frame_index], + dtype=np.float64, + ) + if position_values.shape != (3,) or quaternion_values.shape != (4,): + raise SessionIntegrityError("LAB E25 pose vector shape changed") + position = ( + float(position_values[0]), + float(position_values[1]), + float(position_values[2]), + ) + quaternion = ( + float(quaternion_values[0]), + float(quaternion_values[1]), + float(quaternion_values[2]), + float(quaternion_values[3]), + ) + projected = project_map_points_kb4( + points_map, + position_map_xyz=position, + orientation_map_from_lidar_xyzw=quaternion, + profile=self.projection, + ) + visible = _visible_projection_indices( + projected.pixels_xy, + projected.depths_m, + self.projection.width, + ) + pixels = projected.pixels_xy[visible] + depths = projected.depths_m[visible] + source_indices = projected.source_indices[visible] + points_lidar = map_points_to_lidar( + points_map, + position_map_xyz=position, + orientation_map_from_lidar_xyzw=quaternion, + ) + semantic = self.semantic_masks[semantic_index] + pixel_x = np.clip( + np.rint(pixels[:, 0]).astype(np.int64), + 0, + self.projection.width - 1, + ) + pixel_y = np.clip( + np.rint(pixels[:, 1]).astype(np.int64), + 0, + self.projection.height - 1, + ) + sampled_semantic = semantic[pixel_y, pixel_x] + candidates: list[SupportMeasurement] = [] + for source_object in fusion["objects"]: + measurement = self._object_support( + source_object, + pixels=pixels, + depths=depths, + source_indices=source_indices, + sampled_semantic=sampled_semantic, + points_map=points_map, + points_lidar=points_lidar, + ) + if measurement is not None: + candidates.append(measurement) + selected = _suppress_duplicate_measurements( + candidates, + overlap_threshold=float(self.association["support_duplicate_overlap_threshold"]), + ) + if selected: + self.frames_with_support += 1 + self.measurements += len(selected) + self.points += sum(item.points_map.shape[0] for item in selected) + return {item.source_track_id: item for item in selected} + + def snapshot(self) -> dict[str, int]: + return { + "frames_with_support": self.frames_with_support, + "support_measurements": self.measurements, + "support_points": self.points, + } + + def _object_support( + self, + source: dict[str, Any], + *, + pixels: FloatArray, + depths: FloatArray, + source_indices: IntArray, + sampled_semantic: npt.NDArray[np.uint8], + points_map: FloatArray, + points_lidar: FloatArray, + ) -> SupportMeasurement | None: + group = str(source.get("association_group", source.get("label", "object"))) + if group not in self.association["semantic_ids"]: + return None + x1, y1, x2, y2 = (float(value) for value in source["bbox_xyxy"]) + width, height = x2 - x1, y2 - y1 + inset = self.association["box_inset"] + inside = ( + (pixels[:, 0] >= x1 + width * float(inset["horizontal_fraction"])) + & (pixels[:, 0] < x2 - width * float(inset["horizontal_fraction"])) + & (pixels[:, 1] >= y1 + height * float(inset["top_fraction"])) + & (pixels[:, 1] < y2 - height * float(inset["bottom_fraction"])) + ) + candidates = np.flatnonzero(inside).astype(np.int64) + allowed = np.asarray( + self.association["semantic_ids"][group], + dtype=np.uint8, + ) + compatible = candidates[np.isin(sampled_semantic[candidates], allowed)] + clustered = _depth_cluster(compatible, depths, self.association) + selected = _spatial_cluster( + source_indices[clustered], + points_lidar, + float(self.association["spatial_cluster_radius_m"][group]), + ) + selected = _filter_support_by_ground( + selected, + points_map, + group=group, + profile=self.association["object_support_ground_filter"], + ) + minimum = int( + _group_value( + self.profile["support"]["minimum_evidence_points"], + group, + ) + ) + if selected.size < minimum: + return None + maximum_points = int(self.profile["support"]["maximum_points_per_object"]) + if selected.size > maximum_points: + step = selected.size / maximum_points + selected = selected[np.floor(np.arange(maximum_points) * step).astype(np.int64)] + return SupportMeasurement( + source_track_id=int(source.get("source_track_id", source["track_id"])), + label=str(source.get("label", "object")), + group=group, + score=float(source.get("score", 0.0)), + points_map=points_map[selected], + source_indices=selected, + ) + + +def read_persistent_support_profile(path: Path) -> tuple[dict[str, Any], str]: + resolved = path.expanduser().resolve(strict=True) + profile = _read_object(resolved) + occupancy = profile.get("occupancy") + if ( + profile.get("schema_version") != PROFILE_SCHEMA + or profile.get("mode") != "recorded-streaming-qualification" + or profile.get("authority") + != {"commands_enabled": False, "navigation_or_safety_accepted": False} + or not isinstance(occupancy, dict) + or not 0.05 <= float(occupancy.get("voxel_size_m", 0.0)) <= 1.0 + or not 1 <= int(profile.get("bounds", {}).get("maximum_tracks", 0)) <= 512 + or float(occupancy.get("dynamic_overlap_enter", 1.0)) + >= float(occupancy.get("static_overlap_enter", 0.0)) + ): + raise SessionIntegrityError("LAB E25 persistent-support profile is invalid") + return profile, _sha256(resolved) + + +def read_persistent_support_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 benchmark.get("source_session_id") != "20260720T065719Z_viewer_live" + or not isinstance(events, list) + or not events + ): + raise SessionIntegrityError("LAB E25 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("kind") not in {"target-motion", "static-control"} + 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 E25 benchmark event is invalid") + if event["kind"] == "target-motion": + targets = event.get("target_source_track_ids") + if ( + not isinstance(targets, list) + or not targets + or not all(isinstance(value, int) and value > 0 for value in targets) + ): + raise SessionIntegrityError("LAB E25 target binding is invalid") + identifiers.add(event["id"]) + return benchmark, _sha256(resolved) + + +def evaluate_persistent_support_benchmark( + source_rows: list[dict[str, Any]], + fusion_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"]) + targets = set(int(value) for value in event.get("target_source_track_ids", [])) + source_keys: set[tuple[int, int]] = set() + for row in source_rows: + timestamp = float(row["session_seconds"]) + if not start <= timestamp <= end: + continue + for item in row["objects"]: + source_id = int(item.get("source_track_id", item["track_id"])) + if _event_class_matches(item, event) and ( + event["kind"] == "static-control" or source_id in targets + ): + source_keys.add((_benchmark_frame_key(row, timestamp), source_id)) + source_observations = len(source_keys) + evidence_by_key: dict[ + tuple[int, int], + tuple[float, dict[str, Any]], + ] = {} + duplicate_evidence_observations = 0 + for row in fusion_rows: + timestamp = float(row["session_seconds"]) + if not start <= timestamp <= end: + continue + for item in row["objects"]: + source_id = item.get("source_track_id") + if ( + isinstance(source_id, int) + and _event_class_matches(item, event) + and (event["kind"] == "static-control" or source_id in targets) + and item.get("occupancy_evidence_current") is True + ): + key = (_benchmark_frame_key(row, timestamp), source_id) + previous = evidence_by_key.get(key) + if previous is not None: + duplicate_evidence_observations += 1 + if previous is None or _benchmark_evidence_rank( + item + ) > _benchmark_evidence_rank(previous[1]): + evidence_by_key[key] = (timestamp, item) + matched = sorted(evidence_by_key.values(), key=lambda value: value[0]) + coverage = len(matched) / max(1, source_observations) + if event["kind"] == "static-control": + state_counts = Counter(str(item["motion_state"]) for _, item in matched) + classified = state_counts["static"] + state_counts["dynamic"] + classified_fraction = classified / max(1, len(matched)) + false_dynamic_fraction = state_counts["dynamic"] / max(1, len(matched)) + passed = ( + source_observations >= int(event["minimum_source_observations"]) + and coverage >= float(event["minimum_coverage_fraction"]) + and classified_fraction >= float(event["minimum_classified_fraction"]) + and false_dynamic_fraction <= float(event["maximum_false_dynamic_fraction"]) + ) + outcomes.append( + { + "id": event["id"], + "kind": event["kind"], + "expected_motion": event["expected_motion"], + "class_group": event["class_group"], + "window_seconds": [start, end], + "passed": passed, + "source_observations": source_observations, + "evidence_observations": len(matched), + "duplicate_evidence_observations": (duplicate_evidence_observations), + "coverage_fraction": coverage, + "classified_fraction": classified_fraction, + "false_dynamic_fraction": false_dynamic_fraction, + "state_counts": dict(state_counts), + } + ) + continue + by_track: dict[int, list[tuple[float, dict[str, Any]]]] = defaultdict(list) + for timestamp, item in matched: + if item.get("motion_state") == event["expected_motion"]: + by_track[int(item["track_id"])].append((timestamp, item)) + best_track: int | None = None + best_hits = 0 + best_span = 0.0 + for track_id, observations in by_track.items(): + timestamps = [value[0] for value in observations] + span = max(timestamps) - min(timestamps) + if (len(observations), span) > (best_hits, best_span): + best_track = track_id + best_hits = len(observations) + best_span = span + passed = ( + coverage >= float(event["minimum_coverage_fraction"]) + and best_hits >= int(event["minimum_hits"]) + and best_span >= float(event["minimum_span_seconds"]) + ) + outcomes.append( + { + "id": event["id"], + "kind": event["kind"], + "expected_motion": event["expected_motion"], + "class_group": event["class_group"], + "target_source_track_ids": sorted(targets), + "window_seconds": [start, end], + "passed": passed, + "source_observations": source_observations, + "evidence_observations": len(matched), + "duplicate_evidence_observations": duplicate_evidence_observations, + "coverage_fraction": coverage, + "best_track_id": best_track, + "best_hits": best_hits, + "best_span_seconds": best_span, + } + ) + return { + "schema_version": "missioncore.e25-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_persistent_support_result( + *, + source: IntegratedPerceptionResult, + lab_job: CameraComputeJob, + lab_pack: Path, + results_root: Path, + world_motion_profile_path: Path, + profile_path: Path, + benchmark_path: Path, +) -> PersistentSupportBuild: + """Build one immutable E25 result from the accepted E19 source.""" + + if not source.accepted: + raise SessionIntegrityError("LAB E25 source result is not accepted") + world_profile, world_profile_sha256 = read_world_motion_profile(world_motion_profile_path) + profile, profile_sha256 = read_persistent_support_profile(profile_path) + benchmark, benchmark_sha256 = read_persistent_support_benchmark(benchmark_path) + if source.job.session_id != benchmark["source_session_id"]: + raise SessionIntegrityError("LAB E25 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 E25 source timelines are incomplete") + configuration = source_document["identity"].get("configuration") + association = ( + configuration.get("profile", {}).get("association") + if isinstance(configuration, dict) + else None + ) + if not isinstance(association, dict): + raise SessionIntegrityError("LAB E25 source association profile is unavailable") + + world_tracker = WorldMotionTracker(world_profile) + occupancy_tracker = PersistentSupportTracker(profile) + extractor = RecordedSupportExtractor( + source=source, + lidar_pack=lab_pack, + association=association, + profile=profile, + ) + fusion_rows: list[dict[str, Any]] = [] + world_rows: list[dict[str, Any]] = [] + frame_ms: list[float] = [] + support_offsets = [0] + support_points: list[np.ndarray] = [] + support_colors: list[np.ndarray] = [] + try: + for index, (source_fusion_row, source_world_row) in enumerate( + zip(source_fusion, source_world, strict=True) + ): + started = time.perf_counter() + measurements = extractor.frame(index, source_fusion_row) + augmented = [ + _augment_source_object(item, measurements) for item in source_fusion_row["objects"] + ] + session_seconds = float(source_fusion_row["session_seconds"]) + fusion_objects, world_objects = world_tracker.update( + frame_index=int(source_fusion_row["frame_index"]), + session_seconds=session_seconds, + objects=augmented, + ) + normalized_objects = _apply_occupancy_evidence( + fusion_objects, + measurements, + occupancy_tracker, + session_seconds, + ) + normalized_fusion = json.loads(json.dumps(source_fusion_row)) + normalized_fusion["objects"] = normalized_objects + normalized_fusion["world_motion"] = { + "schema_version": "missioncore.e25-persistent-support-frame/v1", + "coordinate_frame": "k1-map", + "lookahead_frames": 0, + "measurement": "persistent-object-support-occupancy", + } + normalized_world = json.loads(json.dumps(source_world_row)) + normalized_world["objects"] = _update_world_objects( + world_objects, + normalized_objects, + ) + normalized_world["object_count"] = len(normalized_world["objects"]) + delivery = normalized_world.setdefault("delivery", {}) + delivery["world_motion"] = "e25-persistent-support-dual-hypothesis" + 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) + frame_points, frame_colors = _support_arrays(measurements) + if frame_points.size: + support_points.append(frame_points) + support_colors.append(frame_colors) + support_offsets.append(support_offsets[-1] + frame_points.shape[0]) + finally: + extractor.close() + + benchmark_result = evaluate_persistent_support_benchmark( + source_fusion, + fusion_rows, + benchmark, + ) + runtime = _percentiles(frame_ms) + tracker_metrics = occupancy_tracker.snapshot() + checks = { + "source_payloads_immutable": True, + "diagnostic_authority_only": True, + "single_pass_no_lookahead": True, + "track_state_bound": tracker_metrics["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"] + result_configuration = { + "pipeline": PIPELINE_ID, + "world_motion_profile_sha256": world_profile_sha256, + "world_motion_profile": world_profile, + "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": result_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": { + "persistent_support_frame_processing_ms": runtime, + **tracker_metrics, + **extractor.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.", + ( + "Operator timestamps and detector-track bindings are reviewed annotations, " + "not ground truth." + ), + ( + "Unknown remains an occupied obstacle and is intentional when motion " + "evidence is insufficient." + ), + ( + "Motion uses persistent map-frame support overlap; the completed cuboid " + "center is not the sole measurement." + ), + "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 E25 result id collides") + return PersistentSupportBuild( + 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, box_colors = _box_arrays(fusion_rows) + all_support = ( + np.concatenate(support_points).astype(np.float32, copy=False) + if support_points + else np.empty((0, 3), dtype=np.float32) + ) + all_support_colors = ( + np.concatenate(support_colors).astype(np.uint8, copy=False) + if support_colors + else np.empty((0, 3), dtype=np.uint8) + ) + _rewrite_npz_boxes( + source.arrays_path, + arrays_path, + { + "support_offsets": np.asarray(support_offsets, dtype=np.int64), + "support_points": all_support, + "support_colors": all_support_colors, + "box_offsets": box_offsets, + "box_centers": centers, + "box_half_sizes": half_sizes, + "box_quaternions": quaternions, + "box_colors": box_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 PersistentSupportBuild( + destination, + profile_sha256, + benchmark_sha256, + report, + ) + + +def _augment_source_object( + source: dict[str, Any], + measurements: dict[int, SupportMeasurement], +) -> dict[str, Any]: + normalized: dict[str, Any] = json.loads(json.dumps(source)) + source_track_id = int(source.get("source_track_id", source["track_id"])) + measurement = measurements.get(source_track_id) + if measurement is None: + return normalized + normalized["e25_source_clustered_points"] = normalized.get("clustered_points", 0) + normalized["clustered_points"] = int(measurement.points_map.shape[0]) + if not _valid_vector(normalized.get("observed_cuboid_center_map"), 3): + normalized["observed_cuboid_center_map"] = np.median( + measurement.points_map, + axis=0, + ).tolist() + normalized["observed_cuboid_quaternion_xyzw"] = [0.0, 0.0, 0.0, 1.0] + normalized["e25_support_recovered"] = True + return normalized + + +def _apply_occupancy_evidence( + objects: list[dict[str, Any]], + measurements: dict[int, SupportMeasurement], + tracker: PersistentSupportTracker, + session_seconds: float, +) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for source in objects: + item = json.loads(json.dumps(source)) + source_track_id = item.get("source_track_id") + track_id = item.get("track_id") + evidence: dict[str, Any] | None = None + if isinstance(source_track_id, int) and isinstance(track_id, int): + measurement = measurements.get(source_track_id) + if measurement is not None and item.get("temporal_status") == "e24-observed": + evidence = tracker.observe( + track_id=track_id, + source_track_id=source_track_id, + group=measurement.group, + session_seconds=session_seconds, + points_map=measurement.points_map, + ) + else: + evidence = tracker.hold(track_id, session_seconds) + if evidence is None: + item["motion_state"] = "unknown" + item["motion_confidence"] = 0.0 + item["motion_status"] = "e25-unavailable-no-persistent-support" + item["occupancy_evidence_current"] = False + else: + item.update(evidence) + item["temporal_status"] = ( + "e25-support-observed" + if evidence["occupancy_evidence_current"] + else "e25-support-held" + ) + result.append(item) + return result + + +def _update_world_objects( + world_objects: list[dict[str, Any]], + fusion_objects: list[dict[str, Any]], +) -> list[dict[str, Any]]: + by_track = { + int(item["track_id"]): item + for item in fusion_objects + if isinstance(item.get("track_id"), int) and isinstance(item.get("source_track_id"), int) + } + result: list[dict[str, Any]] = [] + for source in world_objects: + item = json.loads(json.dumps(source)) + fusion = by_track.get(int(item["track_id"])) + if fusion is None: + item["motion_state"] = "unknown" + item["motion_confidence"] = 0.0 + item["velocity_status"] = "e25-unavailable-no-persistent-support" + result.append(item) + continue + for key in ( + "motion_state", + "motion_confidence", + "speed_mps", + "velocity_map_mps", + "occupancy_overlap_fraction", + "occupancy_displacement_m", + "occupancy_direction_consistency", + "occupancy_support_observations", + "occupancy_cell_count", + "occupancy_resolution_m", + "occupancy_footprint_map_xy", + "occupancy_evidence_current", + "occupancy_observation_age_ms", + "source_track_aliases", + ): + item[key] = fusion.get(key) + item["velocity_status"] = fusion["motion_status"] + item["temporal_status"] = fusion["temporal_status"] + result.append(item) + return result + + +def _support_arrays( + measurements: dict[int, SupportMeasurement], +) -> tuple[np.ndarray, np.ndarray]: + points: list[np.ndarray] = [] + colors: list[np.ndarray] = [] + for source_track_id, measurement in sorted(measurements.items()): + values = measurement.points_map.astype(np.float32, copy=False) + seed = hashlib.sha256(f"e25:{measurement.group}:{source_track_id}".encode()).digest() + color = np.asarray( + [[64 + value % 176 for value in seed[:3]]], + dtype=np.uint8, + ) + points.append(values) + colors.append(np.tile(color, (values.shape[0], 1))) + if not points: + return ( + np.empty((0, 3), dtype=np.float32), + np.empty((0, 3), dtype=np.uint8), + ) + return np.concatenate(points), np.concatenate(colors) + + +def _event_class_matches( + item: dict[str, Any], + event: dict[str, Any], +) -> bool: + group = str( + item.get( + "association_group", + item.get("class", item.get("label", "object")), + ) + ) + if event["class_group"] == "vulnerable_road_user": + return group in {"person", "bicycle", "motorcycle"} + return group == str(event["class_group"]) + + +def _benchmark_frame_key(row: dict[str, Any], timestamp: float) -> int: + frame_index = row.get("frame_index") + if isinstance(frame_index, int): + return frame_index + return round(timestamp * 1_000_000) + + +def _benchmark_evidence_rank(item: dict[str, Any]) -> tuple[int, int, float, int]: + return ( + int(item.get("occupancy_cell_count", 0)), + int(item.get("occupancy_support_observations", 0)), + float(item.get("motion_confidence", 0.0)), + -int(item.get("track_id", 0)), + ) + + +def _visible_projection_indices( + pixels: FloatArray, + depths: FloatArray, + width: int, +) -> IntArray: + if depths.size == 0: + return np.empty((0,), dtype=np.int64) + px = np.rint(pixels[:, 0]).astype(np.int64) + py = np.rint(pixels[:, 1]).astype(np.int64) + keys = py * width + px + order = np.lexsort((depths, keys)) + ordered_keys = keys[order] + first = np.concatenate(([True], ordered_keys[1:] != ordered_keys[:-1])) + return np.sort(order[first]).astype(np.int64, copy=False) + + +def _depth_cluster( + indices: IntArray, + depths: FloatArray, + profile: Mapping[str, Any], +) -> IntArray: + if indices.size < 2: + return indices + ordered = indices[np.argsort(depths[indices])] + groups: list[IntArray] = [] + start = 0 + for offset, gap in enumerate(np.diff(depths[ordered]), start=1): + threshold = max( + float(profile["depth_cluster_minimum_gap_m"]), + float(profile["depth_cluster_gap_fraction"]) * float(depths[ordered[offset - 1]]), + ) + if float(gap) > threshold: + groups.append(ordered[start:offset]) + start = offset + groups.append(ordered[start:]) + return min( + groups, + key=lambda group: (-int(group.size), float(np.median(depths[group]))), + ) + + +def _spatial_cluster( + indices: IntArray, + points_lidar: FloatArray, + radius: float, +) -> IntArray: + if indices.size < 2: + return indices + points = points_lidar[indices] + adjacent = np.sum((points[:, None, :] - points[None, :, :]) ** 2, axis=2) <= radius * radius + unseen = set(range(indices.size)) + groups: list[list[int]] = [] + while unseen: + seed = unseen.pop() + group = [seed] + pending = [seed] + while pending: + current = pending.pop() + for neighbor in [value for value in tuple(unseen) if adjacent[current, value]]: + unseen.remove(neighbor) + pending.append(neighbor) + group.append(neighbor) + groups.append(group) + selected = min( + groups, + key=lambda group: ( + -len(group), + float(np.median(np.linalg.norm(points[group], axis=1))), + ), + ) + return indices[np.asarray(selected, dtype=np.int64)] + + +def _filter_support_by_ground( + indices: IntArray, + points_map: FloatArray, + *, + group: str, + profile: Mapping[str, Any], +) -> IntArray: + if indices.size == 0: + return indices + support = points_map[indices] + support_lower = float(np.percentile(support[:, 2], 5.0)) + center = np.median(support[:, :2], axis=0) + distances = np.linalg.norm(points_map[:, :2] - center, axis=1) + candidates = points_map[distances <= float(profile["local_radius_m"]), 2] + ground = ( + float(np.percentile(candidates, float(profile["lower_percentile"]))) + if candidates.size + else math.nan + ) + if ( + not math.isfinite(ground) + or ground < support_lower - float(profile["maximum_below_support_m"]) + or ground > support_lower + float(profile["maximum_above_support_m"]) + ): + ground = support_lower - float(profile["fallback_below_support_m"]) + minimum = ground + float(profile["minimum_height_above_ground_m"][group]) + maximum_height = ground + float(profile["maximum_height_above_ground_m"][group]) + keep = (support[:, 2] >= minimum) & (support[:, 2] <= maximum_height) + return indices[keep] + + +def _suppress_duplicate_measurements( + measurements: Sequence[SupportMeasurement], + *, + overlap_threshold: float, +) -> list[SupportMeasurement]: + accepted: list[SupportMeasurement] = [] + for item in sorted( + measurements, + key=lambda value: (-value.score, value.source_track_id), + ): + if any( + other.group == item.group + and _index_overlap(other.source_indices, item.source_indices) >= overlap_threshold + for other in accepted + ): + continue + accepted.append(item) + return accepted + + +def _index_overlap(one: IntArray, two: IntArray) -> float: + if one.size == 0 or two.size == 0: + return 0.0 + shared = np.intersect1d(one, two, assume_unique=False).size + return float(shared / min(one.size, two.size)) + + +def _voxel_cells( + points: FloatArray, + *, + voxel_size: float, + maximum: int, +) -> frozenset[Cell]: + values = sorted( + { + (int(value[0]), int(value[1])) + for value in np.floor(points[:, :2] / voxel_size).astype(np.int64) + } + ) + if len(values) > maximum: + step = len(values) / maximum + values = [values[math.floor(index * step)] for index in range(maximum)] + return frozenset(values) + + +def _cell_overlap( + current: frozenset[Cell], + reference: set[Cell], + *, + radius: int, +) -> float: + if not current or not reference: + return 0.0 + expanded = { + (x + delta_x, y + delta_y) + for x, y in reference + for delta_x in range(-radius, radius + 1) + for delta_y in range(-radius, radius + 1) + } + return sum(cell in expanded for cell in current) / len(current) + + +def _footprint( + cells: frozenset[Cell], + voxel_size: float, +) -> list[list[float]] | None: + if not cells: + return None + x_values = [value[0] for value in cells] + y_values = [value[1] for value in cells] + minimum_x = min(x_values) * voxel_size + minimum_y = min(y_values) * voxel_size + maximum_x = (max(x_values) + 1) * voxel_size + maximum_y = (max(y_values) + 1) * voxel_size + return [ + [minimum_x, minimum_y], + [maximum_x, minimum_y], + [maximum_x, maximum_y], + [minimum_x, maximum_y], + ] + + +def _median_velocity( + history: deque[tuple[float, np.ndarray]], +) -> np.ndarray | None: + if len(history) < 3: + return None + values = list(history) + velocities: list[np.ndarray] = [] + for start in range(len(values) - 1): + for end in range(start + 1, len(values)): + delta = values[end][0] - values[start][0] + if delta < 0.35: + continue + velocities.append((values[end][1] - values[start][1]) / delta) + if not velocities: + return None + return np.asarray( + np.median(np.stack(velocities), axis=0), + dtype=np.float64, + ) + + +def _group_value(values: Mapping[str, Any], group: str) -> float: + return float(values.get(group, values["default"])) + + +def _valid_vector(value: Any, length: int) -> bool: + return ( + isinstance(value, list) + and len(value) == length + and all( + isinstance(item, int | float) + and not isinstance(item, bool) + and math.isfinite(float(item)) + for item in value + ) + ) diff --git a/src/k1link/device_plugins/xgrids_k1/cli.py b/src/k1link/device_plugins/xgrids_k1/cli.py index dbcc591..3bea462 100644 --- a/src/k1link/device_plugins/xgrids_k1/cli.py +++ b/src/k1link/device_plugins/xgrids_k1/cli.py @@ -23,6 +23,7 @@ from k1link.compute import ( publish_e22_lab_instance, publish_e23_lab_instance, publish_e24_lab_instance, + publish_e25_lab_instance, publish_integrated_lab_instance, ) from k1link.device_plugins.xgrids_k1.analyze import ( @@ -656,6 +657,92 @@ def publish_e24_lab( ) +@lab_app.command("publish-e25") +def publish_e25_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 E25 source.", + ), + ], + world_profile: Annotated[ + Path, + typer.Option( + "--world-profile", + exists=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Bounded E24 world-association profile used before E25 evidence.", + ), + ], + profile: Annotated[ + Path, + typer.Option( + exists=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Bounded E25 persistent-support profile.", + ), + ], + benchmark: Annotated[ + Path, + typer.Option( + exists=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Spatially bound E25 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 E25.1'."), + ], + display_name: Annotated[ + str, + typer.Option("--display-name", help="Operator-facing saved-session title."), + ], +) -> None: + """Build and publish map-frame persistent-support motion evidence.""" + + repository_root = Path(__file__).resolve().parents[4] + try: + published = publish_e25_lab_instance( + repository_root=repository_root, + source_result_root=result, + world_motion_profile_path=world_profile, + 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]E25 LAB publication failed:[/red] {exc}") + raise typer.Exit(code=2) from exc + metrics = published.build.report["metrics"] + console.print( + "[green]E25 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']['persistent_support_frame_processing_ms']['p95']:.3f}ms; " + "source_payloads_mutated=false" + ) + + @app.command("serve") def serve_console( port: Annotated[ diff --git a/tests/test_e10_integrated_perception.py b/tests/test_e10_integrated_perception.py index 9204563..07420a6 100644 --- a/tests/test_e10_integrated_perception.py +++ b/tests/test_e10_integrated_perception.py @@ -267,6 +267,30 @@ def test_recorded_cuboid_presentation_holds_one_failed_association_then_expires( assert expired is None +def test_recorded_cuboid_presentation_omits_unavailable_distance() -> None: + state = _CuboidPresentationState(hold_ns=500_000_000) + accepted = { + "association_group": "person", + "clustered_points": 3, + "cuboid_status": "accepted-world-track-provisional-e24-v1", + "distance_smoothed_m": None, + "label": "person", + "track_id": 9, + } + + presented = state.update( + 1_000_000_000, + [accepted], + np.asarray([[1.0, 2.0, 3.0]], dtype=np.float32), + np.asarray([[0.35, 0.35, 0.9]], dtype=np.float32), + np.asarray([[0.0, 0.0, 0.0, 1.0]], dtype=np.float32), + np.asarray([[118, 204, 132, 88]], dtype=np.uint8), + ) + + assert presented is not None + assert presented[4] == ["person #9 person · 3 pts"] + + def test_e13_completes_a_visible_car_face_away_from_the_sensor() -> None: fusion, runner = _worker_modules() profile, _digest = runner.read_profile( diff --git a/tests/test_e25_occupancy_motion.py b/tests/test_e25_occupancy_motion.py new file mode 100644 index 0000000..402ceba --- /dev/null +++ b/tests/test_e25_occupancy_motion.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +from k1link.compute.occupancy_motion import ( + PersistentSupportTracker, + SupportMeasurement, + _apply_occupancy_evidence, + evaluate_persistent_support_benchmark, + read_persistent_support_benchmark, + read_persistent_support_profile, +) + + +def _profile() -> dict[str, object]: + root = Path(__file__).resolve().parents[1] + profile, digest = read_persistent_support_profile( + root / "experiments" / "perception" / "e25_persistent_support_profile.json" + ) + assert len(digest) == 64 + return profile + + +def _support(x: float, y: float = 2.0) -> np.ndarray: + return np.asarray( + [ + [x - 0.2, y - 0.2, 0.4], + [x + 0.2, y - 0.2, 0.5], + [x - 0.2, y + 0.2, 0.6], + [x + 0.2, y + 0.2, 0.7], + ], + dtype=np.float64, + ) + + +def test_e25_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["occupancy"]["maximum_snapshots_per_track"] == 32 + assert profile["authority"] == { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + } + + +def test_e25_static_support_survives_visible_surface_center_jitter() -> None: + tracker = PersistentSupportTracker(_profile()) + evidence = {} + for frame in range(30): + jitter = 0.18 if frame % 2 else -0.18 + evidence = tracker.observe( + track_id=250001, + source_track_id=10, + group="vehicle", + session_seconds=frame * 0.1, + points_map=_support(10.0 + jitter), + ) + + assert evidence["motion_state"] == "static" + assert evidence["occupancy_overlap_fraction"] is not None + assert evidence["occupancy_overlap_fraction"] >= 0.55 + + +def test_e25_coherent_translation_becomes_dynamic() -> None: + tracker = PersistentSupportTracker(_profile()) + evidence = {} + for frame in range(20): + evidence = tracker.observe( + track_id=250002, + source_track_id=20, + group="person", + session_seconds=frame * 0.1, + points_map=_support(4.0 + frame * 0.5), + ) + + assert evidence["motion_state"] == "dynamic" + assert evidence["speed_mps"] is not None + assert evidence["speed_mps"] > 1.0 + assert evidence["occupancy_overlap_fraction"] <= 0.25 + + +def test_e25_does_not_claim_motion_without_older_support() -> None: + tracker = PersistentSupportTracker(_profile()) + + evidence = tracker.observe( + track_id=250003, + source_track_id=30, + group="vehicle", + session_seconds=0.0, + points_map=_support(8.0), + ) + + assert evidence["motion_state"] == "unknown" + assert evidence["motion_confidence"] == 0.0 + + +def test_e25_current_support_is_not_attached_to_an_e24_held_hypothesis() -> None: + tracker = PersistentSupportTracker(_profile()) + measurement = SupportMeasurement( + source_track_id=40, + label="car", + group="vehicle", + score=0.9, + points_map=_support(10.0), + source_indices=np.asarray([1, 2, 3, 4], dtype=np.int64), + ) + _apply_occupancy_evidence( + [ + { + "track_id": 250040, + "source_track_id": 40, + "temporal_status": "e24-observed", + } + ], + {40: measurement}, + tracker, + 0.0, + ) + result = _apply_occupancy_evidence( + [ + { + "track_id": 250041, + "source_track_id": 40, + "temporal_status": "e24-observed", + }, + { + "track_id": 250040, + "source_track_id": 40, + "temporal_status": "e24-held-prediction", + }, + ], + {40: measurement}, + tracker, + 0.1, + ) + + assert result[0]["occupancy_evidence_current"] is True + assert result[1]["occupancy_evidence_current"] is False + assert tracker.snapshot()["support_observations"] == 2 + + +def test_e25_target_benchmark_cannot_be_satisfied_by_another_object() -> None: + event = { + "id": "target-five", + "kind": "target-motion", + "window_seconds": [10.0, 12.0], + "class_group": "vehicle", + "target_source_track_ids": [5], + "expected_motion": "dynamic", + "minimum_hits": 2, + "minimum_span_seconds": 0.1, + "minimum_coverage_fraction": 0.5, + } + source_rows = [ + { + "session_seconds": 10.0 + index * 0.2, + "objects": [ + { + "track_id": 5, + "association_group": "vehicle", + } + ], + } + for index in range(3) + ] + wrong_fusion_rows = [ + { + "session_seconds": 10.0 + index * 0.2, + "objects": [ + { + "track_id": 250099, + "source_track_id": 99, + "association_group": "vehicle", + "motion_state": "dynamic", + "occupancy_evidence_current": True, + } + ], + } + for index in range(3) + ] + + result = evaluate_persistent_support_benchmark( + source_rows, + wrong_fusion_rows, + {"events": [event]}, + ) + + assert result["passed"] is False + assert result["events"][0]["evidence_observations"] == 0 + + +def test_e25_benchmark_deduplicates_world_hypotheses_for_one_source_object() -> None: + event = { + "id": "target-five", + "kind": "target-motion", + "window_seconds": [10.0, 12.0], + "class_group": "vehicle", + "target_source_track_ids": [5], + "expected_motion": "dynamic", + "minimum_hits": 2, + "minimum_span_seconds": 0.1, + "minimum_coverage_fraction": 0.5, + } + source_rows = [ + { + "frame_index": index, + "session_seconds": 10.0 + index * 0.2, + "objects": [{"track_id": 5, "association_group": "vehicle"}], + } + for index in range(3) + ] + fusion_rows = [ + { + "frame_index": index, + "session_seconds": 10.0 + index * 0.2, + "objects": [ + { + "track_id": world_track, + "source_track_id": 5, + "association_group": "vehicle", + "motion_state": "dynamic", + "motion_confidence": 0.8, + "occupancy_cell_count": 4, + "occupancy_support_observations": 6, + "occupancy_evidence_current": True, + } + for world_track in (250005, 250006) + ], + } + for index in range(3) + ] + + result = evaluate_persistent_support_benchmark( + source_rows, + fusion_rows, + {"events": [event]}, + ) + + assert result["events"][0]["coverage_fraction"] == 1.0 + assert result["events"][0]["evidence_observations"] == 3 + assert result["events"][0]["duplicate_evidence_observations"] == 3 + + +def test_e25_static_control_rejects_excess_false_dynamic_evidence() -> None: + event = { + "id": "parked-vehicles", + "kind": "static-control", + "window_seconds": [20.0, 22.0], + "class_group": "vehicle", + "expected_motion": "static", + "minimum_source_observations": 3, + "minimum_coverage_fraction": 0.5, + "minimum_classified_fraction": 0.5, + "maximum_false_dynamic_fraction": 0.1, + } + source_rows = [ + { + "session_seconds": 20.0 + index * 0.2, + "objects": [{"track_id": 7, "association_group": "vehicle"}], + } + for index in range(3) + ] + fusion_rows = [ + { + "session_seconds": 20.0 + index * 0.2, + "objects": [ + { + "track_id": 250007, + "source_track_id": 7, + "association_group": "vehicle", + "motion_state": "dynamic", + "occupancy_evidence_current": True, + } + ], + } + for index in range(3) + ] + + result = evaluate_persistent_support_benchmark( + source_rows, + fusion_rows, + {"events": [event]}, + ) + + assert result["passed"] is False + assert result["events"][0]["false_dynamic_fraction"] == 1.0 + + +def test_e25_benchmark_contract_uses_reviewed_source_track_bindings() -> None: + root = Path(__file__).resolve().parents[1] + benchmark, digest = read_persistent_support_benchmark( + root / "experiments" / "perception" / "e25_motion_benchmark.json" + ) + target_events = [event for event in benchmark["events"] if event["kind"] == "target-motion"] + + assert len(digest) == 64 + assert target_events + assert all(event["target_source_track_ids"] for event in target_events)