diff --git a/experiments/perception/LAB_E52_REPORT_2026-07-30.md b/experiments/perception/LAB_E52_REPORT_2026-07-30.md new file mode 100644 index 0000000..af802f1 --- /dev/null +++ b/experiments/perception/LAB_E52_REPORT_2026-07-30.md @@ -0,0 +1,119 @@ +# LAB E52 · восстановление camera-first семантики + +Дата: 2026-07-30 + +Статус: принят инженерный кандидат; runtime-модель не изменена и не +авторизована + +## Причина + +Операторская проверка E40 показала, что интерфейс выдавал фиксированную +`stratum-policy` за уверенность модели. В частности, +`geometry-only → occupied-environment` отображалось как решение модели с +уверенностью 100%. Из-за этого человек, тележка или часть фасада могли +выглядеть как ошибочно распознанный «фон/шум». + +Это не было результатом рабочего camera-first контура. E29 по-прежнему +реализует исходное разделение ответственности: + +- камера владеет классом и идентичностью объекта; +- LiDAR проверяет геометрическую поддержку и вычисляет дальность; +- LiDAR-кластер без camera-детекта не получает семантический класс. + +E40 являлся историческим видимым мета-оценщиком и повторно решал presence по +фиксированным правилам поверх E29. E52 не переносит эти правила в runtime. + +## Неизменяемые входы + +- E29/E30: + `e30-materialization-841af926d8d28ab93538c46d8f31278a2234c4d1c12c7dc4dc296b249d59735a`; +- E40: + `e40-perception-product-gate-e96eec9fd68c3ffaaee898d46285dd329191267200011680f084c75095b92e9a`; +- полная операторская ревизия E40: 23 из 23 случаев; +- camera candidate: + `result-f4cebdea8a82698a5b8a65d2c3fbdb0428b88b9dc49fe45f8cb37d740ed83d02`; +- camera source: + `recorded-camera-602ac89026ed12978619801d`, 4 489 кадров; +- Mask R-CNN: + `torchvision/maskrcnn_resnet50_fpn_v2`, COCO, threshold 0.5, + checkpoint SHA-256 + `73cbd0190fcbe3ba339921fbce2c3a0b6bb9126c9a133c85e43a2a8e060a109e`. + +Все входные SHA-256 проверены до и после E52. Исходные артефакты не +перезаписывались. + +## Метод + +E52 использует уже рассчитанные camera-детекты и выполняет только лёгкое +source-bound сопоставление: + +1. берёт 20 случаев, где оператор подтвердил ошибку представления E40; +2. отбрасывает camera-боксы больше 25% изображения как патологические; +3. для `geometry-only` требует не меньше четырёх выбранных LiDAR-точек; +4. camera-бокс должен покрывать не меньше 70% точек выбранного кластера; +5. класс берётся только из camera-детекта; +6. дальность вычисляется как медиана глубин связанных LiDAR-точек; +7. нерешённый кластер остаётся геометрией без класса. + +Для исходных camera-observation используется camera-box IoU не ниже 0.25. +Группы `car/bus/truck` сравниваются как транспорт, но исходная camera-метка +сохраняется в evidence. + +## Результат + +```text +e52-camera-first-restoration-37ea5269a7f49fc84cbe2c394682f30ccc0d06b781d7d734c2f5420ba764b09f +``` + +| Измерение | Результат | +| --- | ---: | +| Подтверждённые оператором случаи | 20 | +| Подтверждённые `geometry-only` | 8 | +| Восстановлена camera-семантика + LiDAR-дальность | 3 | +| Намеренно оставлены нерешёнными | 5 | +| Pathological full-frame box принят | 0 | + +Восстановленные случаи: + +| Кадр | Camera-класс | Camera score | Покрытие LiDAR | LiDAR range | +| ---: | --- | ---: | ---: | ---: | +| 167 | truck | 0.979998 | 15/15 | 8.038404 m | +| 259 | person | 0.999834 | 49/50 | 0.512825 m | +| 3181 | car | 0.878104 | 4/4 | 0.301412 m | + +Кадр 259 подтверждает исходную проблему: YOLOX не дал семантического +наблюдения, однако уже рассчитанный camera candidate уверенно выделил человека, +а LiDAR обеспечил независимую метрическую дальность. + +Пять случаев не были автоматически «исправлены»: + +- кадры 1270, 1878 и 2535 имеют только 1–2 выбранные LiDAR-точки; +- на кадрах 267 и 1080 пересечение давал патологический бокс почти на весь + кадр; +- E52 не повышает такие случаи до объектов и не выдаёт отсутствие точек за + свободное пространство. + +## Решение + +Camera-first принцип восстановлен в отдельном инженерном кандидате: + +```text +camera detection + → проверка LiDAR-точек внутри camera bbox + → camera semantic + LiDAR range +``` + +Runtime E29, sealed E37–E40, K1-команды и persistent map не изменены. Общий +Mask R-CNN пока не принят как production detector. + +Следующий gate — прогнать уже рассчитанные детекты через неизменённую +ассоциацию E29 в полном source-bound shadow-контуре и измерить: + +- восстановление camera-семантики; +- отбрасывание больших ложных боксов; +- временную стабильность; +- задержку и ресурсную стоимость; +- отсутствие регрессии текущих корректных camera-first объектов. + +Только после отдельного явного принятия этого gate кандидат может заменить или +дополнить YOLOX в runtime. diff --git a/experiments/perception/e52_camera_first_restoration_profile.json b/experiments/perception/e52_camera_first_restoration_profile.json new file mode 100644 index 0000000..15eae34 --- /dev/null +++ b/experiments/perception/e52_camera_first_restoration_profile.json @@ -0,0 +1,35 @@ +{ + "schema_version": "missioncore.e52-camera-first-restoration-profile/v1", + "profile_id": "e52-ravnoves00-camera-first-restoration/v1", + "expected_e30_materialization_id": "e30-materialization-841af926d8d28ab93538c46d8f31278a2234c4d1c12c7dc4dc296b249d59735a", + "expected_e40_result_id": "e40-perception-product-gate-e96eec9fd68c3ffaaee898d46285dd329191267200011680f084c75095b92e9a", + "expected_detector_result_id": "result-f4cebdea8a82698a5b8a65d2c3fbdb0428b88b9dc49fe45f8cb37d740ed83d02", + "expected_camera_job_id": "recorded-camera-602ac89026ed12978619801d", + "target_labels": [ + "person", + "bicycle", + "car", + "motorcycle", + "bus", + "truck" + ], + "label_groups": { + "person": [ + "person" + ], + "two-wheel": [ + "bicycle", + "motorcycle" + ], + "vehicle": [ + "car", + "bus", + "truck" + ] + }, + "minimum_detector_score": 0.5, + "maximum_bbox_image_fraction": 0.25, + "minimum_selected_lidar_points": 4, + "minimum_selected_lidar_coverage": 0.7, + "minimum_original_bbox_iou": 0.25 +} diff --git a/experiments/perception/run_e52_camera_first_restoration.py b/experiments/perception/run_e52_camera_first_restoration.py new file mode 100644 index 0000000..2cfeda5 --- /dev/null +++ b/experiments/perception/run_e52_camera_first_restoration.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Build the immutable E52 camera-first restoration candidate.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from k1link.compute.e52_camera_first_restoration import ( + build_e52_camera_first_restoration, +) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--e30-materialization", type=Path, required=True) + parser.add_argument("--e40-result", type=Path, required=True) + parser.add_argument("--operator-review", type=Path, required=True) + parser.add_argument("--detector-result", 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_e52_camera_first_restoration( + e30_materialization_root=args.e30_materialization, + e40_result_root=args.e40_result, + operator_review_path=args.operator_review, + detector_result_root=args.detector_result, + profile_path=args.profile, + output_root=args.output_root, + ) + print( + json.dumps( + { + "result_id": result.result_id, + "result_root": str(result.result_root), + "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/__init__.py b/src/k1link/compute/__init__.py index 92c2ab9..22edd2a 100644 --- a/src/k1link/compute/__init__.py +++ b/src/k1link/compute/__init__.py @@ -52,6 +52,17 @@ from .e51_motion_semantic_qualification import ( derive_motion_semantic_signal, read_e51_motion_semantic_qualification, ) +from .e52_camera_first_restoration import ( + E52_CASE_SCHEMA, + E52_PROFILE_SCHEMA, + E52_REPORT_SCHEMA, + E52_RESULT_SCHEMA, + E52CameraFirstRestoration, + E52CameraFirstRestorationError, + build_e52_camera_first_restoration, + evaluate_camera_first_case, + read_e52_camera_first_restoration, +) from .evaluation_pack import ( ANNOTATION_CONTRACT_SCHEMA, EVALUATION_PACK_SCHEMA, @@ -419,6 +430,15 @@ __all__ = [ "build_e51_motion_semantic_qualification", "derive_motion_semantic_signal", "read_e51_motion_semantic_qualification", + "E52_CASE_SCHEMA", + "E52_PROFILE_SCHEMA", + "E52_REPORT_SCHEMA", + "E52_RESULT_SCHEMA", + "E52CameraFirstRestoration", + "E52CameraFirstRestorationError", + "build_e52_camera_first_restoration", + "evaluate_camera_first_case", + "read_e52_camera_first_restoration", "build_lidar_ground_annotation_template", "build_lidar_ground_benchmark", "build_k1_local_surface", diff --git a/src/k1link/compute/e52_camera_first_restoration.py b/src/k1link/compute/e52_camera_first_restoration.py new file mode 100644 index 0000000..a7806d1 --- /dev/null +++ b/src/k1link/compute/e52_camera_first_restoration.py @@ -0,0 +1,1050 @@ +"""E52 camera-first semantic restoration over immutable E29/E30 evidence.""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import re +import shutil +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Final + +import numpy as np + +from .e40_perception_product_gate import ( + E40_PREDICTIONS_NAME, + read_e40_perception_product_gate, +) + +E52_PROFILE_SCHEMA: Final = "missioncore.e52-camera-first-restoration-profile/v1" +E52_RESULT_SCHEMA: Final = "missioncore.e52-camera-first-restoration/v1" +E52_CASE_SCHEMA: Final = "missioncore.e52-camera-first-restoration-case/v1" +E52_REPORT_SCHEMA: Final = "missioncore.e52-camera-first-restoration-report/v1" +E52_CASES_NAME: Final = "camera-first-restoration-cases.jsonl" +E52_REPORT_NAME: Final = "camera-first-restoration-report.json" +E52_MANIFEST_NAME: Final = "manifest.json" + +_E30_SCHEMA: Final = "missioncore.e30-evidence-materialization/v2" +_E40_REVIEW_SCHEMA: Final = "missioncore.e40-operator-review/v1" +_E40_REVIEW_PROTOCOL: Final = "sealed-error-adjudication/v1" +_DETECTOR_SCHEMA: Final = "missioncore.recorded-perception-result/v2" +_DETECTOR_FRAME_SCHEMA: Final = "missioncore.panoptic-frame/v1" +_SHA256 = re.compile(r"^[a-f0-9]{64}$") +_AUTHORITY: Final = { + "commands_enabled": False, + "navigation_or_safety_accepted": False, +} + + +class E52CameraFirstRestorationError(RuntimeError): + """An E52 input, profile, association, or immutable result is invalid.""" + + +@dataclass(frozen=True, slots=True) +class E52CameraFirstRestoration: + """One immutable source-scoped camera-first restoration candidate.""" + + result_id: str + result_root: Path + manifest: dict[str, Any] + report: dict[str, Any] + cases: tuple[dict[str, Any], ...] + + +def build_e52_camera_first_restoration( + *, + e30_materialization_root: Path, + e40_result_root: Path, + operator_review_path: Path, + detector_result_root: Path, + profile_path: Path, + output_root: Path, +) -> E52CameraFirstRestoration: + """Build a bounded candidate without changing E29, E30, E40, or detector data.""" + + profile = _read_profile(profile_path) + e30_root, e30_manifest, e30_index = _read_e30(e30_materialization_root) + e40 = read_e40_perception_product_gate(e40_result_root) + e40_identity = _object(e40.manifest.get("identity"), "E40 identity") + e40_source = _object(e40_identity.get("source"), "E40 source") + if ( + e40.result_id != profile["expected_e40_result_id"] + or e30_root.name != profile["expected_e30_materialization_id"] + or e40_source.get("materialization_id") != e30_root.name + or e40_source.get("materialization_index_sha256") != _sha256(e30_index) + ): + raise E52CameraFirstRestorationError("E52 E30/E40 binding changed") + + review_path = _regular_file( + operator_review_path.expanduser().absolute().parent.resolve(strict=True), + operator_review_path.name, + ) + review = _read_json(review_path) + mismatch_rows = _e40_mismatch_rows(e40.result_root / E40_PREDICTIONS_NAME) + confirmed_ids = _confirmed_review_ids( + review=review, + e40_result_id=e40.result_id, + e30_result_id=e30_root.name, + mismatch_rows=mismatch_rows, + ) + materialized = _materialized_rows(e30_index, confirmed_ids) + + detector_root, detector_result, detector_frames_path = _read_detector(detector_result_root) + detector_identity = _object( + detector_result.get("identity"), + "detector identity", + ) + e30_identity = _object(e30_manifest.get("identity"), "E30 identity") + camera_evidence = _object( + e30_identity.get("camera_evidence"), + "E30 camera evidence", + ) + detector_calibration = _object( + detector_identity.get("calibration"), + "detector calibration", + ) + if ( + detector_root.name != profile["expected_detector_result_id"] + or detector_identity.get("job_id") != profile["expected_camera_job_id"] + or detector_identity.get("job_id") != camera_evidence.get("job_id") + or detector_identity.get("input_sha256") != camera_evidence.get("input_sha256") + or detector_calibration.get("camera_slot") != e30_identity["projection"]["calibration_slot"] + ): + raise E52CameraFirstRestorationError("E52 detector camera source changed") + source_frame_indices = { + _integer( + row["evidence_binding"].get("source_frame_index"), + "E30 source frame index", + ) + for row in materialized.values() + } + detector_frames = _selected_detector_frames( + detector_frames_path, + source_frame_indices, + ) + + input_hashes_before = { + "e30_manifest": _sha256(e30_root / "manifest.json"), + "e30_index": _sha256(e30_index), + "e40_manifest": _sha256(e40.result_root / "manifest.json"), + "e40_predictions": _sha256(e40.result_root / E40_PREDICTIONS_NAME), + "operator_review": _sha256(review_path), + "detector_result": _sha256(detector_root / "result.json"), + "detector_frames": _sha256(detector_frames_path), + "profile": _sha256(profile_path.resolve(strict=True)), + } + cases: list[dict[str, Any]] = [] + for item_id in sorted( + confirmed_ids, + key=lambda value: int(mismatch_rows[value]["sequence"]), + ): + item = materialized[item_id] + artifact = _object(item.get("artifact"), "E30 item artifact") + arrays_path = _verified_relative_artifact( + e30_root, + artifact, + expected_role=None, + ) + with np.load(arrays_path, allow_pickle=False) as arrays: + cases.append( + evaluate_camera_first_case( + item=item, + e40_case=mismatch_rows[item_id], + detector_frame=detector_frames[ + int(item["evidence_binding"]["source_frame_index"]) + ], + projected_pixels_xy=np.asarray( + arrays["projected_pixels_xy"], + dtype=np.float64, + ), + projected_depth_m=np.asarray( + arrays["projected_depth_m"], + dtype=np.float64, + ), + projected_selected_mask=np.asarray( + arrays["projected_selected_mask"], + dtype=np.uint8, + ), + profile=profile, + ) + ) + + case_payload = b"".join(_canonical_json(row) + b"\n" for row in cases) + input_hashes_after = { + "e30_manifest": _sha256(e30_root / "manifest.json"), + "e30_index": _sha256(e30_index), + "e40_manifest": _sha256(e40.result_root / "manifest.json"), + "e40_predictions": _sha256(e40.result_root / E40_PREDICTIONS_NAME), + "operator_review": _sha256(review_path), + "detector_result": _sha256(detector_root / "result.json"), + "detector_frames": _sha256(detector_frames_path), + "profile": _sha256(profile_path.resolve(strict=True)), + } + upstream_unchanged = input_hashes_after == input_hashes_before + if not upstream_unchanged: + raise E52CameraFirstRestorationError("E52 input changed during build") + + identity = { + "schema_version": E52_RESULT_SCHEMA, + "profile": profile, + "inputs": { + "e30_materialization_id": e30_root.name, + "e40_result_id": e40.result_id, + "operator_review_id": review["review_id"], + "detector_result_id": detector_root.name, + "camera_job_id": detector_identity["job_id"], + "hashes": input_hashes_before, + }, + "case_count": len(cases), + "cases_sha256": hashlib.sha256(case_payload).hexdigest(), + "producer_sha256": _sha256(Path(__file__).resolve(strict=True)), + "policy": { + "semantic_owner": "camera", + "metric_geometry_owner": "lidar", + "lidar_native_semantic_promotion": False, + "generic_detector_runtime_promotion": False, + "upstream_artifacts_mutated": False, + }, + "authority": _AUTHORITY, + } + identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest() + result_id = f"e52-camera-first-restoration-{identity_sha256}" + destination = output_root.expanduser().absolute() / result_id + if destination.exists(): + return read_e52_camera_first_restoration(destination) + + report = _build_report( + result_id=result_id, + identity_sha256=identity_sha256, + cases=cases, + upstream_unchanged=upstream_unchanged, + ) + 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: + (staging / E52_CASES_NAME).write_bytes(case_payload) + _write_json(staging / E52_REPORT_NAME, report) + manifest = { + "schema_version": E52_RESULT_SCHEMA, + "result_id": result_id, + "identity_sha256": identity_sha256, + "identity": identity, + "created_at_utc": _utc_now(), + "acceptance_state": "development-candidate-no-runtime-promotion", + "classification": "private-diagnostic-derivative", + "ground_truth": False, + "artifacts": [ + _artifact(staging / E52_CASES_NAME, "restoration-cases"), + _artifact(staging / E52_REPORT_NAME, "restoration-report"), + ], + "authority": _AUTHORITY, + } + _write_json(staging / E52_MANIFEST_NAME, manifest) + os.replace(staging, destination) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + return read_e52_camera_first_restoration(destination) + + +def evaluate_camera_first_case( + *, + item: dict[str, Any], + e40_case: dict[str, Any], + detector_frame: dict[str, Any], + projected_pixels_xy: np.ndarray, + projected_depth_m: np.ndarray, + projected_selected_mask: np.ndarray, + profile: dict[str, Any], +) -> dict[str, Any]: + """Evaluate one candidate while preserving camera/LiDAR ownership.""" + + pixels = np.asarray(projected_pixels_xy, dtype=np.float64) + depths = np.asarray(projected_depth_m, dtype=np.float64) + selected_mask = np.asarray(projected_selected_mask, dtype=np.uint8) + if ( + pixels.ndim != 2 + or pixels.shape[1] != 2 + or depths.shape != (pixels.shape[0],) + or selected_mask.shape != (pixels.shape[0],) + or not np.isfinite(pixels).all() + ): + raise E52CameraFirstRestorationError("E52 projected LiDAR arrays are invalid") + selected = selected_mask.astype(bool) + selected_pixels = pixels[selected] + selected_depths = depths[selected] + frame_index = _integer( + item["evidence_binding"].get("source_frame_index"), + "E52 source frame index", + ) + if ( + detector_frame.get("schema_version") != _DETECTOR_FRAME_SCHEMA + or detector_frame.get("frame_index") != frame_index + ): + raise E52CameraFirstRestorationError("E52 detector frame is not source aligned") + + materialization = _object(item.get("materialization"), "E30 materialization") + width = _integer(materialization.get("projection_width"), "projection width") + height = _integer( + materialization.get("projection_height"), + "projection height", + ) + stratum = _string(item.get("stratum"), "E30 stratum") + snapshot = _object(item.get("e29_snapshot"), "E29 snapshot") + original_bbox = _bbox(snapshot.get("bbox_xyxy")) + candidates, rejected_large = _eligible_detections( + detector_frame.get("instances"), + width=width, + height=height, + profile=profile, + ) + + if stratum == "geometry-only": + selected_candidate = _select_geometry_candidate( + candidates, + selected_pixels=selected_pixels, + selected_depths=selected_depths, + profile=profile, + ) + else: + selected_candidate = _select_semantic_candidate( + candidates, + original_bbox=original_bbox, + original_label=snapshot.get("label"), + profile=profile, + ) + + baseline_label = snapshot.get("label") + baseline_range = snapshot.get("range_m") + state, semantic_label, range_m, publishable = _restoration_state( + stratum=stratum, + snapshot=snapshot, + selected_candidate=selected_candidate, + ) + mismatch_dimensions = [ + dimension + for dimension in ("presence", "geometry_association", "freshness") + if e40_case["prediction"][dimension] != e40_case["reference"][dimension] + ] + return { + "schema_version": E52_CASE_SCHEMA, + "item_id": item["item_id"], + "sequence": e40_case["sequence"], + "source_frame_index": frame_index, + "source_stratum": stratum, + "operator_verdict": "confirmed-error", + "e40_context": { + "mismatched_dimensions": mismatch_dimensions, + "prediction": e40_case["prediction"], + "reference": e40_case["reference"], + "role": "historical-visible-evaluator-only", + }, + "baseline_e29": { + "semantic_owner": "camera", + "detector": "YOLOX-S/COCO-80", + "label": baseline_label, + "score": snapshot.get("score"), + "bbox_xyxy": original_bbox, + "geometry_status": snapshot.get("geometry_status"), + "range_m": baseline_range, + }, + "candidate_camera": { + "detector": "MaskRCNN-ResNet50-FPN-V2/COCO", + "eligible_detection_count": len(candidates), + "rejected_pathological_large_box_count": rejected_large, + "selected": selected_candidate, + }, + "lidar_association": { + "owner": "lidar", + "selected_point_count": int(selected_pixels.shape[0]), + "range_rule": "median-associated-selected-lidar-depth", + "absence_of_points_means_free": False, + }, + "restoration": { + "state": state, + "semantic_owner": "camera", + "semantic_label": semantic_label, + "metric_geometry_owner": "lidar", + "range_m": range_m, + "runtime_publishable": publishable, + "requires_shadow_acceptance": True, + }, + "authority": _AUTHORITY, + } + + +def read_e52_camera_first_restoration( + root: Path, +) -> E52CameraFirstRestoration: + """Read and fully verify one immutable E52 result.""" + + source = root.expanduser().absolute() + if source.is_symlink(): + raise E52CameraFirstRestorationError("E52 result root is a symlink") + resolved = source.resolve(strict=True) + manifest = _read_json(resolved / E52_MANIFEST_NAME) + identity = _object(manifest.get("identity"), "E52 identity") + identity_sha256 = manifest.get("identity_sha256") + if ( + manifest.get("schema_version") != E52_RESULT_SCHEMA + or identity.get("schema_version") != E52_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"e52-camera-first-restoration-{identity_sha256}" + or manifest.get("result_id") != resolved.name + or manifest.get("acceptance_state") != "development-candidate-no-runtime-promotion" + or manifest.get("authority") != _AUTHORITY + ): + raise E52CameraFirstRestorationError("E52 result identity is invalid") + artifacts = _artifact_map(resolved, manifest.get("artifacts")) + cases_payload = artifacts["restoration-cases"].read_bytes() + if hashlib.sha256(cases_payload).hexdigest() != identity.get("cases_sha256"): + raise E52CameraFirstRestorationError("E52 case content changed") + cases = tuple( + _object(json.loads(line), "E52 case") + for line in cases_payload.decode("utf-8").splitlines() + if line + ) + if len(cases) != identity.get("case_count") or any( + row.get("schema_version") != E52_CASE_SCHEMA + or row.get("authority") != _AUTHORITY + or row.get("restoration", {}).get("runtime_publishable") is not False + for row in cases + ): + raise E52CameraFirstRestorationError("E52 case set is invalid") + report = _read_json(artifacts["restoration-report"]) + if ( + report.get("schema_version") != E52_REPORT_SCHEMA + or report.get("result_id") != resolved.name + or report.get("identity_sha256") != identity_sha256 + or report.get("metrics", {}).get("confirmed_case_count") != len(cases) + or report.get("decision", {}).get("runtime_promoted") is not False + or report.get("authority") != _AUTHORITY + ): + raise E52CameraFirstRestorationError("E52 report is invalid") + return E52CameraFirstRestoration( + result_id=resolved.name, + result_root=resolved, + manifest=manifest, + report=report, + cases=cases, + ) + + +def _select_geometry_candidate( + candidates: list[dict[str, Any]], + *, + selected_pixels: np.ndarray, + selected_depths: np.ndarray, + profile: dict[str, Any], +) -> dict[str, Any] | None: + minimum_points = int(profile["minimum_selected_lidar_points"]) + if selected_pixels.shape[0] < minimum_points: + return None + scored: list[tuple[float, float, float, dict[str, Any], np.ndarray]] = [] + for candidate in candidates: + bbox = candidate["bbox_xyxy"] + inside = ( + (selected_pixels[:, 0] >= bbox[0]) + & (selected_pixels[:, 0] <= bbox[2]) + & (selected_pixels[:, 1] >= bbox[1]) + & (selected_pixels[:, 1] <= bbox[3]) + ) + coverage = float(np.mean(inside)) + scored.append( + ( + coverage, + float(candidate["score"]), + -float(candidate["bbox_image_fraction"]), + candidate, + inside, + ) + ) + if not scored: + return None + coverage, _, _, candidate, inside = max(scored, key=lambda value: value[:3]) + if coverage < float(profile["minimum_selected_lidar_coverage"]): + return None + associated_depths = selected_depths[inside] + finite_depths = associated_depths[np.isfinite(associated_depths) & (associated_depths > 0.0)] + if finite_depths.size < minimum_points: + return None + return { + **candidate, + "association": { + "basis": "selected-lidar-points-inside-camera-box", + "selected_point_count": int(selected_pixels.shape[0]), + "associated_point_count": int(np.count_nonzero(inside)), + "selected_point_coverage": round(coverage, 6), + "range_m": round(float(np.median(finite_depths)), 6), + }, + } + + +def _select_semantic_candidate( + candidates: list[dict[str, Any]], + *, + original_bbox: list[float] | None, + original_label: object, + profile: dict[str, Any], +) -> dict[str, Any] | None: + if original_bbox is None: + return None + scored = [ + ( + _box_iou(original_bbox, candidate["bbox_xyxy"]), + float(candidate["score"]), + candidate, + ) + for candidate in candidates + ] + if not scored: + return None + iou, _, candidate = max(scored, key=lambda value: value[:2]) + if iou < float(profile["minimum_original_bbox_iou"]): + return None + original_family = _label_family(original_label, profile) + candidate_family = _label_family(candidate["label"], profile) + return { + **candidate, + "association": { + "basis": "camera-box-iou", + "original_bbox_iou": round(iou, 6), + "semantic_family_match": ( + original_family is not None and original_family == candidate_family + ), + }, + } + + +def _eligible_detections( + value: object, + *, + width: int, + height: int, + profile: dict[str, Any], +) -> tuple[list[dict[str, Any]], int]: + if not isinstance(value, list): + raise E52CameraFirstRestorationError("E52 detector instances are invalid") + target_labels = set(profile["target_labels"]) + minimum_score = float(profile["minimum_detector_score"]) + maximum_fraction = float(profile["maximum_bbox_image_fraction"]) + eligible: list[dict[str, Any]] = [] + rejected_large = 0 + for row in value: + if not isinstance(row, dict) or row.get("label") not in target_labels: + continue + score = _number(row.get("score"), "detector score") + bbox = _bbox(row.get("box_xyxy")) + if bbox is None or score < minimum_score: + continue + fraction = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) / float(width * height) + if fraction > maximum_fraction: + rejected_large += 1 + continue + eligible.append( + { + "label": row["label"], + "score": round(score, 6), + "bbox_xyxy": bbox, + "bbox_image_fraction": round(fraction, 6), + "instance_id": row.get("instance_id"), + } + ) + return eligible, rejected_large + + +def _restoration_state( + *, + stratum: str, + snapshot: dict[str, Any], + selected_candidate: dict[str, Any] | None, +) -> tuple[str, str | None, float | None, bool]: + if stratum == "geometry-only": + if selected_candidate is None: + return "unresolved-no-camera-semantic", None, None, False + return ( + "camera-semantic-restored-with-lidar-range", + str(selected_candidate["label"]), + float(selected_candidate["association"]["range_m"]), + False, + ) + if stratum == "unknown": + return "unresolved-stale-camera-observation", None, None, False + baseline_label = snapshot.get("label") + if selected_candidate is None: + return ( + "baseline-camera-semantic-not-corroborated", + str(baseline_label) if isinstance(baseline_label, str) else None, + None, + False, + ) + family_match = selected_candidate["association"].get("semantic_family_match") + if family_match is not True: + return ( + "candidate-camera-semantic-disagrees", + str(selected_candidate["label"]), + None, + False, + ) + range_value = snapshot.get("range_m") if stratum == "agree" else None + return ( + ( + "camera-semantic-corroborated-with-lidar-range" + if range_value is not None + else "camera-semantic-corroborated-no-lidar-range" + ), + str(selected_candidate["label"]), + float(range_value) if range_value is not None else None, + False, + ) + + +def _build_report( + *, + result_id: str, + identity_sha256: str, + cases: list[dict[str, Any]], + upstream_unchanged: bool, +) -> dict[str, Any]: + geometry_cases = [row for row in cases if row["source_stratum"] == "geometry-only"] + recovered = [ + row + for row in geometry_cases + if row["restoration"]["state"] == "camera-semantic-restored-with-lidar-range" + ] + states: dict[str, int] = {} + labels: dict[str, int] = {} + for row in cases: + state = str(row["restoration"]["state"]) + states[state] = states.get(state, 0) + 1 + label = row["restoration"].get("semantic_label") + if isinstance(label, str): + labels[label] = labels.get(label, 0) + 1 + return { + "schema_version": E52_REPORT_SCHEMA, + "result_id": result_id, + "identity_sha256": identity_sha256, + "status": "completed-camera-first-restoration-development-candidate", + "metrics": { + "confirmed_case_count": len(cases), + "geometry_only_confirmed_count": len(geometry_cases), + "geometry_only_recovered_count": len(recovered), + "geometry_only_unresolved_count": len(geometry_cases) - len(recovered), + "restoration_states": states, + "candidate_semantic_labels": labels, + }, + "architecture": { + "semantic_owner": "camera", + "metric_geometry_owner": "lidar", + "association_rule": ( + "camera box first; selected occupied LiDAR support and median depth second" + ), + "lidar_native_semantic_promotion": False, + "absence_of_points_means_free": False, + }, + "acceptance": { + "upstream_unchanged": upstream_unchanged, + "source_scoped_operator_feedback_only": True, + "independent_ground_truth": False, + }, + "decision": { + "camera_first_architecture_restored_in_candidate": True, + "runtime_promoted": False, + "generic_detector_accepted": False, + "next_gate": ( + "run the candidate detector through the unchanged E29 " + "association in shadow over the complete recording; measure " + "semantic recovery, large-box rejection, temporal stability, " + "and source-bound latency before explicit runtime promotion" + ), + }, + "limitations": [ + ( + "operator verdicts identify E40 presentation errors; they are " + "source-scoped development feedback, not independent truth" + ), + ( + "Mask R-CNN is a generic COCO candidate and is not accepted " + "as the operational camera detector by this result" + ), + ( + "unresolved geometry-only clusters remain geometry without a " + "semantic label; E52 never invents a LiDAR-native class" + ), + ], + "authority": _AUTHORITY, + } + + +def _read_profile(path: Path) -> dict[str, Any]: + value = _read_json(path.resolve(strict=True)) + required_strings = ( + "profile_id", + "expected_e30_materialization_id", + "expected_e40_result_id", + "expected_detector_result_id", + "expected_camera_job_id", + ) + if ( + value.get("schema_version") != E52_PROFILE_SCHEMA + or any(not isinstance(value.get(key), str) for key in required_strings) + or not isinstance(value.get("target_labels"), list) + or not value["target_labels"] + or any(not isinstance(label, str) for label in value["target_labels"]) + or not isinstance(value.get("label_groups"), dict) + ): + raise E52CameraFirstRestorationError("E52 profile is invalid") + for key in ( + "minimum_detector_score", + "maximum_bbox_image_fraction", + "minimum_selected_lidar_coverage", + "minimum_original_bbox_iou", + ): + number = _number(value.get(key), f"profile {key}") + if not 0.0 <= number <= 1.0: + raise E52CameraFirstRestorationError(f"E52 profile {key} is outside [0, 1]") + if ( + _integer( + value.get("minimum_selected_lidar_points"), + "minimum selected LiDAR points", + ) + < 1 + ): + raise E52CameraFirstRestorationError("E52 minimum selected LiDAR points is invalid") + return value + + +def _read_e30(root: Path) -> tuple[Path, dict[str, Any], Path]: + source = root.expanduser().absolute() + if source.is_symlink(): + raise E52CameraFirstRestorationError("E30 root is a symlink") + resolved = source.resolve(strict=True) + manifest = _read_json(resolved / "manifest.json") + identity = _object(manifest.get("identity"), "E30 identity") + identity_sha256 = manifest.get("identity_sha256") + if ( + manifest.get("schema_version") != _E30_SCHEMA + or identity.get("schema_version") != _E30_SCHEMA + or not isinstance(identity_sha256, str) + or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256 + or resolved.name != f"e30-materialization-{identity_sha256}" + or manifest.get("result_id") != resolved.name + or manifest.get("authority") != _AUTHORITY + ): + raise E52CameraFirstRestorationError("E30 identity is invalid") + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list) or len(artifacts) != 1: + raise E52CameraFirstRestorationError("E30 artifact set is invalid") + index = _verified_relative_artifact( + resolved, + _object(artifacts[0], "E30 index artifact"), + expected_role="materialized-items", + ) + return resolved, manifest, index + + +def _read_detector( + root: Path, +) -> tuple[Path, dict[str, Any], Path]: + source = root.expanduser().absolute() + if source.is_symlink(): + raise E52CameraFirstRestorationError("detector root is a symlink") + resolved = source.resolve(strict=True) + result = _read_json(resolved / "result.json") + if ( + result.get("schema_version") != _DETECTOR_SCHEMA + or result.get("result_id") != resolved.name + or not isinstance(result.get("identity"), dict) + ): + raise E52CameraFirstRestorationError("detector identity is invalid") + artifacts = result.get("artifacts") + if not isinstance(artifacts, list): + raise E52CameraFirstRestorationError("detector artifacts are invalid") + matches = [ + _object(row, "detector artifact") + for row in artifacts + if isinstance(row, dict) and row.get("path") == "frames.jsonl" + ] + if len(matches) != 1: + raise E52CameraFirstRestorationError("detector frame artifact is unavailable") + frames = _verified_relative_artifact( + resolved, + matches[0], + expected_role=None, + ) + return resolved, result, frames + + +def _confirmed_review_ids( + *, + review: dict[str, Any], + e40_result_id: str, + e30_result_id: str, + mismatch_rows: dict[str, dict[str, Any]], +) -> set[str]: + source = _object(review.get("source"), "operator review source") + decisions = review.get("decisions") + if ( + review.get("schema_version") != _E40_REVIEW_SCHEMA + or review.get("protocol") != _E40_REVIEW_PROTOCOL + or source.get("result_id") != e40_result_id + or source.get("materialization_id") != e30_result_id + or source.get("item_count") != len(mismatch_rows) + or not isinstance(decisions, list) + or review.get("reviewed_item_count") != len(mismatch_rows) + or review.get("remaining_item_count") != 0 + or len(decisions) != len(mismatch_rows) + ): + raise E52CameraFirstRestorationError("operator review binding is invalid") + verdicts: dict[str, str] = {} + for row in decisions: + if not isinstance(row, dict): + raise E52CameraFirstRestorationError("operator review decision is invalid") + item_id = row.get("item_id") + verdict = row.get("verdict") + if ( + not isinstance(item_id, str) + or item_id in verdicts + or item_id not in mismatch_rows + or verdict not in {"confirmed-error", "rejected-error"} + ): + raise E52CameraFirstRestorationError("operator review decision set changed") + verdicts[item_id] = verdict + if set(verdicts) != set(mismatch_rows): + raise E52CameraFirstRestorationError("operator review coverage changed") + confirmed = {item_id for item_id, verdict in verdicts.items() if verdict == "confirmed-error"} + if not confirmed: + raise E52CameraFirstRestorationError("operator review has no confirmed cases") + return confirmed + + +def _e40_mismatch_rows(path: Path) -> dict[str, dict[str, Any]]: + rows: dict[str, dict[str, Any]] = {} + for row in _read_jsonl(path): + prediction = _object(row.get("prediction"), "E40 prediction") + reference = _object(row.get("reference"), "E40 reference") + if ( + row.get("split") != "validation" + or row.get("scored") is not True + or prediction == reference + ): + continue + item_id = _string(row.get("item_id"), "E40 item id") + if item_id in rows: + raise E52CameraFirstRestorationError("E40 mismatch identity is duplicated") + rows[item_id] = row + if not rows: + raise E52CameraFirstRestorationError("E40 mismatch set is empty") + return rows + + +def _materialized_rows( + path: Path, + item_ids: set[str], +) -> dict[str, dict[str, Any]]: + rows = {str(row["item_id"]): row for row in _read_jsonl(path) if row.get("item_id") in item_ids} + if set(rows) != item_ids: + raise E52CameraFirstRestorationError("E30 confirmed case coverage is incomplete") + return rows + + +def _selected_detector_frames( + path: Path, + frame_indices: set[int], +) -> dict[int, dict[str, Any]]: + frames: dict[int, dict[str, Any]] = {} + for row in _read_jsonl(path): + frame_index = row.get("frame_index") + if frame_index not in frame_indices: + continue + if ( + isinstance(frame_index, bool) + or not isinstance(frame_index, int) + or frame_index in frames + ): + raise E52CameraFirstRestorationError("detector frame identity is invalid") + frames[frame_index] = row + if set(frames) != frame_indices: + raise E52CameraFirstRestorationError("detector frame coverage is incomplete") + return frames + + +def _label_family(value: object, profile: dict[str, Any]) -> str | None: + if not isinstance(value, str): + return None + groups = _object(profile.get("label_groups"), "profile label groups") + for family, labels in groups.items(): + if isinstance(labels, list) and value in labels: + return str(family) + return value + + +def _box_iou(first: list[float], second: list[float]) -> float: + x1 = max(first[0], second[0]) + y1 = max(first[1], second[1]) + x2 = min(first[2], second[2]) + y2 = min(first[3], second[3]) + intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1) + 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 _bbox(value: object) -> list[float] | None: + if not isinstance(value, list) or len(value) != 4: + return None + try: + result = [float(item) for item in value] + except (TypeError, ValueError): + return None + if ( + not all(math.isfinite(item) for item in result) + or result[2] <= result[0] + or result[3] <= result[1] + ): + return None + return result + + +def _artifact_map(root: Path, value: object) -> dict[str, Path]: + if not isinstance(value, list) or len(value) != 2: + raise E52CameraFirstRestorationError("E52 artifact set is invalid") + result: dict[str, Path] = {} + for row in value: + artifact = _object(row, "E52 artifact") + role = _string(artifact.get("role"), "E52 artifact role") + if role in result: + raise E52CameraFirstRestorationError("E52 artifact role is duplicated") + result[role] = _verified_relative_artifact( + root, + artifact, + expected_role=role, + ) + if set(result) != {"restoration-cases", "restoration-report"}: + raise E52CameraFirstRestorationError("E52 artifact coverage changed") + return result + + +def _verified_relative_artifact( + root: Path, + artifact: dict[str, Any], + *, + expected_role: str | None, +) -> Path: + relative = artifact.get("path") + if ( + not isinstance(relative, str) + or Path(relative).is_absolute() + or ".." in Path(relative).parts + or (expected_role is not None and artifact.get("role") != expected_role) + ): + raise E52CameraFirstRestorationError("artifact path is invalid") + path = _regular_file(root, relative) + if artifact.get("byte_length") != path.stat().st_size or artifact.get("sha256") != _sha256( + path + ): + raise E52CameraFirstRestorationError("artifact content changed") + return path + + +def _regular_file(root: Path, relative: str) -> Path: + candidate = root / relative + if candidate.is_symlink(): + raise E52CameraFirstRestorationError("artifact is a symlink") + resolved = candidate.resolve(strict=True) + try: + resolved.relative_to(root.resolve(strict=True)) + except ValueError as exc: + raise E52CameraFirstRestorationError("artifact escaped its root") from exc + if not resolved.is_file(): + raise E52CameraFirstRestorationError("artifact is not a regular file") + return resolved + + +def _artifact(path: Path, role: str) -> dict[str, Any]: + return { + "path": path.name, + "role": role, + "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, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise E52CameraFirstRestorationError(f"invalid JSON: {path.name}") from exc + return _object(value, path.name) + + +def _read_jsonl(path: Path) -> tuple[dict[str, Any], ...]: + try: + with path.open("r", encoding="utf-8") as stream: + rows = tuple(_object(json.loads(line), path.name) for line in stream if line.strip()) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise E52CameraFirstRestorationError(f"invalid JSONL: {path.name}") from exc + return rows + + +def _write_json(path: Path, value: object) -> None: + path.write_bytes(_canonical_json(value) + b"\n") + + +def _canonical_json(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +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 _object(value: object, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise E52CameraFirstRestorationError(f"{label} is not an object") + return value + + +def _string(value: object, label: str) -> str: + if not isinstance(value, str) or not value: + raise E52CameraFirstRestorationError(f"{label} is invalid") + return value + + +def _integer(value: object, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise E52CameraFirstRestorationError(f"{label} is invalid") + return value + + +def _number(value: object, label: str) -> float: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(float(value)) + ): + raise E52CameraFirstRestorationError(f"{label} is invalid") + return float(value) + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") diff --git a/tests/test_e52_camera_first_restoration.py b/tests/test_e52_camera_first_restoration.py new file mode 100644 index 0000000..d447281 --- /dev/null +++ b/tests/test_e52_camera_first_restoration.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from typing import Any + +import numpy as np + +from k1link.compute.e52_camera_first_restoration import ( + E52_CASE_SCHEMA, + evaluate_camera_first_case, +) + +PROFILE: dict[str, Any] = { + "target_labels": ["person", "car", "bus", "truck"], + "label_groups": { + "person": ["person"], + "vehicle": ["car", "bus", "truck"], + }, + "minimum_detector_score": 0.5, + "maximum_bbox_image_fraction": 0.25, + "minimum_selected_lidar_points": 4, + "minimum_selected_lidar_coverage": 0.7, + "minimum_original_bbox_iou": 0.25, +} + + +def _item(*, stratum: str, snapshot: dict[str, Any]) -> dict[str, Any]: + return { + "item_id": "e30-review-item-" + "a" * 64, + "stratum": stratum, + "evidence_binding": {"source_frame_index": 259}, + "materialization": { + "projection_width": 800, + "projection_height": 600, + }, + "e29_snapshot": snapshot, + } + + +def _case(*, stratum: str) -> dict[str, Any]: + return { + "sequence": 7, + "source_stratum": stratum, + "prediction": { + "presence": "occupied-environment", + "geometry_association": "independent-occupied", + "freshness": "current", + }, + "reference": { + "presence": "object-present", + "geometry_association": "object-associated", + "freshness": "current", + }, + } + + +def _frame(instances: list[dict[str, Any]]) -> dict[str, Any]: + return { + "schema_version": "missioncore.panoptic-frame/v1", + "frame_index": 259, + "instances": instances, + } + + +def test_e52_restores_camera_semantic_then_derives_lidar_range() -> None: + pixels = np.asarray([[150.0, 225.0], [152.0, 230.0], [155.0, 235.0], [160.0, 240.0]]) + depths = np.asarray([4.0, 4.2, 4.4, 4.6]) + result = evaluate_camera_first_case( + item=_item( + stratum="geometry-only", + snapshot={ + "geometry_status": "single-source-geometry", + "nearest_range_m": 3.9, + }, + ), + e40_case=_case(stratum="geometry-only"), + detector_frame=_frame( + [ + { + "instance_id": 1, + "label": "person", + "score": 0.99, + "box_xyxy": [140.0, 210.0, 220.0, 450.0], + } + ] + ), + projected_pixels_xy=pixels, + projected_depth_m=depths, + projected_selected_mask=np.ones(4, dtype=np.uint8), + profile=PROFILE, + ) + + assert result["schema_version"] == E52_CASE_SCHEMA + assert result["restoration"] == { + "state": "camera-semantic-restored-with-lidar-range", + "semantic_owner": "camera", + "semantic_label": "person", + "metric_geometry_owner": "lidar", + "range_m": 4.3, + "runtime_publishable": False, + "requires_shadow_acceptance": True, + } + + +def test_e52_rejects_pathological_full_frame_detector_box() -> None: + pixels = np.asarray([[150.0, 225.0], [152.0, 230.0], [155.0, 235.0], [160.0, 240.0]]) + result = evaluate_camera_first_case( + item=_item( + stratum="geometry-only", + snapshot={"geometry_status": "single-source-geometry"}, + ), + e40_case=_case(stratum="geometry-only"), + detector_frame=_frame( + [ + { + "instance_id": 1, + "label": "car", + "score": 0.9, + "box_xyxy": [0.0, 0.0, 800.0, 600.0], + } + ] + ), + projected_pixels_xy=pixels, + projected_depth_m=np.asarray([4.0, 4.2, 4.4, 4.6]), + projected_selected_mask=np.ones(4, dtype=np.uint8), + profile=PROFILE, + ) + + assert result["candidate_camera"]["selected"] is None + assert result["candidate_camera"]["rejected_pathological_large_box_count"] == 1 + assert result["restoration"]["state"] == "unresolved-no-camera-semantic" + + +def test_e52_corroborates_existing_camera_semantic_without_inventing_range() -> None: + result = evaluate_camera_first_case( + item=_item( + stratum="camera-only", + snapshot={ + "label": "bus", + "score": 0.4, + "bbox_xyxy": [100.0, 100.0, 200.0, 200.0], + "geometry_status": "camera-only", + "range_m": None, + }, + ), + e40_case=_case(stratum="camera-only"), + detector_frame=_frame( + [ + { + "instance_id": 4, + "label": "truck", + "score": 0.95, + "box_xyxy": [105.0, 105.0, 205.0, 205.0], + } + ] + ), + projected_pixels_xy=np.empty((0, 2), dtype=np.float64), + projected_depth_m=np.empty(0, dtype=np.float64), + projected_selected_mask=np.empty(0, dtype=np.uint8), + profile=PROFILE, + ) + + assert result["restoration"]["state"] == "camera-semantic-corroborated-no-lidar-range" + assert result["restoration"]["semantic_label"] == "truck" + assert result["restoration"]["range_m"] is None