NODEDC_MISSION_CORE/src/k1link/web/lidar_api.py

418 lines
16 KiB
Python

from __future__ import annotations
import os
import re
from collections.abc import Callable
from pathlib import Path
from typing import 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,
)
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"
_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}$")
RootProvider = 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 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,
) -> APIRouter:
router = APIRouter(prefix="/api/v1/lidar", tags=["lidar"])
@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"},
)
return router