feat(lab): add recorded realtime spatial playback
This commit is contained in:
@@ -767,6 +767,7 @@ app.include_router(
|
||||
root_provider=lambda: (
|
||||
REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "m4" / "replay-threat"
|
||||
),
|
||||
repository_root_provider=lambda: REPOSITORY_ROOT,
|
||||
camera_frame_provider=(
|
||||
session_recorded_camera_frame_service.extract
|
||||
if session_recorded_camera_frame_service is not None
|
||||
|
||||
@@ -22,11 +22,15 @@ from k1link.perception.threat_replay import (
|
||||
ThreatReplayResult,
|
||||
read_threat_replay_result,
|
||||
)
|
||||
from k1link.perception.threat_timeline import (
|
||||
RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
||||
RecordedThreatTimeline,
|
||||
RecordedThreatTimelineError,
|
||||
)
|
||||
from k1link.sessions import RecordedCameraFrame, SessionIntegrityError
|
||||
|
||||
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]
|
||||
@@ -36,6 +40,7 @@ CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
|
||||
def build_m4_threat_replay_router(
|
||||
*,
|
||||
root_provider: RootProvider = lambda: None,
|
||||
repository_root_provider: RootProvider = lambda: None,
|
||||
camera_frame_provider: CameraFrameProvider | None = None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory/m4-threat", tags=["laboratory"])
|
||||
@@ -54,6 +59,23 @@ def build_m4_threat_replay_router(
|
||||
except (ThreatReplayError, OSError, ValueError):
|
||||
raise HTTPException(status_code=404, detail="M4.6 result не найден") from None
|
||||
|
||||
def timeline(result_id: str) -> RecordedThreatTimeline:
|
||||
frozen = result(result_id)
|
||||
repository_root = _configured_root(repository_root_provider)
|
||||
if repository_root is None:
|
||||
raise HTTPException(status_code=503, detail="M4.6 timeline source недоступен")
|
||||
try:
|
||||
return _read_threat_timeline_cached(
|
||||
str(repository_root),
|
||||
str(frozen.result_root),
|
||||
_result_signature(frozen.result_root),
|
||||
)
|
||||
except (OSError, ThreatReplayError, RecordedThreatTimelineError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="M4.6 bounded timeline не прошёл проверку",
|
||||
) from None
|
||||
|
||||
@router.get("/results")
|
||||
def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]:
|
||||
candidates = _candidates(root_provider)
|
||||
@@ -134,41 +156,38 @@ def build_m4_threat_replay_router(
|
||||
sequence = frames[ordinal - 1].get("sequence")
|
||||
if not isinstance(session_id, str) or not isinstance(sequence, int):
|
||||
raise HTTPException(status_code=404, detail="M4.6 camera identity не найдена")
|
||||
try:
|
||||
camera = camera_frame_provider(session_id, sequence)
|
||||
except (OSError, SessionIntegrityError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="M4.6 exact camera frame недоступен",
|
||||
) from None
|
||||
if camera.width != 800 or camera.height != 600:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="M4.6 camera frame нарушил размерный контракт",
|
||||
)
|
||||
return Response(
|
||||
content=camera.payload,
|
||||
media_type=camera.media_type,
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{camera.sha256}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
return _camera_response(camera_frame_provider, session_id, sequence)
|
||||
|
||||
@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"]),
|
||||
)
|
||||
)
|
||||
@router.get("/results/{result_id}/timeline")
|
||||
def get_timeline(result_id: str) -> dict[str, object]:
|
||||
return copy.deepcopy(timeline(result_id).metadata())
|
||||
|
||||
@router.get("/results/{result_id}/timeline/chunk")
|
||||
def get_timeline_chunk(
|
||||
result_id: str,
|
||||
start: int = Query(default=0, ge=0),
|
||||
count: int = Query(
|
||||
default=12,
|
||||
ge=1,
|
||||
le=RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
return timeline(result_id).chunk(start_sequence=start, frame_count=count)
|
||||
except RecordedThreatTimelineError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="M4.6 timeline chunk не найден",
|
||||
) from None
|
||||
|
||||
@router.get("/results/{result_id}/timeline/frames/{sequence}/camera")
|
||||
def get_timeline_camera(result_id: str, sequence: int) -> Response:
|
||||
if camera_frame_provider is None:
|
||||
raise HTTPException(status_code=503, detail="M4.6 camera decoder недоступен")
|
||||
projected = timeline(result_id)
|
||||
if not 0 <= sequence < len(projected.index.source_times_ns):
|
||||
raise HTTPException(status_code=404, detail="M4.6 timeline frame не найден")
|
||||
return _camera_response(camera_frame_provider, projected.profile.session_id, sequence)
|
||||
|
||||
return router
|
||||
|
||||
@@ -183,53 +202,16 @@ def _read_threat_result_cached(
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _cached_video_overlay(
|
||||
result_id: str,
|
||||
def _read_threat_timeline_cached(
|
||||
repository_root_value: 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")
|
||||
not in {THREAT_REPLAY_FRAME_SCHEMA, THREAT_REPLAY_FRAME_SCHEMA_V2}
|
||||
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",
|
||||
}
|
||||
signature: tuple[int, ...],
|
||||
) -> RecordedThreatTimeline:
|
||||
result = _read_threat_result_cached(root_value, signature)
|
||||
return RecordedThreatTimeline(
|
||||
repository_root=Path(repository_root_value),
|
||||
result=result,
|
||||
)
|
||||
|
||||
|
||||
def _project_result(result: ThreatReplayResult) -> dict[str, object]:
|
||||
@@ -261,14 +243,32 @@ def _project_result(result: ThreatReplayResult) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
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 _camera_response(
|
||||
provider: CameraFrameProvider,
|
||||
session_id: str,
|
||||
sequence: int,
|
||||
) -> Response:
|
||||
try:
|
||||
camera = provider(session_id, sequence)
|
||||
except (OSError, SessionIntegrityError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="M4.6 exact camera frame недоступен",
|
||||
) from None
|
||||
if camera.width != 800 or camera.height != 600:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="M4.6 camera frame нарушил размерный контракт",
|
||||
)
|
||||
return Response(
|
||||
content=camera.payload,
|
||||
media_type=camera.media_type,
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{camera.sha256}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _configured_root(provider: RootProvider) -> Path | None:
|
||||
@@ -343,15 +343,8 @@ def _array(value: object) -> list[object]:
|
||||
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