feat(perception): add dual evidence replay threat
This commit is contained in:
@@ -112,6 +112,7 @@ from k1link.web.laboratory_api import build_laboratory_router
|
||||
from k1link.web.laboratory_report_api import build_laboratory_report_router
|
||||
from k1link.web.lidar_api import build_lidar_router
|
||||
from k1link.web.lidar_local_surface_service import K1LocalSurfaceReadService
|
||||
from k1link.web.m4_threat_replay_api import build_m4_threat_replay_router
|
||||
from k1link.web.map_api import (
|
||||
MapGatewayConfiguration,
|
||||
MapGatewayProxy,
|
||||
@@ -752,6 +753,17 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_m4_threat_replay_router(
|
||||
root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "m4"
|
||||
/ "replay-threat"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e46e_ready_stack_router(
|
||||
root_provider=lambda: (
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Read-only LAB projection of the canonical M4.6 replay threat result."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable, Iterator
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from k1link.perception.threat_replay import (
|
||||
THREAT_REPLAY_FRAME_SCHEMA,
|
||||
THREAT_REPLAY_RESULT_PREFIX,
|
||||
THREAT_REPLAY_VISUAL_SCHEMA,
|
||||
ThreatReplayError,
|
||||
ThreatReplayResult,
|
||||
read_threat_replay_result,
|
||||
)
|
||||
|
||||
M4_THREAT_CATALOG_SCHEMA: Final = "missioncore.m4-threat-replay-catalog/v1"
|
||||
M4_THREAT_VIEW_SCHEMA: Final = "missioncore.m4-threat-replay-view/v1"
|
||||
M4_THREAT_VIDEO_SCHEMA: Final = "missioncore.m4-threat-video-overlay/v1"
|
||||
M4_THREAT_VISUAL_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.m4-threat-visual-catalog/v1"
|
||||
)
|
||||
_RESULT_ID = re.compile(rf"^{THREAT_REPLAY_RESULT_PREFIX}[a-f0-9]{{64}}$")
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
||||
|
||||
def build_m4_threat_replay_router(
|
||||
*,
|
||||
root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory/m4-threat", tags=["laboratory"])
|
||||
|
||||
def result(result_id: str) -> ThreatReplayResult:
|
||||
if _RESULT_ID.fullmatch(result_id) is None:
|
||||
raise HTTPException(status_code=404, detail="M4.6 result не найден")
|
||||
root = _configured_root(root_provider)
|
||||
if root is None:
|
||||
raise HTTPException(status_code=404, detail="M4.6 result не найден")
|
||||
path = (root / result_id).resolve()
|
||||
if path.parent != root or path.is_symlink():
|
||||
raise HTTPException(status_code=404, detail="M4.6 result не найден")
|
||||
try:
|
||||
return _read_threat_result_cached(str(path), _result_signature(path))
|
||||
except (ThreatReplayError, OSError, ValueError):
|
||||
raise HTTPException(status_code=404, detail="M4.6 result не найден") from None
|
||||
|
||||
@router.get("/results")
|
||||
def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]:
|
||||
candidates = _candidates(root_provider)
|
||||
items: list[dict[str, object]] = []
|
||||
invalid_total = 0
|
||||
for candidate in candidates:
|
||||
try:
|
||||
frozen = result(candidate.name)
|
||||
if len(items) < limit:
|
||||
items.append(_project_result(frozen))
|
||||
except HTTPException:
|
||||
invalid_total += 1
|
||||
return {
|
||||
"schema_version": M4_THREAT_CATALOG_SCHEMA,
|
||||
"configured": _configured_root(root_provider) is not None,
|
||||
"items": items,
|
||||
"candidate_total": len(candidates),
|
||||
"invalid_total": invalid_total,
|
||||
"access": "read-only-replay-simulated",
|
||||
}
|
||||
|
||||
@router.get("/results/{result_id}/visuals")
|
||||
def list_visuals(result_id: str) -> dict[str, object]:
|
||||
frozen = result(result_id)
|
||||
frames = _read_jsonl(frozen.result_root / "visual-frames.jsonl")
|
||||
return {
|
||||
"schema_version": M4_THREAT_VISUAL_CATALOG_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"items": [
|
||||
{
|
||||
"ordinal": index + 1,
|
||||
"sequence": item["sequence"],
|
||||
"frame_id": item["frame_id"],
|
||||
"source_time_ns": item["source_time_ns"],
|
||||
"metric_obstacle_count": len(_array(item.get("metric_obstacles"))),
|
||||
"camera_proposal_count": len(_array(item.get("camera_proposals"))),
|
||||
"point_cloud_sample_count": item["point_cloud_sample_count"],
|
||||
}
|
||||
for index, item in enumerate(frames)
|
||||
],
|
||||
"access": "read-only-replay-simulated",
|
||||
}
|
||||
|
||||
@router.get("/results/{result_id}/visuals/{ordinal}")
|
||||
def get_visual(result_id: str, ordinal: int) -> dict[str, object]:
|
||||
frozen = result(result_id)
|
||||
if not 1 <= ordinal <= 32:
|
||||
raise HTTPException(status_code=404, detail="M4.6 visual frame не найден")
|
||||
frames = _read_jsonl(frozen.result_root / "visual-frames.jsonl")
|
||||
if len(frames) != 32:
|
||||
raise HTTPException(status_code=404, detail="M4.6 visual frame не найден")
|
||||
return {
|
||||
**copy.deepcopy(frames[ordinal - 1]),
|
||||
"result_id": result_id,
|
||||
"ordinal": ordinal,
|
||||
"ground_truth": False,
|
||||
"access": "read-only-replay-simulated",
|
||||
}
|
||||
|
||||
@router.get("/results/{result_id}/video-overlay")
|
||||
def get_video_overlay(result_id: str) -> dict[str, object]:
|
||||
frozen = result(result_id)
|
||||
identity = frozen.manifest["identity"]
|
||||
assert isinstance(identity, dict)
|
||||
return copy.deepcopy(
|
||||
_cached_video_overlay(
|
||||
result_id,
|
||||
str(frozen.result_root),
|
||||
str(identity["frames_sha256"]),
|
||||
str(identity["source_session_id"]),
|
||||
)
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _read_threat_result_cached(
|
||||
root_value: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> ThreatReplayResult:
|
||||
del signature
|
||||
return read_threat_replay_result(Path(root_value))
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _cached_video_overlay(
|
||||
result_id: str,
|
||||
root_value: str,
|
||||
frames_sha256: str,
|
||||
source_session_id: str,
|
||||
) -> dict[str, object]:
|
||||
root = Path(root_value).resolve(strict=True)
|
||||
if root.is_symlink() or not root.is_dir() or len(frames_sha256) != 64:
|
||||
raise ValueError("M4.6 video evidence identity changed")
|
||||
frames = []
|
||||
for expected_sequence, row in enumerate(_iter_jsonl(root / "frames.jsonl")):
|
||||
if (
|
||||
row.get("schema_version") != THREAT_REPLAY_FRAME_SCHEMA
|
||||
or row.get("sequence") != expected_sequence
|
||||
):
|
||||
raise ValueError("M4.6 video frame order changed")
|
||||
frames.append(
|
||||
{
|
||||
"frame_index": expected_sequence,
|
||||
"session_seconds": _nonnegative_int(
|
||||
row.get("source_time_ns"), "source time"
|
||||
)
|
||||
/ 1_000_000_000,
|
||||
"source_available": row["source_available"],
|
||||
"camera_proposals": copy.deepcopy(row["camera_proposals"]),
|
||||
"decision_counts": _decision_counts(_array(row.get("assessments"))),
|
||||
}
|
||||
)
|
||||
if len(frames) != 4489:
|
||||
raise ValueError("M4.6 video frame coverage changed")
|
||||
return {
|
||||
"schema_version": M4_THREAT_VIDEO_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"recorded_source": {
|
||||
"session_id": source_session_id,
|
||||
"source_id": "sensor.camera.right",
|
||||
"synchronization": "host-arrival-best-effort",
|
||||
},
|
||||
"image_width": 800,
|
||||
"image_height": 600,
|
||||
"timeline_start_seconds": frames[0]["session_seconds"],
|
||||
"timeline_end_seconds": frames[-1]["session_seconds"],
|
||||
"frame_count": len(frames),
|
||||
"frames": frames,
|
||||
"ground_truth": False,
|
||||
"authority": "replay-simulated",
|
||||
"access": "read-only-replay-simulated-video",
|
||||
}
|
||||
|
||||
|
||||
def _project_result(result: ThreatReplayResult) -> dict[str, object]:
|
||||
identity = result.manifest["identity"]
|
||||
assert isinstance(identity, dict)
|
||||
return {
|
||||
"schema_version": M4_THREAT_VIEW_SCHEMA,
|
||||
"result_id": result.result_id,
|
||||
"created_at_utc": result.manifest["created_at_utc"],
|
||||
"status": result.report["status"],
|
||||
"profile_id": identity["profile_id"],
|
||||
"rig_profile_id": identity["rig_profile_id"],
|
||||
"corridor_profile_id": identity["corridor_profile_id"],
|
||||
"source_result_ids": {
|
||||
"detector": identity["detector_result_id"],
|
||||
"geometry": identity["geometry_result_id"],
|
||||
"temporal": identity["temporal_result_id"],
|
||||
},
|
||||
"metrics": copy.deepcopy(result.metrics),
|
||||
"configuration": copy.deepcopy(result.report["configuration"]),
|
||||
"acceptance_requirements": copy.deepcopy(
|
||||
result.report["acceptance_requirements"]
|
||||
),
|
||||
"limitations": copy.deepcopy(result.report["limitations"]),
|
||||
"accepted": result.accepted,
|
||||
"ground_truth": False,
|
||||
"authority": "replay-simulated",
|
||||
"physical_collision_accepted": False,
|
||||
"actuation_allowed": False,
|
||||
"access": "read-only-replay-simulated",
|
||||
}
|
||||
|
||||
|
||||
def _decision_counts(raw: list[object]) -> dict[str, int]:
|
||||
result = {"threat": 0, "not-threat": 0, "unknown": 0}
|
||||
for item in raw:
|
||||
assessment = item if isinstance(item, dict) else {}
|
||||
decision = assessment.get("decision")
|
||||
if isinstance(decision, str) and decision in result:
|
||||
result[decision] += 1
|
||||
return result
|
||||
|
||||
|
||||
def _configured_root(provider: RootProvider) -> Path | None:
|
||||
value = provider()
|
||||
if value is None:
|
||||
return None
|
||||
candidate = value.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
return None
|
||||
try:
|
||||
root = candidate.resolve(strict=True)
|
||||
except OSError:
|
||||
return None
|
||||
return root if root.is_dir() else None
|
||||
|
||||
|
||||
def _result_signature(root: Path) -> tuple[int, ...]:
|
||||
signature: list[int] = []
|
||||
for name in (
|
||||
"manifest.json",
|
||||
"report.json",
|
||||
"fixtures.json",
|
||||
"frames.jsonl",
|
||||
"visual-frames.jsonl",
|
||||
):
|
||||
path = root / name
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise ValueError("M4.6 result artifact is invalid")
|
||||
stat = path.stat()
|
||||
signature.extend((stat.st_size, stat.st_mtime_ns))
|
||||
return tuple(signature)
|
||||
|
||||
|
||||
def _candidates(provider: RootProvider) -> list[Path]:
|
||||
root = _configured_root(provider)
|
||||
if root is None:
|
||||
return []
|
||||
return sorted(
|
||||
(
|
||||
item
|
||||
for item in root.iterdir()
|
||||
if item.is_dir()
|
||||
and not item.is_symlink()
|
||||
and _RESULT_ID.fullmatch(item.name)
|
||||
),
|
||||
key=lambda item: item.stat().st_mtime_ns,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> list[dict[str, object]]:
|
||||
return list(_iter_jsonl(path))
|
||||
|
||||
|
||||
def _iter_jsonl(path: Path) -> Iterator[dict[str, object]]:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("M4.6 JSONL row is invalid")
|
||||
if value.get("schema_version") not in {
|
||||
THREAT_REPLAY_FRAME_SCHEMA,
|
||||
THREAT_REPLAY_VISUAL_SCHEMA,
|
||||
}:
|
||||
raise ValueError("M4.6 JSONL schema is invalid")
|
||||
yield value
|
||||
|
||||
|
||||
def _array(value: object) -> list[object]:
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("M4.6 array is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _nonnegative_int(value: object, label: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ValueError(f"M4.6 {label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"M4_THREAT_CATALOG_SCHEMA",
|
||||
"M4_THREAT_VIDEO_SCHEMA",
|
||||
"M4_THREAT_VIEW_SCHEMA",
|
||||
"M4_THREAT_VISUAL_CATALOG_SCHEMA",
|
||||
"build_m4_threat_replay_router",
|
||||
]
|
||||
Reference in New Issue
Block a user