diff --git a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md index ca92977..0dc7967 100644 --- a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md +++ b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md @@ -415,6 +415,13 @@ Dataset expansion is no longer the next gate. - [x] Add a separate diagnostic motion-observation and near-occupied proximity derivative with temporal freshness, source provenance and no persistent-map mutation. +- [x] Run the candidate camera detector through the canonical E29 association + over all 4,489 RAVNOVES00 frames. Keep camera semantic ownership, publish + LiDAR range only from qualified occupied support and retain the detector as + a diagnostic shadow pending independent quality review. +- [x] Version the vehicle-body/LiDAR-mount contract and bind RAVNOVES00 + fail-closed as a portable, physically unbound K1 source without invented + dimensions or transforms. - [ ] Admit a `recent-collision` state only after vehicle-body and LiDAR-mount geometry are bound; proximity is not collision truth. - [x] Reuse the accepted camera-to-LiDAR projection as an optional semantic diff --git a/docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md b/docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md index 21cbaf0..c3967a3 100644 --- a/docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md +++ b/docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md @@ -176,6 +176,13 @@ real authority. - [x] Verify strict round-trip plus adversarial duplicate ownership, unknown owner, camera-only/conflict range, geometry-only semantics, held/persistent ownership, unavailable source and authority mutation cases. +- [x] Reuse the canonical E29 frame association in E53 over all 4,489 + RAVNOVES00 frames with a second camera candidate. Preserve camera semantic + ownership, keep unavailable LiDAR range explicit and reject runtime + promotion without independent truth. +- [x] Add `missioncore.rig-geometry/v1` with strict unbound/measured/qualified + states. Bind the portable RAVNOVES00 K1 as `unbound`; do not infer a vehicle + body or LiDAR-to-body mount from the recording. The frozen A3 engineering and human generations cover all 486 items and retains the exact A2 materialization/review-pack and 42-sheet evidence diff --git a/docs/adr/0035-camera-first-shadow-and-rig-geometry-boundary.md b/docs/adr/0035-camera-first-shadow-and-rig-geometry-boundary.md new file mode 100644 index 0000000..56d5b60 --- /dev/null +++ b/docs/adr/0035-camera-first-shadow-and-rig-geometry-boundary.md @@ -0,0 +1,57 @@ +# ADR 0035: Camera-first shadow and physical rig-geometry boundary + +Date: 2026-07-30 +Status: accepted and implemented + +## Decision + +Mission Core preserves one perception ownership rule: + +```text +camera owns semantic class and object proposal +LiDAR owns metric support and range +``` + +The canonical E29 association is exposed as one reusable frame evaluator and +is reused by E29 and full-source candidate shadows. A camera proposal remains +an object when LiDAR support is unavailable; its range remains unavailable. +Unassociated LiDAR geometry remains geometry without a semantic class. + +E53 runs a candidate camera detector over every RAVNOVES00 frame through that +unchanged association. It is a diagnostic shadow and cannot mutate E29, +persistent reconstruction or runtime detector selection. + +## Rig geometry + +Vehicle-body and LiDAR mount/extrinsic use +`missioncore.rig-geometry/v1`. The contract has three states: + +- `unbound`: no physical body or mount values may be present; +- `measured`: body and rigid transform require immutable measurement evidence + and uncertainty, but collision remains unavailable; +- `qualified`: geometry passed its named qualification method, while + collision still requires a separate algorithm and safety gate. + +RAVNOVES00 binds `ravnoves00-portable-k1/unbound-v1`. It is portable K1 +evidence and supplies no vehicle body or measured LiDAR→body transform. +Invented dimensions, sensor height or identity transforms are rejected. + +## Resource correction + +The first E53 full pass exposed repeated decompression of +`cloud_points_map` inside the frame loop. That immutable result failed its +50 ms p95 gate at 210.7093 ms. + +The accepted implementation materializes compressed source members once +before sequential frame processing. The corrected run processed all 4 489 +frames at 1.7993 ms p95 and 36.8594 MiB peak-RSS growth. No Docker, worker, +network service or second Mission Core backend participates in the path. + +## Authority + +E53 proves technical full-source execution, not detector accuracy: + +- E29 overlap is comparison, not ground truth; +- adjacent image-space IoU is continuity diagnostics, not tracking truth; +- the candidate detector is not operationally promoted; +- collision, navigation, safety and commands remain false. diff --git a/experiments/perception/LAB_E53_REPORT_2026-07-30.md b/experiments/perception/LAB_E53_REPORT_2026-07-30.md new file mode 100644 index 0000000..ad08344 --- /dev/null +++ b/experiments/perception/LAB_E53_REPORT_2026-07-30.md @@ -0,0 +1,116 @@ +# LAB E53 · полный camera-first shadow + +Дата: 2026-07-30 + +Статус: принят технический диагностический shadow; camera detector не +повышен до operational runtime + +## Цель + +Проверить на всех 4 489 кадрах RAVNOVES00 восстановленный архитектурный +принцип: + +```text +camera detection and semantic class + → canonical E29 camera↔LiDAR association + → LiDAR metric support and range +``` + +LiDAR не создаёт семантический класс. Camera-объект без квалифицированной +LiDAR-поддержки остаётся camera-only объектом без выдуманной дальности. + +## Неизменяемые входы + +- E10 LiDAR pack: + `e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b`; +- local surface: + `k1-local-surface-23762244c8bdb97de26fb721ac957d7a00bc9a63571ac4cfa4be19c4effc7d55`; +- camera candidate: + `result-f4cebdea8a82698a5b8a65d2c3fbdb0428b88b9dc49fe45f8cb37d740ed83d02`; +- accepted E29: + `e29-camera-geometry-421a9d930638bef12cd5eb10979a477917fa4a389e655ed95f73ba4bd62e13dc`; +- rig geometry: + `ravnoves00-portable-k1/unbound-v1`. + +Rig profile намеренно не содержит корпуса, трансформации LiDAR→body или +физических размеров: RAVNOVES00 записан переносным K1 и не доказывает +установку на транспортное средство. + +## Метод + +E53 потоково читает detector и E29 metadata, один раз материализует compressed +source arrays и для каждого source-aligned кадра: + +1. оставляет camera-классы `person`, `bicycle`, `car`, `motorcycle`, `bus`, + `truck` с score не ниже 0.5; +2. отбрасывает bbox больше 25% изображения; +3. применяет без изменения профиль + `camera-first-local-surface-validation/v1`; +4. сохраняет camera semantic ownership; +5. публикует LiDAR range только при связанном occupied support; +6. измеряет adjacent-frame family+IoU continuity и source-bound overlap с E29; +7. оставляет collision unavailable. + +Сравнение с E29 и adjacent IoU не являются ground truth или persistent +tracking truth. + +## Отвергнутый первый проход + +Первый immutable результат: + +```text +e53-camera-first-shadow-94eb57cce8dbfb0ea679486ed55aec32c2f38c93ae67e1c606787587cd3a0da5 +``` + +Он обработал все 4 489 кадров, но был отвергнут: p95 составил 210.7093 ms +против заранее заданных 50 ms. Причина — обращение к compressed NPZ member +`cloud_points_map` внутри frame loop, из-за чего массив 9.2 млн точек +распаковывался повторно. + +Исправление не меняло входы, пороги или ассоциацию: source arrays +материализуются один раз до цикла. + +## Принятый результат + +```text +e53-camera-first-shadow-e6f03cf8bfb15db86100239b060e13f914532618b7b99e811866e4e6a555186c +``` + +| Измерение | Результат | +| --- | ---: | +| Кадры | 4 489 / 4 489 | +| Кадры с LiDAR | 3 928 | +| Кадры с camera candidates | 4 471 | +| Принятые camera candidates | 48 944 | +| Pathological large boxes отклонено | 946 | +| Camera + LiDAR agree | 7 686 | +| Camera-only | 34 940 | +| Source unavailable | 6 318 | +| Объекты с LiDAR range | 10 183 | +| Доля объектов с range | 20.8054% | +| E29 current family+IoU overlap | 18 199 / 19 625 = 92.7338% | +| Adjacent family+IoU continuity | 31 476 / 48 933 = 64.3247% | +| Frame latency p50 / p95 / max | 0.9881 / 1.7993 / 9.4521 ms | +| Полный elapsed | 4 962.34 ms | +| Peak-RSS growth | 36.8594 MiB | + +Распределение camera candidates: 44 498 `car`, 2 341 `person`, 1 876 +`truck`, 142 `bicycle`, 66 `motorcycle`, 21 `bus`. + +## Решение + +Полный source-bound camera-first shadow технически принят: + +- accounting и binding закрыты; +- E29 association переиспользована; +- latency и memory gate пройдены; +- camera semantic ownership сохранён; +- LiDAR-native class, false free space и collision не публикуются. + +Mask R-CNN не принят как operational detector. Большое число camera candidates, +64.32% adjacent continuity и 92.73% overlap с E29 требуют независимой +source-bound quality review. E29 также не является ground truth. + +Physical vehicle-body и LiDAR mount/extrinsic остаются отдельным измерительным +gate. До их квалификации `recent-collision`, navigation, safety и commands +остаются недоступны. diff --git a/experiments/perception/e53_camera_first_shadow_profile.json b/experiments/perception/e53_camera_first_shadow_profile.json new file mode 100644 index 0000000..09aa96b --- /dev/null +++ b/experiments/perception/e53_camera_first_shadow_profile.json @@ -0,0 +1,56 @@ +{ + "schema_version": "missioncore.e53-camera-first-shadow-profile/v1", + "profile_id": "e53-ravnoves00-camera-first-full-shadow/v1", + "expected": { + "source_pack_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b", + "local_surface_model_id": "k1-local-surface-23762244c8bdb97de26fb721ac957d7a00bc9a63571ac4cfa4be19c4effc7d55", + "detector_result_id": "result-f4cebdea8a82698a5b8a65d2c3fbdb0428b88b9dc49fe45f8cb37d740ed83d02", + "baseline_e29_result_id": "e29-camera-geometry-421a9d930638bef12cd5eb10979a477917fa4a389e655ed95f73ba4bd62e13dc" + }, + "camera_candidate": { + "target_labels": [ + "person", + "bicycle", + "car", + "motorcycle", + "bus", + "truck" + ], + "label_groups": { + "person": [ + "person" + ], + "two-wheel": [ + "bicycle", + "motorcycle" + ], + "vehicle": [ + "car", + "bus", + "truck" + ] + }, + "minimum_score": 0.5, + "maximum_bbox_image_fraction": 0.25 + }, + "temporal": { + "minimum_adjacent_family_iou": 0.25, + "minimum_baseline_family_iou": 0.25, + "interpretation": "adjacent-image-space-continuity-diagnostic-not-track-truth" + }, + "acceptance": { + "maximum_frame_latency_p95_ms": 50.0, + "maximum_rss_growth_mib": 512.0, + "require_complete_frame_accounting": true, + "require_canonical_e29_association": true, + "require_camera_semantic_ownership": true, + "require_no_lidar_native_semantics": true, + "require_no_false_free_space": true, + "require_no_collision_authority": true, + "require_upstream_immutability": true + }, + "authority": { + "commands_enabled": false, + "navigation_or_safety_accepted": false + } +} diff --git a/experiments/perception/e53_ravnoves00_rig_geometry.json b/experiments/perception/e53_ravnoves00_rig_geometry.json new file mode 100644 index 0000000..d1ac4b4 --- /dev/null +++ b/experiments/perception/e53_ravnoves00_rig_geometry.json @@ -0,0 +1,20 @@ +{ + "schema_version": "missioncore.rig-geometry/v1", + "profile_id": "ravnoves00-portable-k1/unbound-v1", + "rig_kind": "portable", + "qualification": { + "state": "unbound", + "reason": "portable-k1-source-has-no-vehicle-body-or-measured-lidar-to-body-mount", + "evidence_sha256": [] + }, + "coordinate_frames": { + "lidar_frame": "k1-lidar", + "body_frame": null, + "body_from_lidar": null + }, + "vehicle_body": null, + "authority": { + "commands_enabled": false, + "navigation_or_safety_accepted": false + } +} diff --git a/experiments/perception/run_e53_camera_first_shadow.py b/experiments/perception/run_e53_camera_first_shadow.py new file mode 100644 index 0000000..34ed2d6 --- /dev/null +++ b/experiments/perception/run_e53_camera_first_shadow.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Build the immutable full-source E53 camera-first shadow.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from k1link.compute.e53_camera_first_shadow import ( + build_e53_camera_first_shadow, +) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source-pack", type=Path, required=True) + parser.add_argument("--local-surface", type=Path, required=True) + parser.add_argument("--detector-result", type=Path, required=True) + parser.add_argument("--baseline-e29", type=Path, required=True) + parser.add_argument("--rig-geometry", type=Path, required=True) + parser.add_argument("--profile", type=Path, required=True) + parser.add_argument("--output-root", type=Path, required=True) + args = parser.parse_args() + result = build_e53_camera_first_shadow( + source_pack_root=args.source_pack, + local_surface_root=args.local_surface, + detector_result_root=args.detector_result, + baseline_e29_root=args.baseline_e29, + rig_geometry_path=args.rig_geometry, + profile_path=args.profile, + output_root=args.output_root, + ) + print( + json.dumps( + { + "result_id": result.result_id, + "result_root": str(result.result_root), + "accepted": result.report["acceptance"]["accepted"], + "metrics": result.report["metrics"], + "decision": result.report["decision"], + }, + ensure_ascii=False, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/k1link/compute/e53_camera_first_shadow.py b/src/k1link/compute/e53_camera_first_shadow.py new file mode 100644 index 0000000..5b4d65b --- /dev/null +++ b/src/k1link/compute/e53_camera_first_shadow.py @@ -0,0 +1,1105 @@ +"""Full-source camera-first shadow using the canonical E29 association.""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import re +import resource +import shutil +import sys +import time +import uuid +from collections import Counter +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from itertools import zip_longest +from pathlib import Path +from typing import Any, Final + +import numpy as np + +from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import ( + project_map_points_kb4, +) + +from .lidar_field_review import E10LidarFieldSource +from .lidar_local_surface import K1LocalSurfaceV1 +from .rig_geometry import RigGeometry, load_rig_geometry +from .semantic_geometry_fusion import ( + CAMERA_GEOMETRY_FRAME_SCHEMA, + CAMERA_GEOMETRY_FUSION_SCHEMA, + CameraGeometryFusionProfile, + evaluate_camera_geometry_frame, + projection_profile_from_source, +) + +E53_PROFILE_SCHEMA: Final = "missioncore.e53-camera-first-shadow-profile/v1" +E53_RESULT_SCHEMA: Final = "missioncore.e53-camera-first-shadow/v1" +E53_FRAME_SCHEMA: Final = "missioncore.e53-camera-first-shadow-frame/v1" +E53_REPORT_SCHEMA: Final = "missioncore.e53-camera-first-shadow-report/v1" +E53_FRAMES_NAME: Final = "camera-first-shadow-frames.jsonl" +E53_REPORT_NAME: Final = "run-report.json" +E53_MANIFEST_NAME: Final = "manifest.json" + +_DETECTOR_RESULT_SCHEMA: Final = "missioncore.recorded-perception-result/v2" +_DETECTOR_FRAME_SCHEMA: Final = "missioncore.panoptic-frame/v1" +_RESULT_ID = re.compile(r"^e53-camera-first-shadow-[a-f0-9]{64}$") +_SHA256 = re.compile(r"^[a-f0-9]{64}$") +_AUTHORITY: Final = { + "commands_enabled": False, + "navigation_or_safety_accepted": False, +} + + +class E53CameraFirstShadowError(RuntimeError): + """An E53 source, profile, association, or immutable result is invalid.""" + + +@dataclass(frozen=True, slots=True) +class E53CameraFirstShadow: + """One verified full-source shadow result.""" + + result_id: str + result_root: Path + manifest: dict[str, Any] + report: dict[str, Any] + + +@dataclass(frozen=True, slots=True) +class _Profile: + raw: dict[str, Any] + expected_source_pack_id: str + expected_local_surface_id: str + expected_detector_result_id: str + expected_baseline_e29_id: str + target_labels: frozenset[str] + label_groups: Mapping[str, tuple[str, ...]] + minimum_detector_score: float + maximum_bbox_image_fraction: float + adjacent_family_iou: float + baseline_family_iou: float + maximum_latency_p95_ms: float + maximum_rss_growth_mib: float + + +def build_e53_camera_first_shadow( + *, + source_pack_root: Path, + local_surface_root: Path, + detector_result_root: Path, + baseline_e29_root: Path, + rig_geometry_path: Path, + profile_path: Path, + output_root: Path, +) -> E53CameraFirstShadow: + """Run the candidate detector over every source frame without promotion.""" + + profile = _read_profile(profile_path) + rig = load_rig_geometry(rig_geometry_path) + source = E10LidarFieldSource(source_pack_root) + surface = K1LocalSurfaceV1(local_surface_root) + try: + if ( + source.pack_id != profile.expected_source_pack_id + or surface.model_id != profile.expected_local_surface_id + or surface.identity.get("source_pack_id") != source.pack_id + ): + raise E53CameraFirstShadowError("E53 LiDAR/local-surface binding changed") + detector_root, detector_result, detector_frames = _read_detector( + detector_result_root, + profile=profile, + source=source, + ) + baseline_root, baseline_manifest, baseline_frames = _read_baseline( + baseline_e29_root, + profile=profile, + source=source, + ) + input_fingerprints_before = _input_fingerprints( + profile_path=profile_path, + rig_geometry_path=rig_geometry_path, + detector_root=detector_root, + detector_frames=detector_frames, + baseline_root=baseline_root, + baseline_frames=baseline_frames, + source=source, + surface=surface, + ) + identity = { + "schema_version": E53_RESULT_SCHEMA, + "source": { + "session_id": source.identity["session_id"], + "source_pack_id": source.pack_id, + "source_artifact_sha256": source.manifest["artifact"]["sha256"], + "local_surface_model_id": surface.model_id, + "local_surface_logical_sha256": surface.identity[ + "logical_content_sha256" + ], + "detector_result_id": detector_result["result_id"], + "detector_frames_sha256": _sha256(detector_frames), + "baseline_e29_result_id": baseline_manifest["result_id"], + "baseline_frames_sha256": _sha256(baseline_frames), + }, + "profile": profile.raw, + "canonical_association": { + "schema_version": CAMERA_GEOMETRY_FUSION_SCHEMA, + "frame_schema_version": CAMERA_GEOMETRY_FRAME_SCHEMA, + "profile": baseline_manifest["identity"]["profile"], + "producer_sha256": _sha256( + Path( + sys.modules[ + "k1link.compute.semantic_geometry_fusion" + ].__file__ + or "" + ).resolve(strict=True) + ), + }, + "rig_geometry": { + "profile_id": rig.document["profile_id"], + "identity_sha256": rig.identity_sha256, + "qualification_state": rig.qualification_state, + }, + "producer_sha256": _sha256(Path(__file__).resolve(strict=True)), + "authority": _AUTHORITY, + } + identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest() + result_id = f"e53-camera-first-shadow-{identity_sha256}" + destination = output_root.expanduser().absolute() / result_id + if destination.exists(): + return read_e53_camera_first_shadow(destination) + + destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + staging = destination.parent / f".{result_id}.{uuid.uuid4().hex}.tmp" + staging.mkdir(mode=0o700, exist_ok=False) + try: + report = _run_shadow( + staging=staging, + result_id=result_id, + identity_sha256=identity_sha256, + source=source, + surface=surface, + detector_frames=detector_frames, + baseline_frames=baseline_frames, + baseline_profile=_baseline_profile(baseline_manifest), + profile=profile, + rig=rig, + ) + input_fingerprints_after = _input_fingerprints( + profile_path=profile_path, + rig_geometry_path=rig_geometry_path, + detector_root=detector_root, + detector_frames=detector_frames, + baseline_root=baseline_root, + baseline_frames=baseline_frames, + source=source, + surface=surface, + ) + unchanged = input_fingerprints_after == input_fingerprints_before + report["acceptance"]["checks"]["upstream_unchanged"] = unchanged + report["acceptance"]["accepted"] = ( + unchanged + and all(report["acceptance"]["checks"].values()) + ) + if not unchanged: + raise E53CameraFirstShadowError("E53 inputs changed during execution") + _write_json(staging / E53_REPORT_NAME, report) + frames_path = staging / E53_FRAMES_NAME + manifest = { + "schema_version": E53_RESULT_SCHEMA, + "result_id": result_id, + "identity_sha256": identity_sha256, + "identity": identity, + "created_at_utc": _utc_now(), + "classification": "private-diagnostic-shadow", + "ground_truth": False, + "acceptance_state": ( + "accepted-diagnostic-shadow" + if report["acceptance"]["accepted"] + else "rejected-diagnostic-shadow" + ), + "artifacts": [ + _artifact(frames_path, "camera-first-shadow-frames"), + _artifact(staging / E53_REPORT_NAME, "run-report"), + ], + "authority": _AUTHORITY, + } + _write_json(staging / E53_MANIFEST_NAME, manifest) + os.replace(staging, destination) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + return read_e53_camera_first_shadow(destination) + finally: + source.close() + surface.close() + + +def read_e53_camera_first_shadow(root: Path) -> E53CameraFirstShadow: + """Open and fully verify one immutable E53 result.""" + + source = root.expanduser().absolute() + if source.is_symlink(): + raise E53CameraFirstShadowError("E53 result root cannot be a symlink") + resolved = source.resolve(strict=True) + manifest = _read_json(resolved / E53_MANIFEST_NAME) + identity = _object(manifest.get("identity"), "E53 identity") + identity_sha256 = manifest.get("identity_sha256") + if ( + manifest.get("schema_version") != E53_RESULT_SCHEMA + or identity.get("schema_version") != E53_RESULT_SCHEMA + or not isinstance(identity_sha256, str) + or _SHA256.fullmatch(identity_sha256) is None + or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256 + or resolved.name != f"e53-camera-first-shadow-{identity_sha256}" + or manifest.get("result_id") != resolved.name + or manifest.get("ground_truth") is not False + or manifest.get("authority") != _AUTHORITY + ): + raise E53CameraFirstShadowError("E53 result identity is invalid") + artifacts = _verified_artifacts( + resolved, + manifest.get("artifacts"), + key="role", + ) + frames = artifacts["camera-first-shadow-frames"] + report = _read_json(artifacts["run-report"]) + frame_count = sum(1 for line in frames.open("r", encoding="utf-8") if line) + if ( + report.get("schema_version") != E53_REPORT_SCHEMA + or report.get("result_id") != resolved.name + or report.get("identity_sha256") != identity_sha256 + or report.get("metrics", {}).get("frames", {}).get("processed") != frame_count + or report.get("decision", {}).get("runtime_promoted") is not False + or report.get("authority") != _AUTHORITY + ): + raise E53CameraFirstShadowError("E53 report is invalid") + return E53CameraFirstShadow( + result_id=resolved.name, + result_root=resolved, + manifest=manifest, + report=report, + ) + + +def select_candidate_detections( + instances: object, + *, + width: int, + height: int, + profile: _Profile, +) -> tuple[list[dict[str, object]], dict[str, int]]: + """Admit camera detections without assigning any LiDAR-native semantics.""" + + if not isinstance(instances, list): + raise E53CameraFirstShadowError("detector instances must be a list") + selected: list[dict[str, object]] = [] + counts = { + "total": len(instances), + "non_target": 0, + "low_score": 0, + "invalid_box": 0, + "pathological_large_box": 0, + } + for instance in instances: + if not isinstance(instance, Mapping): + raise E53CameraFirstShadowError("detector instance is invalid") + label = instance.get("label") + if not isinstance(label, str) or label not in profile.target_labels: + counts["non_target"] += 1 + continue + score = _finite_number(instance.get("score"), "detector score") + if score < profile.minimum_detector_score: + counts["low_score"] += 1 + continue + bbox = _bbox(instance.get("box_xyxy"), width=width, height=height) + if bbox is None: + counts["invalid_box"] += 1 + continue + fraction = ( + (bbox[2] - bbox[0]) + * (bbox[3] - bbox[1]) + / float(width * height) + ) + if fraction > profile.maximum_bbox_image_fraction: + counts["pathological_large_box"] += 1 + continue + selected.append( + { + "source_track_id": None, + "track_id": None, + "label": label, + "association_group": _label_family(label, profile), + "score": score, + "bbox_xyxy": bbox, + "cuboid_status": "camera-candidate-no-cuboid", + "camera_motion_state": "unavailable", + "camera_motion_confidence": None, + "motion_state": "unavailable", + "motion_status": "candidate-detector-has-no-track-motion", + "detector_instance_id": instance.get("instance_id"), + "detector_mask_pixels": instance.get("mask_pixels"), + } + ) + return selected, counts + + +def adjacent_family_matches( + previous: Sequence[Mapping[str, Any]], + current: Sequence[Mapping[str, Any]], + *, + minimum_iou: float, +) -> int: + """Count deterministic one-to-one adjacent-frame family/IoU links.""" + + pairs: list[tuple[float, int, int]] = [] + for previous_index, previous_item in enumerate(previous): + for current_index, current_item in enumerate(current): + if previous_item.get("association_group") != current_item.get( + "association_group" + ): + continue + iou = _box_iou( + _required_bbox(previous_item.get("bbox_xyxy")), + _required_bbox(current_item.get("bbox_xyxy")), + ) + if iou >= minimum_iou: + pairs.append((iou, previous_index, current_index)) + used_previous: set[int] = set() + used_current: set[int] = set() + matches = 0 + for _, previous_index, current_index in sorted(pairs, reverse=True): + if previous_index in used_previous or current_index in used_current: + continue + used_previous.add(previous_index) + used_current.add(current_index) + matches += 1 + return matches + + +def _run_shadow( + *, + staging: Path, + result_id: str, + identity_sha256: str, + source: E10LidarFieldSource, + surface: K1LocalSurfaceV1, + detector_frames: Path, + baseline_frames: Path, + baseline_profile: CameraGeometryFusionProfile, + profile: _Profile, + rig: RigGeometry, +) -> dict[str, Any]: + started = time.perf_counter() + rss_start = _process_peak_rss_mib() + arrays = source.arrays + offsets = arrays["cloud_offsets"] + points_map = arrays["cloud_points_map"] + sample_available = arrays["sample_available"] + source_frame_indices = arrays["source_frame_indices"] + session_seconds = arrays["session_seconds"] + pose_positions = arrays["pose_positions_map"] + pose_orientations = arrays["pose_quaternions_map_from_lidar"] + point_class = surface.arrays["point_class"] + point_height = surface.arrays["point_height_m"] + surface_frame_valid = surface.arrays["frame_valid"] + projection = projection_profile_from_source(source) + frame_latencies: list[float] = [] + status_counts: Counter[str] = Counter() + class_counts: Counter[str] = Counter() + rejection_counts: Counter[str] = Counter() + candidate_count = 0 + ranged_count = 0 + frame_count = 0 + frames_with_candidates = 0 + adjacent_denominator = 0 + adjacent_matches = 0 + baseline_current = 0 + baseline_family_matches = 0 + previous_candidates: list[dict[str, object]] = [] + collision = rig.collision_contract() + output_path = staging / E53_FRAMES_NAME + with ( + detector_frames.open("r", encoding="utf-8") as detector_stream, + baseline_frames.open("r", encoding="utf-8") as baseline_stream, + output_path.open("x", encoding="utf-8") as output, + ): + for detector_line, baseline_line in zip_longest( + detector_stream, + baseline_stream, + ): + frame_started = time.perf_counter() + if detector_line is None or baseline_line is None: + raise E53CameraFirstShadowError("E53 upstream frame counts differ") + detector = _json_line(detector_line, "detector frame") + baseline = _json_line(baseline_line, "baseline E29 frame") + _validate_frame_alignment( + detector=detector, + baseline=baseline, + expected_frame_index=frame_count, + expected_source_frame_index=int( + source_frame_indices[frame_count] + ), + expected_session_seconds=float(session_seconds[frame_count]), + ) + candidates, rejected = select_candidate_detections( + detector.get("instances"), + width=projection.width, + height=projection.height, + profile=profile, + ) + for key, value in rejected.items(): + rejection_counts[key] += value + if candidates: + frames_with_candidates += 1 + candidate_count += len(candidates) + class_counts.update(str(item["label"]) for item in candidates) + if frame_count > 0: + adjacent_denominator += len(candidates) + adjacent_matches += adjacent_family_matches( + previous_candidates, + candidates, + minimum_iou=profile.adjacent_family_iou, + ) + previous_candidates = candidates + + start = int(offsets[frame_count]) + end = int(offsets[frame_count + 1]) + points = np.asarray( + points_map[start:end], + dtype=np.float64, + ) + classes = point_class[start:end] + heights = point_height[start:end] + source_available = bool(sample_available[frame_count]) + surface_valid = bool(surface_frame_valid[frame_count]) + position = np.asarray( + pose_positions[frame_count], + dtype=np.float64, + ) + if source_available and surface_valid: + orientation = pose_orientations[frame_count] + projected = project_map_points_kb4( + points, + position_map_xyz=( + float(position[0]), + float(position[1]), + float(position[2]), + ), + orientation_map_from_lidar_xyzw=( + float(orientation[0]), + float(orientation[1]), + float(orientation[2]), + float(orientation[3]), + ), + profile=projection, + ) + else: + projected = None + evaluation = evaluate_camera_geometry_frame( + objects=candidates, + projected=projected, + frame_points_map=points, + point_class=classes, + point_height_m=heights, + sensor_position_map=position, + source_available=source_available, + surface_valid=surface_valid, + profile=baseline_profile, + include_geometry_only=False, + ) + observations = list(evaluation.semantic_observations) + for observation in observations: + status_counts[str(observation["geometry_status"])] += 1 + ranged_count += int(observation["range_m"] is not None) + + baseline_observations = [ + item + for item in _object_list( + baseline.get("semantic_observations"), + "baseline observations", + ) + if item.get("semantic_current") is True + and isinstance(item.get("bbox_xyxy"), list) + ] + baseline_current += len(baseline_observations) + baseline_family_matches += adjacent_family_matches( + baseline_observations, + candidates, + minimum_iou=profile.baseline_family_iou, + ) + frame_document = { + "schema_version": E53_FRAME_SCHEMA, + "frame_index": frame_count, + "source_frame_index": int(source_frame_indices[frame_count]), + "session_seconds": float(session_seconds[frame_count]), + "source_available": source_available, + "local_surface_valid": surface_valid, + "candidate_count": len(candidates), + "semantic_observations": observations, + "baseline_current_count": len(baseline_observations), + "collision": collision, + "policy": { + "camera_owns_semantics": True, + "lidar_owns_metric_geometry": True, + "lidar_native_semantic_promotion": False, + "absence_of_points_means_free": False, + "runtime_publishable": False, + }, + "authority": _AUTHORITY, + } + output.write(_canonical_json(frame_document).decode() + "\n") + frame_count += 1 + frame_latencies.append( + (time.perf_counter() - frame_started) * 1_000.0 + ) + rss_end = _process_peak_rss_mib() + latency = _distribution(frame_latencies) + rss_growth = max(0.0, rss_end - rss_start) + checks = { + "complete_frame_accounting": frame_count == source.frame_count, + "source_alignment_complete": frame_count == source.frame_count, + "canonical_e29_association_reused": True, + "camera_semantic_ownership_preserved": True, + "lidar_native_semantics_absent": True, + "absence_of_points_not_free": True, + "collision_not_invented": collision["recent_collision_publishable"] is False, + "latency_p95_within_gate": _required_number(latency["p95"]) + <= profile.maximum_latency_p95_ms, + "rss_growth_within_gate": rss_growth <= profile.maximum_rss_growth_mib, + "upstream_unchanged": False, + } + return { + "schema_version": E53_REPORT_SCHEMA, + "result_id": result_id, + "identity_sha256": identity_sha256, + "created_at_utc": _utc_now(), + "status": "completed-full-source-diagnostic-shadow", + "ground_truth": False, + "metrics": { + "frames": { + "processed": frame_count, + "source_available": int(np.count_nonzero(sample_available)), + "with_candidates": frames_with_candidates, + }, + "camera_candidates": { + "accepted": candidate_count, + "by_label": dict(sorted(class_counts.items())), + "filter_accounting": dict(sorted(rejection_counts.items())), + "adjacent_family_iou_matches": adjacent_matches, + "adjacent_match_denominator": adjacent_denominator, + "adjacent_match_fraction": ( + adjacent_matches / adjacent_denominator + if adjacent_denominator + else 0.0 + ), + }, + "lidar_association": { + "with_metric_range": ranged_count, + "range_fraction": ( + ranged_count / candidate_count if candidate_count else 0.0 + ), + "geometry_status": dict(sorted(status_counts.items())), + }, + "baseline_comparison": { + "baseline_current_observations": baseline_current, + "candidate_family_iou_matches": baseline_family_matches, + "candidate_match_fraction": ( + baseline_family_matches / baseline_current + if baseline_current + else 0.0 + ), + "interpretation": "source-bound-comparison-not-ground-truth", + }, + "runtime": { + "elapsed_ms": (time.perf_counter() - started) * 1_000.0, + "frame_processing_ms": latency, + "process_peak_rss_start_mib": rss_start, + "process_peak_rss_end_mib": rss_end, + "process_peak_rss_growth_mib": rss_growth, + }, + }, + "rig_geometry": { + "profile_id": rig.document["profile_id"], + "identity_sha256": rig.identity_sha256, + "qualification_state": rig.qualification_state, + "collision": collision, + }, + "acceptance": { + "accepted": False, + "checks": checks, + }, + "decision": { + "camera_first_full_source_shadow_complete": True, + "runtime_promoted": False, + "detector_accepted_as_operational": False, + "vehicle_geometry_qualified": rig.collision_geometry_qualified, + "next_gate": ( + "independent source-bound quality review and explicit runtime " + "promotion decision; physical mount/body measurements remain separate" + ), + }, + "limitations": [ + "RAVNOVES00 has no independent detector ground truth", + "adjacent-frame IoU is a continuity diagnostic, not persistent tracking truth", + "portable K1 evidence has no measured vehicle body or LiDAR-to-body mount", + "camera-only observations remain objects without invented LiDAR range", + ], + "authority": _AUTHORITY, + } + + +def _read_profile(path: Path) -> _Profile: + value = _read_json(path.resolve(strict=True)) + expected = _object(value.get("expected"), "E53 expected inputs") + camera = _object(value.get("camera_candidate"), "E53 camera candidate") + temporal = _object(value.get("temporal"), "E53 temporal profile") + acceptance = _object(value.get("acceptance"), "E53 acceptance") + label_groups_raw = _object(camera.get("label_groups"), "E53 label groups") + target_labels_raw = camera.get("target_labels") + if ( + value.get("schema_version") != E53_PROFILE_SCHEMA + or not isinstance(value.get("profile_id"), str) + or not isinstance(target_labels_raw, list) + or not target_labels_raw + or any(not isinstance(item, str) for item in target_labels_raw) + or value.get("authority") != _AUTHORITY + ): + raise E53CameraFirstShadowError("E53 profile is invalid") + label_groups: dict[str, tuple[str, ...]] = {} + for group, labels in label_groups_raw.items(): + if ( + not isinstance(group, str) + or not isinstance(labels, list) + or not labels + or any(not isinstance(label, str) for label in labels) + ): + raise E53CameraFirstShadowError("E53 label groups are invalid") + label_groups[group] = tuple(labels) + target_labels = frozenset(target_labels_raw) + if { + label for labels in label_groups.values() for label in labels + } != target_labels: + raise E53CameraFirstShadowError("E53 target-label grouping is incomplete") + profile = _Profile( + raw=value, + expected_source_pack_id=_string( + expected.get("source_pack_id"), + "expected source pack", + ), + expected_local_surface_id=_string( + expected.get("local_surface_model_id"), + "expected local surface", + ), + expected_detector_result_id=_string( + expected.get("detector_result_id"), + "expected detector result", + ), + expected_baseline_e29_id=_string( + expected.get("baseline_e29_result_id"), + "expected baseline E29", + ), + target_labels=target_labels, + label_groups=label_groups, + minimum_detector_score=_unit_number( + camera.get("minimum_score"), + "minimum detector score", + ), + maximum_bbox_image_fraction=_unit_number( + camera.get("maximum_bbox_image_fraction"), + "maximum bbox image fraction", + ), + adjacent_family_iou=_unit_number( + temporal.get("minimum_adjacent_family_iou"), + "adjacent family IoU", + ), + baseline_family_iou=_unit_number( + temporal.get("minimum_baseline_family_iou"), + "baseline family IoU", + ), + maximum_latency_p95_ms=_positive_number( + acceptance.get("maximum_frame_latency_p95_ms"), + "maximum frame latency", + ), + maximum_rss_growth_mib=_positive_number( + acceptance.get("maximum_rss_growth_mib"), + "maximum RSS growth", + ), + ) + return profile + + +def _read_detector( + root: Path, + *, + profile: _Profile, + source: E10LidarFieldSource, +) -> tuple[Path, dict[str, Any], Path]: + resolved = _safe_root(root, "detector result") + result = _read_json(resolved / "result.json") + identity = _object(result.get("identity"), "detector identity") + calibration = _object(identity.get("calibration"), "detector calibration") + artifacts = _artifacts_by_kind(resolved, result.get("artifacts")) + frames = artifacts["panoptic-frame-metadata"] + if ( + result.get("schema_version") != _DETECTOR_RESULT_SCHEMA + or result.get("result_id") != resolved.name + or resolved.name != profile.expected_detector_result_id + or result.get("frames_processed") != source.frame_count + or result.get("session_id") != source.identity["session_id"] + or result.get("source_id") != source.identity["source_id"] + or identity.get("job_id") != source.identity["job_id"] + or identity.get("input_sha256") != source.identity["input_sha256"] + or calibration.get("camera_slot") != source.identity["camera_slot"] + or calibration.get("content_identity_sha256") + != source.identity["calibration_sha256"] + ): + raise E53CameraFirstShadowError("E53 detector binding changed") + return resolved, result, frames + + +def _read_baseline( + root: Path, + *, + profile: _Profile, + source: E10LidarFieldSource, +) -> tuple[Path, dict[str, Any], Path]: + resolved = _safe_root(root, "baseline E29") + manifest = _read_json(resolved / "manifest.json") + identity = _object(manifest.get("identity"), "baseline E29 identity") + artifacts = _artifacts_by_role(resolved, manifest.get("artifacts")) + if ( + manifest.get("schema_version") != CAMERA_GEOMETRY_FUSION_SCHEMA + or manifest.get("result_id") != resolved.name + or resolved.name != profile.expected_baseline_e29_id + or identity.get("source_pack_id") != source.pack_id + or identity.get("frame_count") != source.frame_count + ): + raise E53CameraFirstShadowError("E53 baseline E29 binding changed") + return resolved, manifest, artifacts["camera-geometry-frames"] + + +def _baseline_profile(manifest: Mapping[str, Any]) -> CameraGeometryFusionProfile: + profile = _object( + _object(manifest.get("identity"), "baseline identity").get("profile"), + "baseline profile", + ) + allowed = { + key: value + for key, value in profile.items() + if key + in { + "profile_id", + "bbox_inset_fraction", + "depth_cluster_minimum_gap_m", + "depth_cluster_gap_fraction", + "spatial_cluster_radius_m", + "semantic_minimum_occupied_points", + "semantic_minimum_occupied_voxels", + "semantic_voxel_size_m", + "conflict_minimum_classified_points", + "conflict_surface_fraction", + "geometry_local_radius_m", + "geometry_voxel_size_m", + "geometry_minimum_cluster_points", + "geometry_minimum_cluster_voxels", + "maximum_geometry_clusters_per_frame", + } + } + try: + return CameraGeometryFusionProfile(**allowed) + except (TypeError, ValueError) as exc: + raise E53CameraFirstShadowError("baseline E29 profile is invalid") from exc + + +def _validate_frame_alignment( + *, + detector: Mapping[str, Any], + baseline: Mapping[str, Any], + expected_frame_index: int, + expected_source_frame_index: int, + expected_session_seconds: float, +) -> None: + if ( + detector.get("schema_version") != _DETECTOR_FRAME_SCHEMA + or detector.get("frame_index") != expected_source_frame_index + or baseline.get("schema_version") != CAMERA_GEOMETRY_FRAME_SCHEMA + or baseline.get("frame_index") != expected_frame_index + or baseline.get("source_frame_index") != expected_source_frame_index + or not math.isclose( + _finite_number(detector.get("session_seconds"), "detector time"), + expected_session_seconds, + abs_tol=1e-6, + ) + or not math.isclose( + _finite_number(baseline.get("session_seconds"), "baseline time"), + expected_session_seconds, + abs_tol=1e-9, + ) + ): + raise E53CameraFirstShadowError("E53 source frame alignment changed") + + +def _input_fingerprints( + *, + profile_path: Path, + rig_geometry_path: Path, + detector_root: Path, + detector_frames: Path, + baseline_root: Path, + baseline_frames: Path, + source: E10LidarFieldSource, + surface: K1LocalSurfaceV1, +) -> dict[str, object]: + return { + "profile_sha256": _sha256(profile_path.resolve(strict=True)), + "rig_geometry_sha256": _sha256(rig_geometry_path.resolve(strict=True)), + "detector_result_sha256": _sha256(detector_root / "result.json"), + "detector_frames_sha256": _sha256(detector_frames), + "baseline_manifest_sha256": _sha256(baseline_root / "manifest.json"), + "baseline_frames_sha256": _sha256(baseline_frames), + "source_artifact": _artifact_stat( + source.root / str(source.manifest["artifact"]["path"]) + ), + "surface_artifact": _artifact_stat( + surface.root / str(surface.manifest["artifacts"][0]["path"]) + ), + } + + +def _artifact_stat(path: Path) -> tuple[int, int, int]: + stat = path.stat() + return stat.st_size, stat.st_mtime_ns, stat.st_ino + + +def _safe_root(path: Path, label: str) -> Path: + source = path.expanduser().absolute() + if source.is_symlink(): + raise E53CameraFirstShadowError(f"{label} cannot be a symlink") + resolved = source.resolve(strict=True) + if not resolved.is_dir(): + raise E53CameraFirstShadowError(f"{label} must be a directory") + return resolved + + +def _artifacts_by_kind(root: Path, value: object) -> dict[str, Path]: + return _verified_artifacts(root, value, key="kind") + + +def _artifacts_by_role(root: Path, value: object) -> dict[str, Path]: + return _verified_artifacts(root, value, key="role") + + +def _verified_artifacts( + root: Path, + value: object, + *, + key: str, +) -> dict[str, Path]: + result: dict[str, Path] = {} + if not isinstance(value, list): + raise E53CameraFirstShadowError("artifact list is invalid") + for raw in value: + artifact = _object(raw, "artifact") + name = artifact.get(key) + relative = artifact.get("path") + if ( + not isinstance(name, str) + or name in result + or not isinstance(relative, str) + or Path(relative).name != relative + ): + raise E53CameraFirstShadowError("artifact identity is invalid") + path = root / relative + if ( + path.is_symlink() + or not path.is_file() + or path.stat().st_size != artifact.get("byte_length") + or _sha256(path) != artifact.get("sha256") + ): + raise E53CameraFirstShadowError("artifact content changed") + result[name] = path + return result + + +def _label_family(label: str, profile: _Profile) -> str: + matches = [ + group + for group, labels in profile.label_groups.items() + if label in labels + ] + if len(matches) != 1: + raise E53CameraFirstShadowError("candidate label family is ambiguous") + return matches[0] + + +def _bbox(value: object, *, width: int, height: int) -> list[float] | None: + if not isinstance(value, list) or len(value) != 4: + return None + try: + box = [float(item) for item in value] + except (TypeError, ValueError): + return None + if ( + not np.isfinite(box).all() + or box[0] < 0.0 + or box[1] < 0.0 + or box[2] > width + or box[3] > height + or box[2] <= box[0] + or box[3] <= box[1] + ): + return None + return box + + +def _required_bbox(value: object) -> list[float]: + if not isinstance(value, list) or len(value) != 4: + raise E53CameraFirstShadowError("comparison bbox is invalid") + box = [float(item) for item in value] + if not np.isfinite(box).all() or box[2] <= box[0] or box[3] <= box[1]: + raise E53CameraFirstShadowError("comparison bbox is invalid") + return box + + +def _box_iou(first: list[float], second: list[float]) -> float: + left = max(first[0], second[0]) + top = max(first[1], second[1]) + right = min(first[2], second[2]) + bottom = min(first[3], second[3]) + intersection = max(0.0, right - left) * max(0.0, bottom - top) + first_area = (first[2] - first[0]) * (first[3] - first[1]) + second_area = (second[2] - second[0]) * (second[3] - second[1]) + union = first_area + second_area - intersection + return intersection / union if union > 0.0 else 0.0 + + +def _distribution(values: list[float]) -> dict[str, float | int | None]: + array = np.asarray(values, dtype=np.float64) + if array.size == 0: + return { + "sample_count": 0, + "minimum": None, + "mean": None, + "p50": None, + "p95": None, + "maximum": None, + } + return { + "sample_count": int(array.size), + "minimum": float(np.min(array)), + "mean": float(np.mean(array)), + "p50": float(np.percentile(array, 50)), + "p95": float(np.percentile(array, 95)), + "maximum": float(np.max(array)), + } + + +def _process_peak_rss_mib() -> float: + value = float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) + divisor = 1024.0 * 1024.0 if sys.platform == "darwin" else 1024.0 + return value / divisor + + +def _artifact(path: Path, role: str) -> dict[str, object]: + return { + "role": role, + "path": path.name, + "media_type": ( + "application/x-ndjson" + if path.suffix == ".jsonl" + else "application/json" + ), + "byte_length": path.stat().st_size, + "sha256": _sha256(path), + } + + +def _read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise E53CameraFirstShadowError(f"invalid JSON: {path.name}") from exc + return _object(value, path.name) + + +def _json_line(line: str, label: str) -> dict[str, Any]: + try: + value = json.loads(line) + except json.JSONDecodeError as exc: + raise E53CameraFirstShadowError(f"{label} is invalid JSON") from exc + return _object(value, label) + + +def _object(value: object, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise E53CameraFirstShadowError(f"{label} must be an object") + return value + + +def _object_list(value: object, label: str) -> list[dict[str, Any]]: + if not isinstance(value, list) or any(not isinstance(item, dict) for item in value): + raise E53CameraFirstShadowError(f"{label} must be a list of objects") + return value + + +def _string(value: object, label: str) -> str: + if not isinstance(value, str) or not value: + raise E53CameraFirstShadowError(f"{label} is invalid") + return value + + +def _finite_number(value: object, label: str) -> float: + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise E53CameraFirstShadowError(f"{label} is invalid") + number = float(value) + if not math.isfinite(number): + raise E53CameraFirstShadowError(f"{label} is invalid") + return number + + +def _unit_number(value: object, label: str) -> float: + number = _finite_number(value, label) + if not 0.0 <= number <= 1.0: + raise E53CameraFirstShadowError(f"{label} is outside [0, 1]") + return number + + +def _positive_number(value: object, label: str) -> float: + number = _finite_number(value, label) + if number <= 0.0: + raise E53CameraFirstShadowError(f"{label} must be positive") + return number + + +def _required_number(value: object) -> float: + return _finite_number(value, "required metric") + + +def _write_json(path: Path, value: object) -> None: + path.write_bytes(_canonical_json(value)) + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") diff --git a/src/k1link/compute/rig_geometry.py b/src/k1link/compute/rig_geometry.py new file mode 100644 index 0000000..e6852fc --- /dev/null +++ b/src/k1link/compute/rig_geometry.py @@ -0,0 +1,237 @@ +"""Versioned, fail-closed vehicle-body and sensor-mount geometry.""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Final, Literal + +import numpy as np + +RIG_GEOMETRY_SCHEMA: Final = "missioncore.rig-geometry/v1" +_SHA256 = re.compile(r"^[a-f0-9]{64}$") +_SAFE_ID = re.compile(r"^[a-z0-9][a-z0-9._/-]{0,159}$") +_AUTHORITY: Final = { + "commands_enabled": False, + "navigation_or_safety_accepted": False, +} + + +class RigGeometryError(ValueError): + """A rig geometry document is incomplete, ambiguous, or inconsistent.""" + + +@dataclass(frozen=True, slots=True) +class RigGeometry: + """Validated rig geometry plus its content identity.""" + + document: Mapping[str, Any] + identity_sha256: str + qualification_state: Literal["unbound", "measured", "qualified"] + + @property + def metric_body_geometry_available(self) -> bool: + return self.qualification_state in {"measured", "qualified"} + + @property + def collision_geometry_qualified(self) -> bool: + return self.qualification_state == "qualified" + + def collision_contract(self) -> dict[str, object]: + if self.collision_geometry_qualified: + return { + "state": "geometry-qualified", + "geometry_profile_sha256": self.identity_sha256, + "recent_collision_publishable": False, + "reason": "collision-algorithm-and-independent-safety-gate-not-qualified", + } + return { + "state": "unavailable", + "geometry_profile_sha256": self.identity_sha256, + "recent_collision_publishable": False, + "reason": ( + "vehicle-body-and-lidar-mount-geometry-not-bound" + if self.qualification_state == "unbound" + else "vehicle-body-and-lidar-mount-geometry-not-qualified" + ), + } + + +def load_rig_geometry(path: Path) -> RigGeometry: + """Read and validate one regular JSON geometry profile.""" + + source = path.expanduser().absolute() + if source.is_symlink(): + raise RigGeometryError("rig geometry profile cannot be a symlink") + resolved = source.resolve(strict=True) + if not resolved.is_file(): + raise RigGeometryError("rig geometry profile must be a regular file") + try: + value = json.loads(resolved.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RigGeometryError("rig geometry profile is not valid JSON") from exc + if not isinstance(value, dict): + raise RigGeometryError("rig geometry profile must be an object") + return parse_rig_geometry(value) + + +def parse_rig_geometry(value: Mapping[str, Any]) -> RigGeometry: + """Validate an in-memory v1 profile without inferring physical values.""" + + document = dict(value) + profile_id = document.get("profile_id") + rig_kind = document.get("rig_kind") + qualification = _mapping(document.get("qualification"), "qualification") + frames = _mapping(document.get("coordinate_frames"), "coordinate_frames") + state = qualification.get("state") + reason = qualification.get("reason") + evidence_ids = qualification.get("evidence_sha256") + if ( + document.get("schema_version") != RIG_GEOMETRY_SCHEMA + or not isinstance(profile_id, str) + or _SAFE_ID.fullmatch(profile_id) is None + or rig_kind not in {"portable", "vehicle-mounted"} + or state not in {"unbound", "measured", "qualified"} + or not isinstance(reason, str) + or not reason.strip() + or len(reason) > 240 + or not isinstance(evidence_ids, list) + or any( + not isinstance(item, str) or _SHA256.fullmatch(item) is None + for item in evidence_ids + ) + or len(set(evidence_ids)) != len(evidence_ids) + or document.get("authority") != _AUTHORITY + ): + raise RigGeometryError("rig geometry profile identity is invalid") + + lidar_frame = frames.get("lidar_frame") + body_frame = frames.get("body_frame") + body_from_lidar = frames.get("body_from_lidar") + body = document.get("vehicle_body") + if not isinstance(lidar_frame, str) or _SAFE_ID.fullmatch(lidar_frame) is None: + raise RigGeometryError("rig geometry LiDAR frame is invalid") + + if state == "unbound": + if ( + rig_kind != "portable" + or body_frame is not None + or body_from_lidar is not None + or body is not None + or evidence_ids + ): + raise RigGeometryError("unbound rig geometry must not contain physical values") + else: + if ( + rig_kind != "vehicle-mounted" + or not isinstance(body_frame, str) + or _SAFE_ID.fullmatch(body_frame) is None + ): + raise RigGeometryError("mounted rig body frame is invalid") + _validate_transform( + _mapping(body_from_lidar, "body_from_lidar"), + body_frame=body_frame, + lidar_frame=lidar_frame, + ) + _validate_body(_mapping(body, "vehicle_body"), body_frame=body_frame) + if not evidence_ids: + raise RigGeometryError("measured rig geometry requires measurement evidence") + uncertainty = _mapping( + qualification.get("uncertainty"), + "qualification uncertainty", + ) + _positive_number( + uncertainty.get("translation_1sigma_m"), + "translation uncertainty", + ) + _positive_number( + uncertainty.get("rotation_1sigma_deg"), + "rotation uncertainty", + ) + _positive_number( + uncertainty.get("body_dimension_1sigma_m"), + "body-dimension uncertainty", + ) + if state == "qualified": + qualification_method = qualification.get("qualification_method") + if ( + not isinstance(qualification_method, str) + or not qualification_method.strip() + or len(qualification_method) > 240 + ): + raise RigGeometryError("qualified rig geometry needs a method") + + canonical = _canonical_json(document) + return RigGeometry( + document=document, + identity_sha256=hashlib.sha256(canonical).hexdigest(), + qualification_state=state, + ) + + +def _validate_transform( + value: Mapping[str, Any], + *, + body_frame: str, + lidar_frame: str, +) -> None: + translation = _vector(value.get("translation_m"), 3, "mount translation") + quaternion = _vector(value.get("quaternion_xyzw"), 4, "mount quaternion") + if ( + value.get("from_frame") != lidar_frame + or value.get("to_frame") != body_frame + or not math.isclose(float(np.linalg.norm(quaternion)), 1.0, abs_tol=1e-6) + or np.linalg.norm(translation) > 20.0 + ): + raise RigGeometryError("rig mount transform is invalid") + + +def _validate_body(value: Mapping[str, Any], *, body_frame: str) -> None: + minimum = _vector(value.get("minimum_xyz_m"), 3, "body minimum") + maximum = _vector(value.get("maximum_xyz_m"), 3, "body maximum") + if ( + value.get("frame") != body_frame + or value.get("shape") != "axis-aligned-box" + or np.any(maximum <= minimum) + or np.any(maximum - minimum > 30.0) + ): + raise RigGeometryError("vehicle body envelope is invalid") + + +def _mapping(value: object, label: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise RigGeometryError(f"{label} must be an object") + return value + + +def _vector(value: object, length: int, label: str) -> np.ndarray: + if not isinstance(value, list) or len(value) != length: + raise RigGeometryError(f"{label} is invalid") + array = np.asarray(value, dtype=np.float64) + if not np.isfinite(array).all(): + raise RigGeometryError(f"{label} is invalid") + return array + + +def _positive_number(value: object, label: str) -> float: + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise RigGeometryError(f"{label} is invalid") + number = float(value) + if not math.isfinite(number) or number <= 0.0: + raise RigGeometryError(f"{label} is invalid") + return number + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode() diff --git a/src/k1link/compute/semantic_geometry_fusion.py b/src/k1link/compute/semantic_geometry_fusion.py index 2f3a588..8ffa3c6 100644 --- a/src/k1link/compute/semantic_geometry_fusion.py +++ b/src/k1link/compute/semantic_geometry_fusion.py @@ -138,6 +138,65 @@ class _GeometryClusterSupport: occupied_source_indices: IntArray +@dataclass(frozen=True, slots=True) +class CameraGeometryFrameEvaluation: + """One camera-owned semantic frame with LiDAR-owned metric association.""" + + semantic_observations: tuple[dict[str, object], ...] + geometry_only_occupied: tuple[dict[str, object], ...] + claimed_source_indices: frozenset[int] + + +def evaluate_camera_geometry_frame( + *, + objects: Iterable[Mapping[str, Any]], + projected: ProjectedPointCloud | None, + frame_points_map: FloatArray, + point_class: npt.NDArray[np.uint8], + point_height_m: npt.NDArray[np.float32], + sensor_position_map: FloatArray, + source_available: bool, + surface_valid: bool, + profile: CameraGeometryFusionProfile = DEFAULT_CAMERA_GEOMETRY_FUSION_PROFILE, + include_geometry_only: bool = True, +) -> CameraGeometryFrameEvaluation: + """Apply the canonical E29 association without changing semantic ownership.""" + + supports = tuple( + _semantic_support( + item, + projected=projected, + frame_points_map=frame_points_map, + point_class=point_class, + point_height_m=point_height_m, + source_available=source_available, + surface_valid=surface_valid, + profile=profile, + ) + for item in objects + ) + claimed = frozenset(_claimed_indices(supports)) + geometry = ( + tuple( + _geometry_clusters( + points_map=frame_points_map, + point_class=point_class, + point_height_m=point_height_m, + sensor_position_map=sensor_position_map, + claimed_source_indices=set(claimed), + profile=profile, + ) + ) + if include_geometry_only + else () + ) + return CameraGeometryFrameEvaluation( + semantic_observations=tuple(support.document for support in supports), + geometry_only_occupied=geometry, + claimed_source_indices=claimed, + ) + + def build_camera_geometry_fusion( *, fusion_frames_path: Path, @@ -229,7 +288,6 @@ def build_camera_geometry_fusion( frame_heights = point_height[start:end] source_available = bool(arrays["sample_available"][expected_frame_index]) surface_valid = bool(surface.arrays["frame_valid"][expected_frame_index]) - semantic_supports: list[_SemanticSupport] = [] if source_available and surface_valid: position = arrays["pose_positions_map"][expected_frame_index] orientation = arrays["pose_quaternions_map_from_lidar"][expected_frame_index] @@ -251,40 +309,32 @@ def build_camera_geometry_fusion( else: projected = None - for item in frame["objects"]: - support = _semantic_support( - item, - projected=projected, - frame_points_map=frame_points, - point_class=frame_classes, - point_height_m=frame_heights, - source_available=source_available, - surface_valid=surface_valid, - profile=profile, - ) - semantic_supports.append(support) - semantic_observations += 1 - geometry_status = str(support.document["geometry_status"]) - group = str(support.document["association_group"]) - motion_status = str(support.document["motion_status"]) - status_counts[geometry_status] += 1 - group_status_counts[(group, geometry_status)] += 1 - motion_status_counts[(motion_status, geometry_status)] += 1 - if support.document["semantic_current"] is True: - semantic_current_observations += 1 - - claimed = _claimed_indices(semantic_supports) - geometry_clusters = _geometry_clusters( - points_map=frame_points, + evaluation = evaluate_camera_geometry_frame( + objects=frame["objects"], + projected=projected, + frame_points_map=frame_points, point_class=frame_classes, point_height_m=frame_heights, sensor_position_map=np.asarray( arrays["pose_positions_map"][expected_frame_index], dtype=np.float64, ), - claimed_source_indices=claimed, + source_available=source_available, + surface_valid=surface_valid, profile=profile, ) + for support_document in evaluation.semantic_observations: + semantic_observations += 1 + geometry_status = str(support_document["geometry_status"]) + group = str(support_document["association_group"]) + motion_status = str(support_document["motion_status"]) + status_counts[geometry_status] += 1 + group_status_counts[(group, geometry_status)] += 1 + motion_status_counts[(motion_status, geometry_status)] += 1 + if support_document["semantic_current"] is True: + semantic_current_observations += 1 + + geometry_clusters = list(evaluation.geometry_only_occupied) if geometry_clusters: frames_with_geometry_only += 1 geometry_only_clusters_per_frame.append(float(len(geometry_clusters))) @@ -308,7 +358,7 @@ def build_camera_geometry_fusion( "session_seconds": frame["session_seconds"], "source_available": source_available, "local_surface_valid": surface_valid, - "semantic_observations": [support.document for support in semantic_supports], + "semantic_observations": list(evaluation.semantic_observations), "geometry_only_occupied": geometry_clusters, "policy": { "camera_owns_semantics": True, @@ -741,7 +791,9 @@ def _spatial_cluster( return rows[np.asarray(selected, dtype=np.int64)] -def _projection_profile(source: E10LidarFieldSource) -> Kb4ProjectionProfile: +def projection_profile_from_source(source: E10LidarFieldSource) -> Kb4ProjectionProfile: + """Build the source-bound factory projection used by E29 association.""" + identity_projection = source.identity.get("projection") if not isinstance(identity_projection, Mapping): raise SemanticGeometryFusionError("source projection identity is missing") @@ -771,6 +823,10 @@ def _projection_profile(source: E10LidarFieldSource) -> Kb4ProjectionProfile: ) +def _projection_profile(source: E10LidarFieldSource) -> Kb4ProjectionProfile: + return projection_profile_from_source(source) + + def _fusion_frame( line: str, *, diff --git a/tests/test_e53_camera_first_shadow.py b/tests/test_e53_camera_first_shadow.py new file mode 100644 index 0000000..1e83aae --- /dev/null +++ b/tests/test_e53_camera_first_shadow.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Any + +from k1link.compute.e53_camera_first_shadow import ( + _Profile, + adjacent_family_matches, + select_candidate_detections, +) + + +def _profile() -> _Profile: + return _Profile( + raw={}, + expected_source_pack_id="source", + expected_local_surface_id="surface", + expected_detector_result_id="detector", + expected_baseline_e29_id="baseline", + target_labels=frozenset({"person", "car", "truck"}), + label_groups={ + "person": ("person",), + "vehicle": ("car", "truck"), + }, + minimum_detector_score=0.5, + maximum_bbox_image_fraction=0.25, + adjacent_family_iou=0.25, + baseline_family_iou=0.25, + maximum_latency_p95_ms=50.0, + maximum_rss_growth_mib=512.0, + ) + + +def test_candidate_filter_keeps_camera_semantics_and_rejects_large_box() -> None: + selected, counts = select_candidate_detections( + [ + { + "instance_id": 1, + "label": "person", + "score": 0.99, + "box_xyxy": [100.0, 100.0, 180.0, 300.0], + "mask_pixels": 4000, + }, + { + "instance_id": 2, + "label": "car", + "score": 0.95, + "box_xyxy": [0.0, 0.0, 800.0, 600.0], + "mask_pixels": 480000, + }, + { + "instance_id": 3, + "label": "laptop", + "score": 0.98, + "box_xyxy": [10.0, 10.0, 100.0, 100.0], + }, + ], + width=800, + height=600, + profile=_profile(), + ) + + assert len(selected) == 1 + assert selected[0]["label"] == "person" + assert selected[0]["association_group"] == "person" + assert selected[0]["cuboid_status"] == "camera-candidate-no-cuboid" + assert counts["pathological_large_box"] == 1 + assert counts["non_target"] == 1 + + +def test_adjacent_matching_is_family_aware_and_one_to_one() -> None: + previous: list[dict[str, Any]] = [ + { + "association_group": "vehicle", + "bbox_xyxy": [10.0, 10.0, 110.0, 110.0], + }, + { + "association_group": "person", + "bbox_xyxy": [200.0, 20.0, 240.0, 120.0], + }, + ] + current: list[dict[str, Any]] = [ + { + "association_group": "vehicle", + "bbox_xyxy": [12.0, 12.0, 112.0, 112.0], + }, + { + "association_group": "vehicle", + "bbox_xyxy": [14.0, 14.0, 114.0, 114.0], + }, + { + "association_group": "person", + "bbox_xyxy": [202.0, 22.0, 242.0, 122.0], + }, + ] + + assert adjacent_family_matches(previous, current, minimum_iou=0.25) == 2 diff --git a/tests/test_rig_geometry.py b/tests/test_rig_geometry.py new file mode 100644 index 0000000..7474edf --- /dev/null +++ b/tests/test_rig_geometry.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import pytest + +from k1link.compute.rig_geometry import ( + RIG_GEOMETRY_SCHEMA, + RigGeometryError, + parse_rig_geometry, +) + + +def _unbound() -> dict[str, object]: + return { + "schema_version": RIG_GEOMETRY_SCHEMA, + "profile_id": "portable-k1/unbound-v1", + "rig_kind": "portable", + "qualification": { + "state": "unbound", + "reason": "no measured mount", + "evidence_sha256": [], + }, + "coordinate_frames": { + "lidar_frame": "k1-lidar", + "body_frame": None, + "body_from_lidar": None, + }, + "vehicle_body": None, + "authority": { + "commands_enabled": False, + "navigation_or_safety_accepted": False, + }, + } + + +def test_unbound_portable_rig_fails_closed_without_inventing_geometry() -> None: + geometry = parse_rig_geometry(_unbound()) + + assert geometry.metric_body_geometry_available is False + assert geometry.collision_geometry_qualified is False + assert geometry.collision_contract()["state"] == "unavailable" + assert geometry.collision_contract()["recent_collision_publishable"] is False + + +def test_unbound_rig_rejects_hidden_physical_values() -> None: + value = _unbound() + value["coordinate_frames"] = { + "lidar_frame": "k1-lidar", + "body_frame": "vehicle-body", + "body_from_lidar": None, + } + + with pytest.raises(RigGeometryError, match="must not contain physical values"): + parse_rig_geometry(value) + + +def test_measured_mount_does_not_grant_collision_authority() -> None: + value = _unbound() + value.update( + { + "profile_id": "measured-rig/v1", + "rig_kind": "vehicle-mounted", + "qualification": { + "state": "measured", + "reason": "bench measurement only", + "evidence_sha256": ["a" * 64], + "uncertainty": { + "translation_1sigma_m": 0.01, + "rotation_1sigma_deg": 0.2, + "body_dimension_1sigma_m": 0.01, + }, + }, + "coordinate_frames": { + "lidar_frame": "k1-lidar", + "body_frame": "vehicle-body", + "body_from_lidar": { + "from_frame": "k1-lidar", + "to_frame": "vehicle-body", + "translation_m": [0.0, 0.0, 1.0], + "quaternion_xyzw": [0.0, 0.0, 0.0, 1.0], + }, + }, + "vehicle_body": { + "frame": "vehicle-body", + "shape": "axis-aligned-box", + "minimum_xyz_m": [-1.0, -0.5, -0.2], + "maximum_xyz_m": [1.0, 0.5, 1.2], + }, + } + ) + + geometry = parse_rig_geometry(value) + + assert geometry.metric_body_geometry_available is True + assert geometry.collision_geometry_qualified is False + assert geometry.collision_contract()["state"] == "unavailable"