from __future__ import annotations import os import re from collections.abc import Callable from pathlib import Path from typing import Annotated, Any, Final from fastapi import APIRouter, HTTPException, Query, Response from k1link.compute import ( LidarFieldReviewV1, LidarGroundBenchmarkV1, LidarGroundError, LidarReplayError, LidarReplayPackV2, lidar_field_review_catalog_item, lidar_ground_benchmark_catalog_item, lidar_ground_frame_detail, lidar_pack_catalog_item, lidar_pack_detail, ) from k1link.datasets import ( RELLIS_SOURCE_ID, DatasetAdmissionError, configured_dataset_admission_manifest, configured_dataset_ground_preview, configured_dataset_preview, dataset_gateway_catalog, read_dataset_ground_preview, read_dataset_native_scan_preview, ) from k1link.web.lidar_local_surface_service import ( K1LocalSurfaceReadService, LocalSurfaceSourceUnavailable, ) LIDAR_CATALOG_SCHEMA: Final = "missioncore.lidar-replay-pack-catalog/v1" LIDAR_GROUND_CATALOG_SCHEMA: Final = "missioncore.lidar-ground-benchmark-catalog/v1" LIDAR_FIELD_REVIEW_CATALOG_SCHEMA: Final = "missioncore.lidar-field-review-catalog/v1" K1_LOCAL_SURFACE_CATALOG_SCHEMA: Final = "missioncore.k1-local-surface-catalog/v1" _PACK_ID = re.compile(r"^lidar-replay-pack-[a-f0-9]{64}$") _BENCHMARK_ID = re.compile(r"^ground-benchmark-[a-f0-9]{64}$") _FIELD_REVIEW_ID = re.compile(r"^lidar-field-review-[a-f0-9]{64}$") _LOCAL_SURFACE_ID = re.compile(r"^k1-local-surface-[a-f0-9]{64}$") RootProvider = Callable[[], Path | None] DatasetArtifactProvider = Callable[[], Path | None] def configured_lidar_replay_root() -> Path | None: value = os.environ.get("MISSIONCORE_LIDAR_REPLAY_ROOT", "").strip() return Path(value).expanduser().absolute() if value else None def configured_lidar_ground_root() -> Path | None: value = os.environ.get("MISSIONCORE_LIDAR_GROUND_ROOT", "").strip() return Path(value).expanduser().absolute() if value else None def configured_lidar_field_review_root() -> Path | None: value = os.environ.get("MISSIONCORE_LIDAR_FIELD_REVIEW_ROOT", "").strip() return Path(value).expanduser().absolute() if value else None def configured_k1_local_surface_root() -> Path | None: value = os.environ.get("MISSIONCORE_K1_LOCAL_SURFACE_ROOT", "").strip() return Path(value).expanduser().absolute() if value else None def configured_e10_lidar_source_root() -> Path | None: value = os.environ.get("MISSIONCORE_E10_LIDAR_SOURCE_ROOT", "").strip() return Path(value).expanduser().absolute() if value else None def build_lidar_router( *, root_provider: RootProvider = configured_lidar_replay_root, ground_root_provider: RootProvider = configured_lidar_ground_root, field_review_root_provider: RootProvider = configured_lidar_field_review_root, local_surface_root_provider: RootProvider = configured_k1_local_surface_root, e10_source_root_provider: RootProvider = configured_e10_lidar_source_root, dataset_admission_provider: DatasetArtifactProvider = configured_dataset_admission_manifest, dataset_preview_provider: DatasetArtifactProvider = configured_dataset_preview, dataset_rellis_preview_provider: DatasetArtifactProvider = lambda: None, dataset_rellis_admission_provider: DatasetArtifactProvider = lambda: None, dataset_ground_preview_provider: DatasetArtifactProvider = configured_dataset_ground_preview, local_surface_read_service: K1LocalSurfaceReadService | None = None, ) -> APIRouter: router = APIRouter(prefix="/api/v1/lidar", tags=["lidar"]) surface_reader = local_surface_read_service or K1LocalSurfaceReadService() @router.get("/dataset-gateway") def get_dataset_gateway() -> dict[str, object]: return dataset_gateway_catalog( admission_manifest_path=dataset_admission_provider(), rellis_preview_path=dataset_rellis_preview_provider(), rellis_admission_path=dataset_rellis_admission_provider(), ) @router.get("/dataset-gateway/preview") def get_dataset_gateway_preview( source_id: Annotated[ str, Query(min_length=1, max_length=160), ] = "goose-3d/v2025-08-22", ) -> dict[str, Any]: if source_id == "goose-3d/v2025-08-22": path = dataset_preview_provider() elif source_id == RELLIS_SOURCE_ID: path = dataset_rellis_preview_provider() else: raise HTTPException(status_code=404, detail="Dataset source не найден.") if path is None or not path.is_file(): raise HTTPException( status_code=404, detail="Размеченный native scan источника ещё не импортирован.", ) try: preview = read_dataset_native_scan_preview(path) if preview["source_id"] != source_id: raise DatasetAdmissionError("dataset preview source mismatch") return preview except DatasetAdmissionError as exc: raise HTTPException( status_code=500, detail="Preview датасета не прошло проверку целостности.", ) from exc @router.get("/dataset-gateway/ground-comparison") def get_dataset_gateway_ground_comparison() -> dict[str, Any]: path = dataset_ground_preview_provider() if path is None or not path.is_file(): raise HTTPException( status_code=404, detail="Ground baseline ещё не сравнивался с GOOSE-разметкой.", ) try: return read_dataset_ground_preview(path) except DatasetAdmissionError as exc: raise HTTPException( status_code=500, detail="Ground comparison не прошло проверку целостности.", ) from exc @router.get("/replay-packs") def list_lidar_replay_packs( limit: int = Query(default=20, ge=1, le=100), ) -> dict[str, Any]: root = root_provider() if root is None or not root.is_dir(): return { "schema_version": LIDAR_CATALOG_SCHEMA, "configured": root is not None, "items": [], "valid_total": 0, "invalid_total": 0, "duplicate_total": 0, "access": "read-only", } items: list[dict[str, object]] = [] invalid_total = 0 duplicate_total = 0 seen_logical_content: set[str] = set() candidates = sorted( ( candidate for candidate in root.iterdir() if candidate.is_dir() and _PACK_ID.fullmatch(candidate.name) is not None ), key=lambda candidate: candidate.stat().st_mtime_ns, reverse=True, ) for candidate in candidates: try: pack = LidarReplayPackV2(candidate) try: item = lidar_pack_catalog_item(pack) logical_content = str(item["logical_content_sha256"]) if logical_content in seen_logical_content: duplicate_total += 1 continue seen_logical_content.add(logical_content) item["created_at_utc"] = pack.manifest.get("created_at_utc") items.append(item) finally: pack.close() except (LidarReplayError, OSError): invalid_total += 1 return { "schema_version": LIDAR_CATALOG_SCHEMA, "configured": True, "items": items[:limit], "valid_total": len(items), "invalid_total": invalid_total, "duplicate_total": duplicate_total, "access": "read-only", } @router.get("/replay-packs/{pack_id}") def get_lidar_replay_pack(pack_id: str) -> dict[str, object]: if _PACK_ID.fullmatch(pack_id) is None: raise HTTPException(status_code=404, detail="LiDAR replay pack не найден") root = root_provider() if root is None or not root.is_dir(): raise HTTPException(status_code=503, detail="LiDAR replay storage не настроен") candidate = root / pack_id if not candidate.is_dir(): raise HTTPException(status_code=404, detail="LiDAR replay pack не найден") try: pack = LidarReplayPackV2(candidate) try: return lidar_pack_detail(pack) finally: pack.close() except (LidarReplayError, OSError) as exc: raise HTTPException( status_code=409, detail="LiDAR replay pack не прошёл проверку целостности", ) from exc @router.get("/ground-benchmarks") def list_lidar_ground_benchmarks( pack_id: str | None = Query(default=None), limit: int = Query(default=20, ge=1, le=100), ) -> dict[str, Any]: if pack_id is not None and _PACK_ID.fullmatch(pack_id) is None: raise HTTPException(status_code=404, detail="LiDAR replay pack не найден") root = ground_root_provider() if root is None or not root.is_dir(): return { "schema_version": LIDAR_GROUND_CATALOG_SCHEMA, "configured": root is not None, "items": [], "valid_total": 0, "invalid_total": 0, "access": "read-only", } items: list[dict[str, object]] = [] invalid_total = 0 candidates = sorted( ( candidate for candidate in root.iterdir() if candidate.is_dir() and _BENCHMARK_ID.fullmatch(candidate.name) is not None ), key=lambda candidate: candidate.stat().st_mtime_ns, reverse=True, ) for candidate in candidates: try: benchmark = LidarGroundBenchmarkV1(candidate) try: if pack_id is None or benchmark.identity.get("replay_pack_id") == pack_id: items.append(lidar_ground_benchmark_catalog_item(benchmark)) finally: benchmark.close() except (LidarGroundError, OSError): invalid_total += 1 return { "schema_version": LIDAR_GROUND_CATALOG_SCHEMA, "configured": True, "items": items[:limit], "valid_total": len(items), "invalid_total": invalid_total, "access": "read-only", } @router.get("/ground-benchmarks/{benchmark_id}") def get_lidar_ground_benchmark(benchmark_id: str) -> dict[str, object]: if _BENCHMARK_ID.fullmatch(benchmark_id) is None: raise HTTPException( status_code=404, detail="LiDAR ground benchmark не найден", ) root = ground_root_provider() if root is None or not root.is_dir(): raise HTTPException( status_code=503, detail="LiDAR ground storage не настроен", ) candidate = root / benchmark_id if not candidate.is_dir(): raise HTTPException( status_code=404, detail="LiDAR ground benchmark не найден", ) try: benchmark = LidarGroundBenchmarkV1(candidate) try: return { "schema_version": ("missioncore.lidar-ground-benchmark-detail/v1"), "benchmark": lidar_ground_benchmark_catalog_item(benchmark), "report": benchmark.report, "access": "read-only", } finally: benchmark.close() except (LidarGroundError, OSError) as exc: raise HTTPException( status_code=409, detail="LiDAR ground benchmark не прошёл проверку целостности", ) from exc @router.get("/ground-benchmarks/{benchmark_id}/frames/{frame_index}") def get_lidar_ground_frame( benchmark_id: str, frame_index: int, ) -> dict[str, object]: if _BENCHMARK_ID.fullmatch(benchmark_id) is None or frame_index < 0: raise HTTPException( status_code=404, detail="LiDAR ground frame не найден", ) ground_root = ground_root_provider() replay_root = root_provider() if ground_root is None or not ground_root.is_dir(): raise HTTPException( status_code=503, detail="LiDAR ground storage не настроен", ) if replay_root is None or not replay_root.is_dir(): raise HTTPException( status_code=503, detail="LiDAR replay storage не настроен", ) benchmark_path = ground_root / benchmark_id if not benchmark_path.is_dir(): raise HTTPException( status_code=404, detail="LiDAR ground benchmark не найден", ) try: benchmark = LidarGroundBenchmarkV1(benchmark_path) try: replay_pack_id = benchmark.identity.get("replay_pack_id") if ( not isinstance(replay_pack_id, str) or _PACK_ID.fullmatch(replay_pack_id) is None ): raise LidarGroundError("Ground benchmark replay identity is invalid") replay_path = replay_root / replay_pack_id if not replay_path.is_dir(): raise HTTPException( status_code=404, detail="Связанный LiDAR replay pack не найден", ) replay = LidarReplayPackV2(replay_path) try: return lidar_ground_frame_detail( benchmark, replay, frame_index, ) finally: replay.close() finally: benchmark.close() except IndexError as exc: raise HTTPException( status_code=404, detail="LiDAR ground frame не найден", ) from exc except HTTPException: raise except (LidarGroundError, LidarReplayError, OSError) as exc: raise HTTPException( status_code=409, detail="LiDAR ground frame не прошёл проверку целостности", ) from exc @router.get("/field-reviews") def list_lidar_field_reviews( limit: int = Query(default=10, ge=1, le=50), ) -> dict[str, Any]: root = field_review_root_provider() if root is None or not root.is_dir(): return { "schema_version": LIDAR_FIELD_REVIEW_CATALOG_SCHEMA, "configured": root is not None, "items": [], "valid_total": 0, "invalid_total": 0, "access": "read-only", } items: list[dict[str, object]] = [] invalid_total = 0 candidates = sorted( ( candidate for candidate in root.iterdir() if candidate.is_dir() and _FIELD_REVIEW_ID.fullmatch(candidate.name) is not None ), key=lambda candidate: candidate.stat().st_mtime_ns, reverse=True, ) for candidate in candidates: try: review = LidarFieldReviewV1(candidate) try: items.append(lidar_field_review_catalog_item(review)) finally: review.close() except (LidarGroundError, OSError): invalid_total += 1 return { "schema_version": LIDAR_FIELD_REVIEW_CATALOG_SCHEMA, "configured": True, "items": items[:limit], "valid_total": len(items), "invalid_total": invalid_total, "access": "read-only", } @router.get("/field-reviews/{review_id}/windows/{window_index}") def get_lidar_field_review_window( review_id: str, window_index: int, ) -> dict[str, object]: if _FIELD_REVIEW_ID.fullmatch(review_id) is None or window_index < 0: raise HTTPException( status_code=404, detail="LiDAR field-review window не найден", ) root = field_review_root_provider() if root is None or not root.is_dir(): raise HTTPException( status_code=503, detail="LiDAR field-review storage не настроен", ) candidate = root / review_id if not candidate.is_dir(): raise HTTPException( status_code=404, detail="LiDAR field review не найден", ) try: review = LidarFieldReviewV1(candidate) try: return review.window_detail(window_index) finally: review.close() except IndexError as exc: raise HTTPException( status_code=404, detail="LiDAR field-review window не найден", ) from exc except (LidarGroundError, OSError) as exc: raise HTTPException( status_code=409, detail="LiDAR field review не прошёл проверку целостности", ) from exc @router.get("/field-reviews/{review_id}/windows/{window_index}/preview") def get_lidar_field_review_preview( review_id: str, window_index: int, ) -> Response: if _FIELD_REVIEW_ID.fullmatch(review_id) is None or window_index < 0: raise HTTPException( status_code=404, detail="LiDAR field-review preview не найден", ) root = field_review_root_provider() if root is None or not root.is_dir(): raise HTTPException( status_code=503, detail="LiDAR field-review storage не настроен", ) candidate = root / review_id if not candidate.is_dir(): raise HTTPException( status_code=404, detail="LiDAR field review не найден", ) try: review = LidarFieldReviewV1(candidate) try: windows = review.report.get("windows") if not isinstance(windows, list) or not 0 <= window_index < len(windows): raise IndexError(window_index) window = windows[window_index] if not isinstance(window, dict) or not isinstance(window.get("key"), str): raise LidarGroundError("LiDAR field-review preview key is invalid") preview = review.preview_paths.get(window["key"]) if preview is None: raise LidarGroundError("LiDAR field-review preview is missing") content = preview.read_bytes() finally: review.close() except IndexError as exc: raise HTTPException( status_code=404, detail="LiDAR field-review preview не найден", ) from exc except (LidarGroundError, OSError) as exc: raise HTTPException( status_code=409, detail="LiDAR field-review preview не прошёл проверку", ) from exc return Response( content=content, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=31536000, immutable"}, ) @router.get("/local-surfaces") def list_k1_local_surfaces( limit: int = Query(default=10, ge=1, le=50), ) -> dict[str, Any]: root = local_surface_root_provider() if root is None or not root.is_dir(): return { "schema_version": K1_LOCAL_SURFACE_CATALOG_SCHEMA, "configured": root is not None, "items": [], "valid_total": 0, "invalid_total": 0, "access": "read-only", } items: list[dict[str, object]] = [] invalid_total = 0 candidates = sorted( ( candidate for candidate in root.iterdir() if candidate.is_dir() and _LOCAL_SURFACE_ID.fullmatch(candidate.name) is not None ), key=lambda candidate: candidate.stat().st_mtime_ns, reverse=True, ) for candidate in candidates: if len(items) >= limit: break try: items.append(surface_reader.catalog_item(candidate)) except (LidarGroundError, OSError): invalid_total += 1 return { "schema_version": K1_LOCAL_SURFACE_CATALOG_SCHEMA, "configured": True, "items": items, "valid_total": len(items), "invalid_total": invalid_total, "candidate_total": len(candidates), "scan_complete": len(items) + invalid_total == len(candidates), "access": "read-only", } @router.get("/local-surfaces/{model_id}/timeline") def get_k1_local_surface_timeline(model_id: str) -> dict[str, object]: if _LOCAL_SURFACE_ID.fullmatch(model_id) is None: raise HTTPException( status_code=404, detail="K1 local-surface timeline не найден", ) model_root = local_surface_root_provider() source_root = e10_source_root_provider() if model_root is None or not model_root.is_dir(): raise HTTPException( status_code=503, detail="K1 local-surface storage не настроен", ) if source_root is None or not source_root.is_dir(): raise HTTPException( status_code=503, detail="E10 LiDAR source storage не настроен", ) model_path = model_root / model_id if not model_path.is_dir(): raise HTTPException( status_code=404, detail="K1 local-surface model не найден", ) try: return surface_reader.timeline_detail(model_path, source_root) except LocalSurfaceSourceUnavailable as exc: raise HTTPException( status_code=404, detail="Связанный E10 LiDAR source не найден", ) from exc except (LidarGroundError, OSError) as exc: raise HTTPException( status_code=409, detail="K1 local-surface timeline не прошёл проверку целостности", ) from exc @router.get("/local-surfaces/{model_id}/review") def get_k1_local_surface_review(model_id: str) -> dict[str, object]: if _LOCAL_SURFACE_ID.fullmatch(model_id) is None: raise HTTPException( status_code=404, detail="K1 local-surface review не найден", ) model_root = local_surface_root_provider() source_root = e10_source_root_provider() if model_root is None or not model_root.is_dir(): raise HTTPException( status_code=503, detail="K1 local-surface storage не настроен", ) if source_root is None or not source_root.is_dir(): raise HTTPException( status_code=503, detail="E10 LiDAR source storage не настроен", ) model_path = model_root / model_id if not model_path.is_dir(): raise HTTPException( status_code=404, detail="K1 local-surface model не найден", ) try: return surface_reader.review_detail(model_path, source_root) except LocalSurfaceSourceUnavailable as exc: raise HTTPException( status_code=404, detail="Связанный E10 LiDAR source не найден", ) from exc except (LidarGroundError, OSError) as exc: raise HTTPException( status_code=409, detail="K1 local-surface review не прошёл проверку целостности", ) from exc @router.get("/local-surfaces/{model_id}/frames/{frame_index}") def get_k1_local_surface_frame( model_id: str, frame_index: int, ) -> dict[str, object]: if _LOCAL_SURFACE_ID.fullmatch(model_id) is None or frame_index < 0: raise HTTPException( status_code=404, detail="K1 local-surface frame не найден", ) model_root = local_surface_root_provider() source_root = e10_source_root_provider() if model_root is None or not model_root.is_dir(): raise HTTPException( status_code=503, detail="K1 local-surface storage не настроен", ) if source_root is None or not source_root.is_dir(): raise HTTPException( status_code=503, detail="E10 LiDAR source storage не настроен", ) model_path = model_root / model_id if not model_path.is_dir(): raise HTTPException( status_code=404, detail="K1 local-surface model не найден", ) try: return surface_reader.frame_detail(model_path, source_root, frame_index) except IndexError as exc: raise HTTPException( status_code=404, detail="K1 local-surface frame не найден", ) from exc except LocalSurfaceSourceUnavailable as exc: raise HTTPException( status_code=404, detail="Связанный E10 LiDAR source не найден", ) from exc except (LidarGroundError, OSError) as exc: raise HTTPException( status_code=409, detail="K1 local-surface evidence не прошло проверку целостности", ) from exc return router